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