refactor(#2266): single-source golden-parity manifest builder + fixture correction (#2273)

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:
Tom Boucher
2026-07-14 17:10:04 -04:00
committed by GitHub
parent ef5a5bc15d
commit 6a474db3aa
14 changed files with 279 additions and 184 deletions

View File

@@ -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,