diff --git a/scripts/gen-golden-install-parity-zcode.cjs b/scripts/gen-golden-install-parity-zcode.cjs index 832608b87..d755bc251 100644 --- a/scripts/gen-golden-install-parity-zcode.cjs +++ b/scripts/gen-golden-install-parity-zcode.cjs @@ -3,60 +3,28 @@ /** * Standalone golden-fixture generator for tests/golden-install-parity. * - * This is a BUILD-TIME generation script — NOT a test run. It replicates the - * buildParityManifest logic from tests/golden-install-parity.test.cjs and - * captures the zcode fixture so the parity test (which the gsd-test gate runs) - * has a committed artifact to compare against. The authoritative test gate - * remains `gsd-test run`, never a local `node --test`. + * This is a BUILD-TIME generation script — NOT a test run. It imports the + * canonical buildParityManifest builder from tests/helpers/install-shared.cjs + * (issue #2266 — single source of truth shared with + * tests/golden-install-parity.test.cjs) and captures the zcode fixture so the + * parity test (which the gsd-test gate runs) has a committed artifact to + * compare against. The authoritative test gate remains `gsd-test run`, never + * a local `node --test`. * * Usage: node scripts/gen-golden-install-parity-zcode.cjs */ const fs = require('node:fs'); const path = require('node:path'); -const crypto = require('node:crypto'); const ROOT = path.resolve(__dirname, '..'); -const { walk, runMinimalInstall, RUNTIME_META } = require(path.join(ROOT, 'tests', 'helpers', 'install-shared.cjs')); -const PKG_VERSION = require(path.join(ROOT, 'package.json')).version; +// buildParityManifest (and its exclusion constants) is the canonical single +// source of truth in tests/helpers/install-shared.cjs (issue #2266) — the +// generator no longer keeps its own inline copy, which had drifted from the +// test harness's copy (missing the realpath/`` normalization) and +// mis-generated the claude-local fixture (#2100). +const { runMinimalInstall, RUNTIME_META, buildParityManifest } = require(path.join(ROOT, 'tests', 'helpers', 'install-shared.cjs')); const FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'golden-install-parity'); -const VOLATILE_FILES = new Set([ - 'gsd-file-manifest.json', - 'gsd-install-state.json', - '.gsd-source', - 'gsd-core/CHANGELOG.md', -]); -// Must match tests/golden-install-parity.test.cjs exactly — settings.local.json -// (Claude LOCAL hook surface, #338/#2086) embeds the same platform-varying -// node-runner command and is excluded there; omitting it here mis-generated the -// claude-local fixture (#2100). -const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.json', 'hooks.json']); -// Kimi's native config.toml (#2095) — see tests/golden-install-parity.test.cjs' -// HOOK_CONFIG_RELATIVE_PATHS comment for why this is an exact relative-path -// exclusion rather than a HOOK_CONFIG_FILES basename entry (a basename entry -// would also blind Codex's stable, platform-independent config.toml fixture). -const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml']); -const EXCLUDED_PREFIXES = ['gsd-core/bin/lib/']; - -function buildParityManifest(configDir, root) { - const allFiles = walk(configDir); - const unsorted = {}; - for (const full of allFiles) { - const rel = path.relative(configDir, full).split(path.sep).join('/'); - if (VOLATILE_FILES.has(rel)) continue; - if (HOOK_CONFIG_FILES.has(path.basename(rel))) continue; - if (HOOK_CONFIG_RELATIVE_PATHS.has(rel)) continue; - if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; - const content = fs.readFileSync(full); - const normalized = content.toString('utf8').split(root).join('').split(PKG_VERSION).join(''); - const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16); - unsorted[rel] = hash; - } - const sorted = {}; - for (const key of Object.keys(unsorted).sort()) sorted[key] = unsorted[key]; - return sorted; -} - function cleanup(root) { try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } } diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 054f7cca5..53518e4ef 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "eefea61f9b0e464c", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "8ae31be7a006204b", + "hooks/gsd-statusline.js": "25996df685a0dac9", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index a73f5fbfc..5a95390ad 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "c8800819f7443a15", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "3be32d2012c77fc1", + "hooks/gsd-statusline.js": "2fab68f4fd190331", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index ce773ce63..b73f7ae5a 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -62,7 +62,7 @@ "commands/gsd-ingest-docs.md": "ded9013d0de7e77b", "commands/gsd-manager.md": "72d5b31b88f77703", "commands/gsd-map-codebase.md": "ecd69887996ae561", - "commands/gsd-mempalace-capture.md": "2e49397072506fd9", + "commands/gsd-mempalace-capture.md": "3812fc95963f92d7", "commands/gsd-mempalace-recall.md": "38716c0983a3ef9c", "commands/gsd-milestone-summary.md": "908509042caf5beb", "commands/gsd-mvp-phase.md": "1ef0d7c2871be49a", @@ -349,7 +349,7 @@ "gsd-core/workflows/plant-seed.md": "fbe964fcdb244802", "gsd-core/workflows/pr-branch.md": "513f6cff722eff2d", "gsd-core/workflows/profile-user.md": "3b34dcb337d50f4b", - "gsd-core/workflows/progress.md": "2be3a57916eccf87", + "gsd-core/workflows/progress.md": "eb0885959b4ac66e", "gsd-core/workflows/quick.md": "f1b474b46327034f", "gsd-core/workflows/reapply-patches.md": "44a96b52b975e9bb", "gsd-core/workflows/remove-phase.md": "8effc8742d58a11a", @@ -372,7 +372,7 @@ "gsd-core/workflows/stats.md": "3953356f476b5053", "gsd-core/workflows/sync-skills.md": "5624d529dae1ad79", "gsd-core/workflows/thread.md": "14a9d195572a198f", - "gsd-core/workflows/transition.md": "78a91b0154a93cf5", + "gsd-core/workflows/transition.md": "42c46f7bdf3d97cc", "gsd-core/workflows/ui-phase.md": "50b0dd962c8029b9", "gsd-core/workflows/ui-review.md": "9c6005236e2067b5", "gsd-core/workflows/ultraplan-phase.md": "b926ba7e4de0c76d", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index d61af7aa4..74156c173 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -327,7 +327,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "7c315416ffc99a9a", + "hooks/gsd-statusline.js": "4ad7c2f59577c5bc", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index f6a696439..60f20de40 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "7f7a7615b303369a", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "ef8dcb6d64fd4493", + "hooks/gsd-statusline.js": "29cdce15038d1ab1", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 870bd22c9..ca4301a01 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "1f58b020a91f032b", "hooks/gsd-read-injection-scanner.js": "f358eca3fa1eab24", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "861808560e60b233", + "hooks/gsd-statusline.js": "884347ccd6549f29", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 741af9b8d..21ea43a53 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -18,7 +18,7 @@ ".kimi/hooks/gsd-read-guard.js": "9e423cd03e2d1b16", ".kimi/hooks/gsd-read-injection-scanner.js": "c519598b9257aafa", ".kimi/hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - ".kimi/hooks/gsd-statusline.js": "2736b0885aa97bbf", + ".kimi/hooks/gsd-statusline.js": "be35341758d50fa5", ".kimi/hooks/gsd-update-banner.js": "55143a25f978f301", ".kimi/hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", ".kimi/hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 9a1a8f402..2b49ba506 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "f72060dfe035f706", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "9c132b5985800462", + "hooks/gsd-statusline.js": "6fcb59ad86d2d0ea", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index 0289b032f..bcceabcc6 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -295,7 +295,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "f454242c010804cf", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "5539e1ae859b987e", + "hooks/gsd-statusline.js": "daa1a98fde95ccaf", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 71e08f236..00c538545 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "2c8d417d12b51040", "hooks/gsd-read-injection-scanner.js": "396574bd25e99ff9", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "739140996a3c0d49", + "hooks/gsd-statusline.js": "2541196849ec5ffc", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/golden-install-parity.test.cjs b/tests/golden-install-parity.test.cjs index d0fb0c869..e14d3d09b 100644 --- a/tests/golden-install-parity.test.cjs +++ b/tests/golden-install-parity.test.cjs @@ -29,11 +29,10 @@ const { test, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const crypto = require('node:crypto'); const { execFileSync } = require('node:child_process'); const { cleanup } = require('./helpers.cjs'); -const { walk, RUNTIME_META, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs'); +const { RUNTIME_META, runMinimalInstall, BUILD_SCRIPT, buildParityManifest } = require('./helpers/install-shared.cjs'); // hooks/dist is gitignored and built (DEFECT.HOOKS-DIST-SCOPED-CI). The scoped // CI test lane does not run build:hooks, so a real install there emits no hooks/ @@ -48,128 +47,13 @@ const UPDATE = process.env.UPDATE_GOLDEN === '1'; const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'golden-install-parity'); -// Volatile metadata files always excluded from the parity manifest. -// .gsd-source (#1477, claude-global only) records the install-time absolute path -// to the package's commands/gsd source tree, which is the checkout/CI workspace -// path — NOT the temp HOME root, so it is never normalized to '' and its -// hash varies by environment. Excluded for the same reason as gsd-install-state.json. -// gsd-core/CHANGELOG.md is excluded because it contains historical version strings -// that cause hash drift between local (PKG_VERSION=1.x.x) and CI (PKG_VERSION=1.x.x-rc.N): -// the PKG_VERSION normalization below replaces only the *current* version, but -// CHANGELOG.md references prior-release versions, so the normalized hash diverges. -const VOLATILE_FILES = new Set([ - 'gsd-file-manifest.json', - 'gsd-install-state.json', - '.gsd-source', - 'gsd-core/CHANGELOG.md', -]); - -// The installed package version, normalized to '' in hash computation so -// the golden is stable across version bumps (the rc step runs `npm version X.Y.Z-rc.N` -// before tests, which rebakes the version into hook files and gsd-core/VERSION). -const PKG_VERSION = require('../package.json').version; - -// Hook-registration config files excluded from the parity manifest. These are -// written by the hook/permission install path (applySettingsJsonHooks / -// finishInstall) — NOT by installRuntimeArtifacts, so they are outside the scope -// of the engine deep-move this harness guards. They also embed the resolved -// node-runner invocation, whose FORM (absolute-quoted "/abs/bin/node" on macOS -// vs bare `node` resolved from PATH on Linux/CI) — not just the binary path — -// varies by platform and cannot be normalized to a single sentinel reliably. -// Their content is asserted directly by the dedicated hook tests -// (install-minimal-hooks, sh-hook-paths, codex-config, etc.). Matched by basename. -// settings.json = Claude/Antigravity/Augment/etc. hook surface; hooks.json = -// Codex/Cursor hook surface — both embed the platform-varying node-runner command. -// settings.local.json = Claude LOCAL hook surface (#338): same platform-varying -// node-runner command as settings.json, so excluded for the same reason (#2086). -const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.json', 'hooks.json']); - -// Kimi's native config.toml (#2095 EoS/kimi Upgrade 1) embeds the same -// platform-varying node-runner command as the HOOK_CONFIG_FILES above (via the -// same buildHookCommand/projectManagedHookCommand machinery), so it needs the -// same exclusion — but it is NOT matched by basename like HOOK_CONFIG_FILES: -// Codex's OWN config.toml (installSurface 'codex-toml') is a stable, tracked -// top-level `config.toml` entry in its golden fixture (it only ever gets a -// platform-stable `[features] hooks = true` flag — the real hook commands -// live in Codex's separate hooks.json, already excluded above). Blanket- -// excluding the 'config.toml' basename would silently blind Codex's fixture -// to any future regression there. Kimi's config.toml instead lives OUTSIDE -// its GSD configDir at runtime (resolveKimiHooksTomlDir resolves ~/.kimi, a -// sibling of the configDir ~/.config/agents) — it only appears inside this -// harness's walked tree at all because runMinimalInstall sets HOME to the -// same temp root used as --config-dir, collapsing the two into one directory -// for the isolated test run. So it is excluded by its exact relative path -// under that collapsed root, not by basename. -const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml']); - -// Path prefixes excluded from the parity manifest. `gsd-core/bin/lib/` holds the -// tsc-built runtime artifacts (compiled from src/*.cts) that the install COPIES -// verbatim — they are NOT produced by installRuntimeArtifacts (the move's parity -// scope), and their exact bytes depend on the BUILD environment (a clean tsc -// build vs a stale incremental one yields different output for unchanged sources). -// Including them made the golden non-portable: CI's clean build legitimately -// differs from a local incremental build for modules the PR never touched -// (e.g. milestone.cjs, roadmap.cjs). The .cts sources are type-checked + drift- -// guarded + coverage-gated elsewhere; this harness asserts the CONVERTED artifact -// output (skills/commands/agents) that the engine actually emits. -const EXCLUDED_PREFIXES = ['gsd-core/bin/lib/']; - -/** - * Build a deterministic hash-map of all non-volatile files under configDir. - * - * For each file: - * - rel = POSIX-slash relative path from configDir - * - hash = sha256(content with root replaced by '').slice(0,16) - * - * Returns a plain object with sorted keys for stable JSON comparison. - * - * @param {string} configDir - absolute path to the installed runtime config dir - * @param {string} root - temp root path to replace with '' - * @returns {{ [rel: string]: string }} - */ -function buildParityManifest(configDir, root) { - const allFiles = walk(configDir); - const unsorted = {}; - - // The claude LOCAL install resolves its config dir via realpath, which on macOS - // prepends `/private` to the temp root (`/var/folders/…` -> `/private/var/folders/…`) - // and embeds that resolved path in the projected agents/commands/workflows (`@…` - // references). On Linux the temp root has no `/private` symlink, so normalizing - // ONLY `root` left the `/private` prefix on macOS and produced platform-divergent - // hashes (#2086). Normalize the realpath form FIRST (it is the longer, `/private`- - // prefixed string) so both platforms collapse to ``. No-op for the global - // fixtures (global install uses the literal `--config-dir`, never realpath-resolved). - let realRoot = root; - try { realRoot = fs.realpathSync(root); } catch { /* root already gone / not resolvable */ } - - for (const full of allFiles) { - // Build POSIX-style relative path for cross-platform stability - const rel = path.relative(configDir, full).split(path.sep).join('/'); - - if (VOLATILE_FILES.has(rel)) continue; - if (HOOK_CONFIG_FILES.has(path.basename(rel))) continue; - if (HOOK_CONFIG_RELATIVE_PATHS.has(rel)) continue; - if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; - - const content = fs.readFileSync(full); - // Normalize every occurrence of the temp root so hashes are stable across runs. - // Also normalize the package version so the golden survives `npm version` bumps - // (the rc release step bakes the new version into hook files before running tests). - const normalized = content.toString('utf8') - .split(realRoot).join('') - .split(root).join('') - .split(PKG_VERSION).join(''); - const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16); - unsorted[rel] = hash; - } - - // Reconstruct with sorted keys for stable JSON serialisation - const sorted = {}; - for (const key of Object.keys(unsorted).sort()) { - sorted[key] = unsorted[key]; - } - return sorted; -} +// The parity-manifest exclusion constants and buildParityManifest builder are +// the canonical single source of truth in tests/helpers/install-shared.cjs +// (issue #2266) — imported above. See that module for the full rationale +// behind each exclusion (VOLATILE_FILES, HOOK_CONFIG_FILES, +// HOOK_CONFIG_RELATIVE_PATHS, EXCLUDED_PREFIXES) and the hash formula. +// scripts/gen-golden-install-parity-zcode.cjs imports the same builder so the +// test harness and the fixture generator can never drift again. // Ensure the fixture directory exists (needed for UPDATE mode) if (UPDATE) { diff --git a/tests/golden-parity-single-source.test.cjs b/tests/golden-parity-single-source.test.cjs new file mode 100644 index 000000000..8c1426fde --- /dev/null +++ b/tests/golden-parity-single-source.test.cjs @@ -0,0 +1,97 @@ +'use strict'; + +/** + * golden-parity-single-source.test.cjs — anti-divergence guard (#2266). + * + * tests/golden-install-parity.test.cjs and scripts/gen-golden-install-parity-zcode.cjs + * used to each carry their OWN inline copy of buildParityManifest plus its 4 + * exclusion constants (VOLATILE_FILES, HOOK_CONFIG_FILES, + * HOOK_CONFIG_RELATIVE_PATHS, EXCLUDED_PREFIXES). The two copies drifted — + * the generator's copy was missing the realpath/`` normalization the + * test harness's copy had — and shipped broken fixtures three times (#2086, + * #2095, #2100). Phase 1 of the golden-install-parity redesign (#2266) + * consolidated both call sites onto a single canonical implementation in + * tests/helpers/install-shared.cjs. + * + * This guard (mirrors the ADR-2121 anti-divergence pattern) enforces that + * consolidation stays consolidated: + * 1. install-shared.cjs actually exports a working buildParityManifest + + * the 4 exclusion constants with the expected shapes. + * 2. Neither downstream consumer re-declares its own inline copy of the + * builder function or the exclusion constants. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); + +test('install-shared.cjs exports the canonical buildParityManifest + exclusion constants (#2266)', () => { + const installShared = require('./helpers/install-shared.cjs'); + + assert.equal( + typeof installShared.buildParityManifest, + 'function', + 'install-shared.cjs must export buildParityManifest as the single source of truth' + ); + + assert.ok( + installShared.VOLATILE_FILES instanceof Set, + 'VOLATILE_FILES must be a Set' + ); + assert.ok( + installShared.HOOK_CONFIG_FILES instanceof Set, + 'HOOK_CONFIG_FILES must be a Set' + ); + assert.ok( + installShared.HOOK_CONFIG_RELATIVE_PATHS instanceof Set, + 'HOOK_CONFIG_RELATIVE_PATHS must be a Set' + ); + assert.ok( + Array.isArray(installShared.EXCLUDED_PREFIXES), + 'EXCLUDED_PREFIXES must be an array' + ); + assert.ok( + installShared.EXCLUDED_PREFIXES.includes('gsd-core/bin/lib/'), + "EXCLUDED_PREFIXES must include 'gsd-core/bin/lib/' (compiled runtime artifacts, build-environment-dependent)" + ); +}); + +// The anti-divergence check below reads the two downstream .cjs source files +// as plain text to prove they no longer re-declare the builder/constants +// inline — the runtime-contract-under-test IS the source text (whether a +// second inline copy exists), not behavior a require() could exercise. +// +// ALL FIVE identifiers are guarded, not just buildParityManifest + VOLATILE_FILES: +// the drift that shipped broken fixtures was a MISSING exclusion-constant entry +// (#2100 = generator's HOOK_CONFIG_FILES copy lacked settings.local.json; #2095 = +// kimi's HOOK_CONFIG_RELATIVE_PATHS entry), so a re-declared HOOK_CONFIG_FILES / +// HOOK_CONFIG_RELATIVE_PATHS / EXCLUDED_PREFIXES is exactly the failure class this +// guard exists to prevent — checking only two of four would leave that gap open. +const FORBIDDEN_INLINE = [ + { label: 'buildParityManifest', re: /function\s+buildParityManifest/ }, + { label: 'VOLATILE_FILES', re: /const\s+VOLATILE_FILES\s*=\s*new\s+Set/ }, + { label: 'HOOK_CONFIG_FILES', re: /const\s+HOOK_CONFIG_FILES\s*=\s*new\s+Set/ }, + { label: 'HOOK_CONFIG_RELATIVE_PATHS', re: /const\s+HOOK_CONFIG_RELATIVE_PATHS\s*=\s*new\s+Set/ }, + { label: 'EXCLUDED_PREFIXES', re: /const\s+EXCLUDED_PREFIXES\s*=\s*\[/ }, +]; + +const CONSUMERS = [ + { name: 'tests/golden-install-parity.test.cjs', rel: ['tests', 'golden-install-parity.test.cjs'], from: './helpers/install-shared.cjs' }, + { name: 'scripts/gen-golden-install-parity-zcode.cjs', rel: ['scripts', 'gen-golden-install-parity-zcode.cjs'], from: 'tests/helpers/install-shared.cjs' }, +]; + +for (const consumer of CONSUMERS) { + test(`${consumer.name} does not re-declare an inline buildParityManifest or any exclusion constant (#2266)`, () => { + // allow-test-rule: source text is the product for this anti-divergence check, see #2266 + const content = fs.readFileSync(path.join(ROOT, ...consumer.rel), 'utf8'); + for (const { label, re } of FORBIDDEN_INLINE) { + assert.ok( + !re.test(content), + `${consumer.name} must import ${label} from ${consumer.from}, not re-declare it inline` + ); + } + }); +} diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 9b9a5e8aa..50a6cb08b 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -1,14 +1,21 @@ 'use strict'; /** - * Shared helpers and constants for install test suite. - * Used by install.test.cjs, install-runtime-artifacts.test.cjs, - * and install-minimal-hooks.test.cjs. + * Shared helpers and constants for the install test suites and the + * golden-install-parity harness. Provides the install/uninstall drivers + * (walk, runMinimalInstall, RUNTIME_META, BUILD_SCRIPT) and the single + * canonical golden-parity manifest builder (buildParityManifest) plus its + * exclusion constants (VOLATILE_FILES, HOOK_CONFIG_FILES, + * HOOK_CONFIG_RELATIVE_PATHS, EXCLUDED_PREFIXES). Imported by many + * tests/*.test.cjs and by scripts/gen-golden-install-parity-zcode.cjs — do + * NOT re-declare the builder/constants inline (enforced by + * tests/golden-parity-single-source.test.cjs, #2266). */ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); +const crypto = require('node:crypto'); const { spawnSync } = require('node:child_process'); const assert = require('node:assert/strict'); @@ -70,6 +77,82 @@ const SKILL_RUNTIMES = [ 'cursor', 'augment', 'trae', 'qwen', 'codebuddy', ]; +// ─── Golden install-parity manifest (canonical — issue #2266) ──────────────── +// +// Single source of truth for the parity-manifest exclusion rules and hash +// formula. Both tests/golden-install-parity.test.cjs (the test harness) and +// scripts/gen-golden-install-parity-zcode.cjs (the build-time fixture +// generator) import buildParityManifest + these constants from here instead +// of each re-declaring their own copy — the prior duplication had drifted +// (the generator's copy was missing the realpath normalization below) and +// shipped broken fixtures three times (#2086, #2095, #2100). + +// The installed package version, normalized to '' in hash computation so +// the golden is stable across version bumps (the rc step runs `npm version X.Y.Z-rc.N` +// before tests, which rebakes the version into hook files and gsd-core/VERSION). +const PKG_VERSION = require('../../package.json').version; + +// Volatile metadata files always excluded from the parity manifest. +// .gsd-source (#1477, claude-global only) records the install-time absolute path +// to the package's commands/gsd source tree, which is the checkout/CI workspace +// path — NOT the temp HOME root, so it is never normalized to '' and its +// hash varies by environment. Excluded for the same reason as gsd-install-state.json. +// gsd-core/CHANGELOG.md is excluded because it contains historical version strings +// that cause hash drift between local (PKG_VERSION=1.x.x) and CI (PKG_VERSION=1.x.x-rc.N): +// the PKG_VERSION normalization below replaces only the *current* version, but +// CHANGELOG.md references prior-release versions, so the normalized hash diverges. +const VOLATILE_FILES = new Set([ + 'gsd-file-manifest.json', + 'gsd-install-state.json', + '.gsd-source', + 'gsd-core/CHANGELOG.md', +]); + +// Hook-registration config files excluded from the parity manifest. These are +// written by the hook/permission install path (applySettingsJsonHooks / +// finishInstall) — NOT by installRuntimeArtifacts, so they are outside the scope +// of the engine deep-move this harness guards. They also embed the resolved +// node-runner invocation, whose FORM (absolute-quoted "/abs/bin/node" on macOS +// vs bare `node` resolved from PATH on Linux/CI) — not just the binary path — +// varies by platform and cannot be normalized to a single sentinel reliably. +// Their content is asserted directly by the dedicated hook tests +// (install-minimal-hooks, sh-hook-paths, codex-config, etc.). Matched by basename. +// settings.json = Claude/Antigravity/Augment/etc. hook surface; hooks.json = +// Codex/Cursor hook surface — both embed the platform-varying node-runner command. +// settings.local.json = Claude LOCAL hook surface (#338): same platform-varying +// node-runner command as settings.json, so excluded for the same reason (#2086). +const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.json', 'hooks.json']); + +// Kimi's native config.toml (#2095 EoS/kimi Upgrade 1) embeds the same +// platform-varying node-runner command as the HOOK_CONFIG_FILES above (via the +// same buildHookCommand/projectManagedHookCommand machinery), so it needs the +// same exclusion — but it is NOT matched by basename like HOOK_CONFIG_FILES: +// Codex's OWN config.toml (installSurface 'codex-toml') is a stable, tracked +// top-level `config.toml` entry in its golden fixture (it only ever gets a +// platform-stable `[features] hooks = true` flag — the real hook commands +// live in Codex's separate hooks.json, already excluded above). Blanket- +// excluding the 'config.toml' basename would silently blind Codex's fixture +// to any future regression there. Kimi's config.toml instead lives OUTSIDE +// its GSD configDir at runtime (resolveKimiHooksTomlDir resolves ~/.kimi, a +// sibling of the configDir ~/.config/agents) — it only appears inside this +// harness's walked tree at all because runMinimalInstall sets HOME to the +// same temp root used as --config-dir, collapsing the two into one directory +// for the isolated test run. So it is excluded by its exact relative path +// under that collapsed root, not by basename. +const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml']); + +// Path prefixes excluded from the parity manifest. `gsd-core/bin/lib/` holds the +// tsc-built runtime artifacts (compiled from src/*.cts) that the install COPIES +// verbatim — they are NOT produced by installRuntimeArtifacts (the move's parity +// scope), and their exact bytes depend on the BUILD environment (a clean tsc +// build vs a stale incremental one yields different output for unchanged sources). +// Including them made the golden non-portable: CI's clean build legitimately +// differs from a local incremental build for modules the PR never touched +// (e.g. milestone.cjs, roadmap.cjs). The .cts sources are type-checked + drift- +// guarded + coverage-gated elsewhere; this harness asserts the CONVERTED artifact +// output (skills/commands/agents) that the engine actually emits. +const EXCLUDED_PREFIXES = ['gsd-core/bin/lib/']; + // ─── Helper functions ───────────────────────────────────────────────────────── function stripAnsi(str) { @@ -87,6 +170,63 @@ function walk(dir) { return results; } +/** + * Build a deterministic hash-map of all non-volatile files under configDir. + * + * For each file: + * - rel = POSIX-slash relative path from configDir + * - hash = sha256(content with root replaced by '').slice(0,16) + * + * Returns a plain object with sorted keys for stable JSON comparison. + * + * @param {string} configDir - absolute path to the installed runtime config dir + * @param {string} root - temp root path to replace with '' + * @returns {{ [rel: string]: string }} + */ +function buildParityManifest(configDir, root) { + const allFiles = walk(configDir); + const unsorted = {}; + + // The claude LOCAL install resolves its config dir via realpath, which on macOS + // prepends `/private` to the temp root (`/var/folders/…` -> `/private/var/folders/…`) + // and embeds that resolved path in the projected agents/commands/workflows (`@…` + // references). On Linux the temp root has no `/private` symlink, so normalizing + // ONLY `root` left the `/private` prefix on macOS and produced platform-divergent + // hashes (#2086). Normalize the realpath form FIRST (it is the longer, `/private`- + // prefixed string) so both platforms collapse to ``. No-op for the global + // fixtures (global install uses the literal `--config-dir`, never realpath-resolved). + let realRoot = root; + try { realRoot = fs.realpathSync(root); } catch { /* root already gone / not resolvable */ } + + for (const full of allFiles) { + // Build POSIX-style relative path for cross-platform stability + const rel = path.relative(configDir, full).split(path.sep).join('/'); + + if (VOLATILE_FILES.has(rel)) continue; + if (HOOK_CONFIG_FILES.has(path.basename(rel))) continue; + if (HOOK_CONFIG_RELATIVE_PATHS.has(rel)) continue; + if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + + const content = fs.readFileSync(full); + // Normalize every occurrence of the temp root so hashes are stable across runs. + // Also normalize the package version so the golden survives `npm version` bumps + // (the rc release step bakes the new version into hook files before running tests). + const normalized = content.toString('utf8') + .split(realRoot).join('') + .split(root).join('') + .split(PKG_VERSION).join(''); + const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16); + unsorted[rel] = hash; + } + + // Reconstruct with sorted keys for stable JSON serialisation + const sorted = {}; + for (const key of Object.keys(unsorted).sort()) { + sorted[key] = unsorted[key]; + } + return sorted; +} + function simulateHookCopy(hooksSrc, hooksDest) { fs.mkdirSync(hooksDest, { recursive: true }); for (const entry of fs.readdirSync(hooksSrc)) { @@ -249,8 +389,14 @@ module.exports = { EXPECTED_ALL_HOOKS, RUNTIME_META, SKILL_RUNTIMES, + PKG_VERSION, + VOLATILE_FILES, + HOOK_CONFIG_FILES, + HOOK_CONFIG_RELATIVE_PATHS, + EXCLUDED_PREFIXES, stripAnsi, walk, + buildParityManifest, simulateHookCopy, installerEnv, runMinimalInstall,