Phase 1 of golden-install-parity redesign (epic #2264). Consolidates buildParityManifest + exclusion constants into tests/helpers/install-shared.cjs (fixes realRoot divergence), adds anti-divergence guard, corrects 12 stale golden fixtures to portable values. Closes #2266.
This commit is contained in:
@@ -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/`<HOME>` 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('<HOME>').split(PKG_VERSION).join('<VERSION>');
|
||||
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 */ }
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
tests/fixtures/golden-install-parity/pi.json
vendored
2
tests/fixtures/golden-install-parity/pi.json
vendored
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 '<HOME>' 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 '<VERSION>' 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 '<HOME>').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 '<HOME>'
|
||||
* @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 `<HOME>`. 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('<HOME>')
|
||||
.split(root).join('<HOME>')
|
||||
.split(PKG_VERSION).join('<VERSION>');
|
||||
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) {
|
||||
|
||||
97
tests/golden-parity-single-source.test.cjs
Normal file
97
tests/golden-parity-single-source.test.cjs
Normal file
@@ -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/`<HOME>` 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`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 '<VERSION>' 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 '<HOME>' 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 '<HOME>').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 '<HOME>'
|
||||
* @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 `<HOME>`. 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('<HOME>')
|
||||
.split(root).join('<HOME>')
|
||||
.split(PKG_VERSION).join('<VERSION>');
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user