* test(#3412): failing-first suite for the pattern-construction seam Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both suites fail with MODULE_NOT_FOUND, which is the intended RED. Locks the measured behavior rather than the assumed behavior: RegExp.escape hex-escapes the leading character of nearly every string ("abc" -> "\x61bc"), so the suite asserts match-equivalence against an inlined historical oracle (the implementation being deleted) rather than byte-equivalence of pattern text — 200 seeded fast-check runs plus a fixed corpus, 0 mismatches. Also locks the latent character-class range bug this phase fixes as a side effect: a hyphen-bearing value interpolated into [...] currently forms a real range and matches an unintended character; post-migration it must not. * chore(#3412): src/pattern.cts owns runtime-value regex construction Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam delegating to the built-in RegExp.escape, deletes every hand-rolled copy, and raises the Node floor to the Active LTS line. The census was low, three times over. ADR-3212 counted 10 copies; a graph query found 12; the new lint rule — once live — found 27 more. The difference is that the census counted named helper FUNCTIONS while the rule counts the escape SHAPE, so inline .replace(<class>, '\$&') copies were never in scope. ADR §1's actual requirement is that no module outside the seam escapes a value for regex use, so all of them are, and CLAUDE.md's no-defer rule makes them this change's work. Fourth consecutive epic here whose copy count was low — the argument for ADR-3180 Amendment 3's "state N found by the guard" rule. Also corrected mid-implementation: the survey reported phase-id.cts's escapeRegex had 0 external importers. It had 8 production importers, making its removal a public-surface change to an ADR-2121-owned module and requiring an update to that ADR's locked-surface test. Blast radius revised Medium-High -> High. RegExp.escape is match-equivalent but NOT text-equivalent: it hex-escapes the leading char of nearly every string ("abc" -> "\x61bc"). Equivalence is proven by a seeded fast-check property test against the deleted implementation as oracle. It also fixes a latent bug: a hyphen-bearing value interpolated into a character class previously formed a real range and matched an unintended character. Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines, .nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate `required-tests` context is unchanged and no job was added or removed, so branch protection cannot be orphaned by the dropped lanes. Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with structural provenance for reviewed pattern-fragment constants rather than a name heuristic) plus a whole-tree companion guard covering the directories ESLint's globs miss. * fix(#3412): close the _SOURCE guard evasion, correct two false claims Three findings from the orthogonal review pass, all fixed. 1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier- name matching with no binding check, so `new RegExp(userInput_SOURCE)` — a function parameter — sailed past the guard. That is the same rename-evasion class issue #3410 documents, reopened by the very fallback meant to complement the structural check. Now bound to the identifier's actual binding kind: import, require-derived const, or module-scope const; parameters, `let`/`var`, and unresolvable bindings fail closed. Four RuleTester cases cover the evasion and prove the legitimate cross-module case still passes. 2. src/pattern.cts's own header carried the stale pre-correction counts (12 copies / 17 call sites) while CONTEXT.md and the design doc carried the corrected ones (~39 / ~44) — a self-contradiction inside the PR whose entire purpose is deleting divergent copies. Rewritten, preserving the durable lesson: a named-function census cannot see inline copies; only a shape-matching guard can. 3. The claim that all deleted copies threw TypeError on non-string was false. phase-id.cts's copy — the one with 8 external importers — did String(value).replace(...) and never threw. The seam's locked signature does not coerce, so this is a real, now-disclosed behavior change rather than the pure preservation the tests asserted. Audited all 32 invocations across the 8 importers and 6 in-file callers: every one is safe by construction (upstream truthy guard or a string-producing derivation), verified by runtime probe against the compiled modules rather than by TS compilation, which cannot see a runtime undefined. Corrected the false claim in both the test comment and the design doc, and added it to Known limits. * docs(#3412): add Changed changeset for the Node 24 floor The only user-visible break in this phase. The escape-behavior change is internal and match-equivalent, so it carries no user-facing note. * fix(#3412): resolve the seam's require graph in script fixtures and packaging Checkpoint 2 came back red with 90 failures on the node24 lane. Three distinct defects, all introduced by routing scripts/ through the new pattern seam, none reproducible by any local gate: 1. ~82 failures — tests/adr-index-gate.test.cjs and tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an mkdtemp fixture and spawn it there (necessary: those scripts resolve their scan root from __dirname/.., so running the real script would scan the real repo). Each harness hand-listed the dependencies to copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to gen-adr-index.cjs made both lists silently incomplete -> MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON' failures from the same crash. Fixed as a class, not an instance: new tests/helpers/copy-script- fixture.cjs walks a script's transitive static relative-require graph and copies it, so dependencies are derived and never re-declared. It throws (naming the unbuilt artifact) instead of letting the child die with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host- contract, sync-runtime-launcher. 2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so the new scripts/lint-no-adhoc-regex-escape.cjs would be MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from the tarball, matching the existing precedent for gen-emitted- baseline.cjs, which is excluded for the identical reason, and locked with a test modeled on that one. Confirmed against a real npm pack: 890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs present (so the other four scripts' requires are legitimate). 3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to the retired hand-rolled escaper but NOT text-equivalent: it hex- escapes the leading character and all hyphens ('0*\x329', '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match decisions across all three real interpolation prefixes, zero divergence. Those tests now compile each source into the same heading regex src/roadmap.cts's searchPhaseInContent builds and assert what matches and what does not, including the 'i'-flag canonicalization the hex escape has to preserve. Re-pinning the new literals would have rebuilt the same brittleness one layer down. Adds a test for the property the escape exists for: a dot in '1.2' must not act as a wildcard. Also shares one definition of 'a require' between the packaging guard and the fixture copier, so the two cannot disagree about what they scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): refuse to copy a fixture dependency outside the fixture root copyScriptWithDeps resolved each relative require and joined the repo-relative result onto fixtureRoot. A require resolving OUTSIDE the repo yields a '../'-prefixed relative path, so path.join climbed out of the fixture and wrote into the surrounding temp dir (verified: repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd). No script in the tree does this today, so this closes an available escape rather than an active one. Refuses via the existing unresolved- require path so the failure names the offending specifier. Covered by a negative proof that the guard fires and that nothing lands outside the fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract Applies all findings from the second orthogonal review round, re-run because real code changed after round 1. HIGH (security) — extractRequires stripped BLOCK comments before LINE comments, so a '//' comment containing '/*' opened a phantom block comment, and a '//' inside a string literal truncated the line. Both hid real requires: 'const u="http://x"; require("./real.cjs")' returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were invisible. Replaced with a real AST parse via espree. This is ADR-3212's own Decision 4 — tokenizer-first for stateful grammars — applied to the case it describes; comment/string/regex nesting is exactly such a grammar, which is why the regex version was wrong. The function was moved byte-identical out of the #2858 packaging guard, so the bug PRE-DATES this branch and has been a live blind spot there: a shipped script could have required an unshipped path undetected. Fixing it makes that guard strictly stronger than on next. espree is promoted from a transitive eslint dependency to an explicit devDependency rather than relying on hoisting. The script parse attempt sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a function, making a top-level return legal — scripts/check-coverage-gate .cjs relies on it, and without the flag the guard throws on a file it is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a real npm pack, so the exact extractor does not newly fail the guard. MEDIUM (security) — the repo-containment check guarded dependencies but not the entry path. One escapesContainment predicate now guards both. LOW (security) — containment was lexical while fs follows symlinks, and a directory symlink could mint a fresh dedupe key per level. realpath now resolves both repoRoot and each dependency before the decision, and the realpath-derived path is the dedupe key. Destination layout still uses the original repo-relative path, so copied trees are unchanged. MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests lost the foreign-prefix contract: every assertion was satisfied by an impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599 bug class the exact-source prevents. The literal assertions it replaced were catching this. Now asserts the compiled regex REJECTS a different prefix with the same number. MAJOR (standards) — the test hand-duplicated production's heading regex with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed the parallel surface instead of policing it: src/roadmap.cts exports buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports it. Byte-identical .source and .flags verified for both escaped forms. MINOR — '..foo' no longer false-flagged as an escape; the inverted spurious-vs-missing doc claim corrected; the dead allow-test-rule header removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3412): backfill changeset pr number to 3416 * fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision Two CI failures on PR #3416, both in code this branch added. CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a bracket run be consumed EITHER by the character-class branch OR one character at a time by the trailing catch-all, so a failing match explored both parses of every pair. Measured on the real regex: n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script scans repo source, so a file with a long bracket run after '.replace(/' would hang CI outright — a guard against undisciplined pattern construction was itself the worst pattern in the diff. Fixed the way ADR-3212 already prescribes: the catch-all branch now excludes '[' and ']' so a bracket can only be consumed by the class branch (this is what makes it linear), and every quantifier is bounded (the locked bounded-quantifiers decision) as a second line of defense. Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the constant: a regex literal with a BARE unescaped ']' outside a class is no longer matched by this backstop. No census shape has that form, and the AST rule remains the primary detector. Verified the guard did not go blind doing it: a real census-shape violation is still reported, and an allow-adhoc-regex-escape suppression comment is still honored. Regression test drives the exported findViolations on a 2000-repetition adversarial input and asserts the RESULT. It makes no wall-clock assertion — elapsed-time tests are forbidden — so a regression surfaces as a harness timeout, which is the correct signal. Prompt injection scan — 'must not act as a regex wildcard' in a test comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if| my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a whole test file over one phrase would blunt the scanner permanently, and the comment has nothing to do with injection. Neither failure was reachable from the remote runner — CodeQL and the injection scan are not in that matrix, so the sha it passed was green and still wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
730 lines
38 KiB
JavaScript
730 lines
38 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* 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), its sibling
|
|
* byte-size builder over the same normalized walk (buildEmittedSizes, #2931),
|
|
* plus their shared 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 assert = require('node:assert/strict');
|
|
|
|
const { runNode } = require('./process-seam.cjs');
|
|
|
|
const {
|
|
resolveRuntimeArtifactLayout,
|
|
} = require('../../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
|
const { escapeRegex: escapeRegExp } = require('../../gsd-core/bin/lib/pattern.cjs');
|
|
|
|
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'bin', 'install.js');
|
|
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
|
|
|
// #3145: class-norm timeout (bounds the previously-unbounded spawnSync in
|
|
// runMinimalInstall) — not a per-suite value. See helpers/timeouts.cjs for
|
|
// the justification; every one of install-shared.cjs's 37+ importers
|
|
// inherits this value, so it must stay generous rather than tight.
|
|
const { INSTALL_TIMEOUT_MS } = require('./timeouts.cjs');
|
|
|
|
const BUILD_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'build-hooks.js');
|
|
const HOOKS_DIST = path.join(__dirname, '..', '..', 'hooks', 'dist');
|
|
|
|
const EXPECTED_SH_HOOKS = [
|
|
'gsd-session-state.sh',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-phase-boundary.sh',
|
|
];
|
|
|
|
const EXPECTED_ALL_HOOKS = [
|
|
'gsd-agent-isolation-guard.js',
|
|
'gsd-check-update.js',
|
|
'gsd-config-reload.js',
|
|
'gsd-context-monitor.js',
|
|
// #997: SessionStart canonical-path bootstrap for plugin installs.
|
|
'gsd-ensure-canonical-path.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-read-injection-scanner.js',
|
|
'gsd-statusline.js',
|
|
'gsd-workflow-guard.js',
|
|
...EXPECTED_SH_HOOKS,
|
|
];
|
|
|
|
// ─── Runtime metadata table ───────────────────────────────────────────────────
|
|
|
|
const RUNTIME_META = {
|
|
claude: { localDir: '.claude', globalSuffix: '.claude' },
|
|
antigravity: { localDir: '.agents', globalSuffix: path.join('.gemini', 'antigravity') },
|
|
augment: { localDir: '.augment', globalSuffix: '.augment' },
|
|
cline: { localDir: '.cline', globalSuffix: '.cline' },
|
|
codebuddy: { localDir: '.codebuddy', globalSuffix: '.codebuddy' },
|
|
codex: { localDir: '.codex', globalSuffix: '.codex' },
|
|
copilot: { localDir: '.github', globalSuffix: '.copilot' },
|
|
cursor: { localDir: '.cursor', globalSuffix: '.cursor' },
|
|
hermes: { localDir: '.hermes', globalSuffix: '.hermes' },
|
|
kimi: { localDir: '.kimi-code', globalSuffix: path.join('.config', 'agents') },
|
|
'kimi-code': { localDir: '.kimi-code', globalSuffix: '.kimi-code' },
|
|
kilo: { localDir: '.kilo', globalSuffix: path.join('.config', 'kilo') },
|
|
opencode: { localDir: '.opencode', globalSuffix: path.join('.config', 'opencode') },
|
|
pi: { localDir: '.pi', globalSuffix: path.join('.pi', 'agent') },
|
|
qwen: { localDir: '.qwen', globalSuffix: '.qwen' },
|
|
trae: { localDir: '.trae', globalSuffix: '.trae' },
|
|
windsurf: { localDir: '.windsurf', globalSuffix: path.join('.codeium', 'windsurf') },
|
|
zcode: { localDir: '.zcode', globalSuffix: '.zcode' },
|
|
};
|
|
|
|
/**
|
|
* The emitted manifest families, as (fixtureName -> install spec).
|
|
*
|
|
* NOT simply `Object.keys(RUNTIME_META)`: that has 18 entries while the fixture set has
|
|
* 19. The extra one is `claude-local` — claude is the reference host and the ONLY
|
|
* runtime with a distinct LOCAL "legacy flat-commands" layout (`commands/gsd-*.md` +
|
|
* `agents/gsd-*.md` at project scope), which `golden-install-parity.test.cjs` guards
|
|
* with a hand-coded test outside its RUNTIME_META loop (#2086).
|
|
*
|
|
* Enumerating from RUNTIME_META alone dropped that family from BOTH sides of the
|
|
* differential, so a same-count self-check (18 === 18) passed vacuously and a PR
|
|
* changing Claude's local-scope output would fail the golden while the attribution
|
|
* check reported ok.
|
|
*
|
|
* Lives HERE, beside RUNTIME_META, so the emitted-attribution helpers and the
|
|
* emitted-provenance table read ONE derivation rather than each carrying a literal.
|
|
* Two surfaces sharing a hand-maintained count is what produced the #2723 deadlock:
|
|
* a single constant was asserted against both the base ref and the PR head, which
|
|
* legitimately differ whenever a PR adds or removes a runtime.
|
|
*/
|
|
const MANIFEST_FAMILIES = [
|
|
...Object.keys(RUNTIME_META).map((runtime) => ({ name: runtime, runtime, scope: 'global' })),
|
|
{ name: 'claude-local', runtime: 'claude', scope: 'local' },
|
|
];
|
|
|
|
/**
|
|
* Absolute floor on the family set, independent of any derivation.
|
|
*
|
|
* A pure equality between "derived" and "recorded" cannot catch a universe that shrank
|
|
* on BOTH sides at once (drop a RUNTIME_META entry and delete its fixture together, and
|
|
* 18 === 18 passes over a smaller world). This floor is the one number that must not be
|
|
* derived — it ratchets, and lowering it is a deliberate, reviewable act. It never
|
|
* blocks ADDING a runtime, which is the asymmetry the old shared literal lacked.
|
|
*/
|
|
const MINIMUM_MANIFEST_FAMILIES = 19;
|
|
|
|
// Runtimes that emit per-skill files under skills/ (not rules-based or commands-based)
|
|
const SKILL_RUNTIMES = [
|
|
'claude', 'opencode', 'kilo', 'codex', 'copilot', 'antigravity',
|
|
'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).
|
|
|
|
// This checkout's own package version — the DEFAULT normalized 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).
|
|
//
|
|
// This is only a default. buildParityManifest's `pkgVersion` option exists precisely
|
|
// because the tree being MEASURED is not always this checkout (#2767's `repoRoot`
|
|
// installer-spawn path measures a DIFFERENT tree's `bin/install.js` output). The
|
|
// version that must be normalized is always the version of the tree that PRODUCED
|
|
// the emitted bytes, not the version of whichever checkout happens to be running this
|
|
// test file — see the pkgVersion JSDoc on buildParityManifest below.
|
|
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.
|
|
// gsd-file-manifest.json's exclusion was revisited deliberately for #2872: the
|
|
// manifest gained `manifestVersion`/`runtime`/`scope` fields, all of which are
|
|
// deterministic and would not by themselves force an exclusion, but `timestamp`
|
|
// — the original reason this file is volatile — is unchanged by #2872, so the
|
|
// exclusion still holds for exactly the same reason it always has.
|
|
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.
|
|
// Both Kimi products' native config.toml embeds a platform-varying node-runner
|
|
// command, so neither belongs in the golden-tracked emitted manifest. kimi-code
|
|
// resolves its own root since #2755 — listing only `.kimi/config.toml` here made
|
|
// kimi-code's config.toml newly manifest-visible and unattributable.
|
|
const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml', '.kimi-code/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 ─────────────────────────────────────────────────────────
|
|
|
|
const ANSI_ESCAPE = String.fromCharCode(27);
|
|
const ANSI_SGR_RE = new RegExp(`${ANSI_ESCAPE}\\[[0-9;]*m`, 'g');
|
|
|
|
function stripAnsi(str) {
|
|
return str.replace(ANSI_SGR_RE, '');
|
|
}
|
|
|
|
// A version string can itself contain regex metacharacters (`.`, and — via
|
|
// prerelease/build metadata — `-`/`+`), so it must be escaped before being spliced
|
|
// into a RegExp source, or e.g. the `.` in "1.9.0" would match ANY character.
|
|
|
|
// Loosely semver-shaped: leading `MAJOR.MINOR.PATCH`, optional `-prerelease` and/or
|
|
// `+build` metadata (e.g. `1.9.0`, `1.9.0-rc.1`, `1.9.0+abc`). Deliberately loose
|
|
// (not the full semver grammar) — this only needs to reject obviously-malformed
|
|
// values like `'1'` (FINDING 2, #2891 review) before they reach regex construction,
|
|
// not to be a semver validator.
|
|
const SEMVER_ISH_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
|
|
|
|
/**
|
|
* Anchored, narrow version-stamp normalization — the FINDING 1 (#2891 review) fix for
|
|
* the prior `.split(pkgVersion).join('<VERSION>')`, which blind-replaced EVERY
|
|
* occurrence of the version string anywhere in emitted content. That was unsound in
|
|
* both directions once each side of a diff normalizes against a DIFFERENT pkgVersion
|
|
* (baseline vs current tree, #2891): a bare semver literal that genuinely changed
|
|
* between the two versions collapses to '<VERSION>' on both sides and goes invisible
|
|
* (false negative), while an UNCHANGED file that happens to contain a literal equal to
|
|
* only the CURRENT version collapses on one side only and reports as spurious drift
|
|
* (false positive). Emitted sources really do carry bare semver literals that are NOT
|
|
* install-time stamps — e.g. gsd-core/workflows/update.md's `1.4.0`/`1.3.1` examples,
|
|
* agents/gsd-project-researcher.md's `1.2.3`, gsd-core/workflows/help/modes/full.md's
|
|
* `1.0.0` — and those must stay VISIBLE to the parity gate if they ever change.
|
|
*
|
|
* So instead of replacing the version everywhere, this only normalizes it at the
|
|
* specific places `bin/install.js` actually stamps `pkg.version` into emitted content
|
|
* as a version FIELD/marker (not prose):
|
|
* - `// gsd-hook-version: <ver>` / `# gsd-hook-version: <ver>` — the
|
|
* `{{GSD_VERSION}}` substitution done for every emitted hook file.
|
|
* - `"version": "<ver>"` — JSON manifests (plugin/extension/capability-style)
|
|
* embedding the package version as a string field.
|
|
* - `version: "<ver>"` / `version: <ver>` — YAML frontmatter version fields (e.g.
|
|
* skill frontmatter's `yamlQuote(pkg.version)`, Hermes' category
|
|
* `DESCRIPTION.md`).
|
|
* - `@opengsd/gsd-core@<ver>` — pinned package-spec references.
|
|
* - a file whose ENTIRE trimmed content IS the version (`gsd-core/VERSION`).
|
|
* Each pattern only matches when the version in the content EQUALS the supplied
|
|
* `pkgVersion` — this is deliberately narrow, at the cost of needing a new pattern any
|
|
* time the installer grows a new stamp site (see the empirical repro-harness check
|
|
* this fix was verified against, #2891 review FINDING 1).
|
|
*
|
|
* @param {string} content
|
|
* @param {string} pkgVersion - already validated non-empty semver-ish string.
|
|
* @returns {string}
|
|
*/
|
|
function normalizeVersionStamps(content, pkgVersion) {
|
|
const v = escapeRegExp(pkgVersion);
|
|
// `(?![\w.+-])` after a bare (unquoted/uncaptured-suffix) match stops a version
|
|
// from matching as a PREFIX of a longer version-shaped string it is not equal to
|
|
// (e.g. pkgVersion '1.9.0' must not match inside '1.9.0-rc.1' or '1.9.0.1').
|
|
return content
|
|
.replace(new RegExp(`((?:\\/\\/|#)\\s*gsd-hook-version:\\s*)${v}(?![\\w.+-])`, 'g'), '$1<VERSION>')
|
|
.replace(new RegExp(`("version"\\s*:\\s*")${v}(")`, 'g'), '$1<VERSION>$2')
|
|
.replace(new RegExp(`(version:\\s*")${v}(")`, 'g'), '$1<VERSION>$2')
|
|
.replace(new RegExp(`(version:\\s*)${v}(?![\\w.+-])`, 'g'), '$1<VERSION>')
|
|
.replace(new RegExp(`(@opengsd/gsd-core@)${v}(?![\\w.+-])`, 'g'), '$1<VERSION>')
|
|
.replace(new RegExp(`^(\\s*)${v}(\\s*)$`), '$1<VERSION>$2');
|
|
}
|
|
|
|
function walk(dir) {
|
|
const results = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) results.push(...walk(full));
|
|
else results.push(full);
|
|
}
|
|
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>'
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.pkgVersion] - the version string to normalize to '<VERSION>'.
|
|
* MUST be the version of the tree that PRODUCED the emitted content at `configDir`
|
|
* — NOT necessarily this checkout's own version. OMITTING this option (or the whole
|
|
* `opts` argument) defaults to this checkout's own PKG_VERSION, which is only
|
|
* correct when `configDir` was emitted by THIS checkout's installer. A caller
|
|
* measuring a DIFFERENT tree's installer output (#2767's `repoRoot`-driven spawns)
|
|
* must pass that other tree's own version explicitly, or the normalization silently
|
|
* compares apples to oranges: baseline hooks baked with version X never collapse to
|
|
* '<VERSION>' when normalized against version Y, so every emitted file looks changed
|
|
* even when byte-identical apart from the version stamp. Must be a non-empty
|
|
* semver-ish string (`MAJOR.MINOR.PATCH` with optional prerelease/build metadata)
|
|
* when the key is REACHABLE at all — see the guard below for why, and why an
|
|
* explicit `{ pkgVersion: undefined }` is treated as a caller error rather than
|
|
* silently falling back to the default (that fallback is the exact bug being
|
|
* fixed). `opts` itself must be a plain object (or omitted/undefined) — `null` or a
|
|
* non-object throws rather than reaching `Object`'s coercion of `null`/`undefined`
|
|
* (#2891 review FINDING 5).
|
|
* @returns {{ [rel: string]: string }}
|
|
*/
|
|
/**
|
|
* Shared walk+validate+normalize core for buildParityManifest and
|
|
* buildEmittedSizes. Both need the EXACT same file set (VOLATILE_FILES,
|
|
* HOOK_CONFIG_FILES, HOOK_CONFIG_RELATIVE_PATHS, EXCLUDED_PREFIXES) and the
|
|
* exact same `<HOME>`/version-stamp normalized content — one hashes it, the
|
|
* other measures its byte length — so the coverage and the normalization live
|
|
* in ONE place instead of being copy-pasted across the two (the copy-paste
|
|
* itself is the drift risk this function exists to remove; see #2931 brief).
|
|
*
|
|
* @param {string} configDir
|
|
* @param {string} root
|
|
* @param {object} opts - same shape as buildParityManifest's `opts` (see its
|
|
* JSDoc for the full pkgVersion/opts contract this validates).
|
|
* @param {string} callerName - used only in thrown error messages, so a
|
|
* caller-facing error still names the PUBLIC function the caller invoked
|
|
* (`buildParityManifest` / `buildEmittedSizes`), not this internal helper.
|
|
* @returns {{ [rel: string]: string }} rel -> normalized utf8 content, sorted keys.
|
|
*/
|
|
function collectNormalizedEmittedFiles(configDir, root, opts, callerName) {
|
|
// `opts = {}` at each public call site only substitutes for an OMITTED (or
|
|
// explicit `undefined`) third argument — `null` and other non-object values
|
|
// sail past that default parameter and would otherwise reach the `in` check
|
|
// below and throw a raw, unhelpful `TypeError: Cannot convert undefined or
|
|
// null to object` (#2891 review FINDING 5). Fail with a clear, attributable
|
|
// message instead.
|
|
if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
|
|
throw new Error(
|
|
`${callerName}: opts must be a plain object or omitted, got ${JSON.stringify(opts)}.`
|
|
);
|
|
}
|
|
|
|
// Distinguish "the caller didn't pass pkgVersion at all" (legitimate — use this
|
|
// checkout's own PKG_VERSION) from "the caller passed pkgVersion explicitly, and it
|
|
// happens to be undefined/null/empty/non-string" (a caller error that must throw,
|
|
// never silently fall back). A plain default-parameter (`{ pkgVersion = PKG_VERSION
|
|
// } = {}`) cannot make this distinction — JS treats an explicit `undefined` value
|
|
// identically to an absent key, which would let a caller-side bug (e.g. a repoRoot
|
|
// version lookup that resolved to undefined) silently normalize against THIS
|
|
// checkout's version instead of throwing — exactly the cross-tree mis-attribution
|
|
// bug #2891 fixes. Use the `in` operator (not `hasOwnProperty`) so the key is honored
|
|
// whether it is OWN or INHERITED — `Object.create({ pkgVersion: 'x' })` reaches this
|
|
// function with the key reachable via the prototype chain, and a caller-error value
|
|
// sitting there must still be validated (and rejected) rather than silently ignored
|
|
// in favor of the default; only a key ABSENT from the whole chain means "caller
|
|
// didn't specify one, use this checkout's own version" (#2891 review FINDING 4).
|
|
const pkgVersion = 'pkgVersion' in opts ? opts.pkgVersion : PKG_VERSION;
|
|
|
|
// GUARD: an empty/falsy/non-string/non-semver-shaped pkgVersion must never reach the
|
|
// normalization below. A careless caller passing e.g. '1' would silently match and
|
|
// corrupt content that merely contains that digit as a substring of an unrelated
|
|
// number (#2891 review FINDING 2) — and, pre-FINDING-1, an empty string reaching
|
|
// `.split('')` would have exploded manifest content into individual characters. Fail
|
|
// closed instead of falling back to this checkout's PKG_VERSION: a silent fallback is
|
|
// exactly the cross-tree mis-attribution bug this option exists to fix (#2891).
|
|
if (typeof pkgVersion !== 'string' || pkgVersion.length === 0 || !SEMVER_ISH_RE.test(pkgVersion)) {
|
|
throw new Error(
|
|
`${callerName}: pkgVersion must be a non-empty semver-ish string ` +
|
|
`(MAJOR.MINOR.PATCH, optional -prerelease/+build), got ${JSON.stringify(pkgVersion)}. ` +
|
|
'Pass the version of the tree that produced the emitted content at configDir.'
|
|
);
|
|
}
|
|
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 results are stable across runs.
|
|
// Also normalize the PRODUCING tree's package version at its known stamp sites
|
|
// (pkgVersion, defaulted to this checkout's own) — via the ANCHORED
|
|
// normalizeVersionStamps, not a blind substring replace — so results survive
|
|
// `npm version` bumps (the rc release step bakes the new version into hook files
|
|
// before running tests) and, for a cross-tree caller, so the version stamp baked
|
|
// in by a DIFFERENT tree's installer doesn't masquerade as a real content diff,
|
|
// WITHOUT also masking a genuine content change to an unrelated bare semver
|
|
// literal that happens to equal pkgVersion (#2891; see normalizeVersionStamps'
|
|
// own doc comment for the blind-replace failure modes this replaced).
|
|
const normalized = normalizeVersionStamps(
|
|
content.toString('utf8')
|
|
.split(realRoot).join('<HOME>')
|
|
.split(root).join('<HOME>'),
|
|
pkgVersion,
|
|
);
|
|
unsorted[rel] = normalized;
|
|
}
|
|
|
|
// 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 buildParityManifest(configDir, root, opts = {}) {
|
|
const normalizedByRel = collectNormalizedEmittedFiles(configDir, root, opts, 'buildParityManifest');
|
|
const out = {};
|
|
for (const rel of Object.keys(normalizedByRel)) {
|
|
out[rel] = crypto.createHash('sha256').update(normalizedByRel[rel]).digest('hex').slice(0, 16);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Build a deterministic byte-size map for all non-volatile files under configDir,
|
|
* measured on the SAME `<HOME>`/version-normalized content buildParityManifest
|
|
* hashes (see collectNormalizedEmittedFiles — one walk, one normalization, two
|
|
* projections, so the two functions can never diverge on which files they cover).
|
|
*
|
|
* Byte counting is LF-normalized (CRLF -> LF, stripped BEFORE the byte count),
|
|
* matching RULESET.WORKFLOW_SIZE_BUDGET ("BYTES not lines, LF-normalized" per
|
|
* #683/#717) and mirroring scripts/workflow-size.cjs's `lfByteCount`. That helper
|
|
* is NOT reused directly here: `lfByteCount(filePath)` re-reads a file from disk
|
|
* by path and has no `<HOME>`/version normalization, whereas this must count the
|
|
* already-normalized IN-MEMORY string collectNormalizedEmittedFiles produced — a
|
|
* re-read would both duplicate I/O and measure the wrong (unnormalized, raw
|
|
* temp-root-embedding) bytes. Only the one-line CRLF-strip + `Buffer.byteLength`
|
|
* formula is duplicated, not a file-reading helper.
|
|
*
|
|
* NOTE: this counts the NORMALIZED content (post `<HOME>` substitution and
|
|
* version-stamp normalization), not raw on-disk bytes. That is deliberate:
|
|
* determinism is the whole point of this map — a caller comparing sizes across
|
|
* machines/CI runs/temp dirs needs a value that does not vary with the temp
|
|
* root's length. Consequence: for any file whose content embeds the temp root
|
|
* (e.g. `@`-referenced absolute paths), this UNDERSTATES real on-disk size by
|
|
* roughly `(len(configDir) - len('<HOME>')) * occurrences`. A future consumer
|
|
* enforcing a byte cap close to a real hard limit must carry its own margin for
|
|
* this gap — it is not folded in here.
|
|
*
|
|
* @param {string} configDir - absolute path to the installed runtime config dir
|
|
* @param {string} root - temp root path to replace with '<HOME>'
|
|
* @param {object} [opts] - same shape/guards as buildParityManifest's `opts`
|
|
* (see its JSDoc for the full pkgVersion/opts contract).
|
|
* @returns {{ [rel: string]: number }}
|
|
*/
|
|
function buildEmittedSizes(configDir, root, opts = {}) {
|
|
const normalizedByRel = collectNormalizedEmittedFiles(configDir, root, opts, 'buildEmittedSizes');
|
|
const out = {};
|
|
for (const rel of Object.keys(normalizedByRel)) {
|
|
out[rel] = Buffer.byteLength(normalizedByRel[rel].replace(/\r\n/g, '\n'), 'utf8');
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Sorted list of emitted relative paths for a runtime install (file-set snapshot,
|
|
* #2267). Reuses buildParityManifest's exact exclusion set so the tree and the
|
|
* content manifest never diverge on which files they cover. Deliberately does NOT
|
|
* take (or forward) a `pkgVersion`/`opts` parameter: the emitted FILE SET is
|
|
* version-independent — `pkgVersion` only ever changes which bytes a file's HASH
|
|
* normalizes to, never which paths buildParityManifest walks or excludes — so there
|
|
* is nothing for a caller to pass here, and forwarding one through would only let a
|
|
* bad version value make a pure file-set query throw for no file-set-shaped reason
|
|
* (#2891 review FINDING 6; verified no caller passes a third argument —
|
|
* tests/golden-install-tree.test.cjs, scripts/gen-install-tree-fixtures.cjs). */
|
|
function buildInstallTree(configDir, root) {
|
|
return Object.keys(buildParityManifest(configDir, root)).sort();
|
|
}
|
|
|
|
function simulateHookCopy(hooksSrc, hooksDest) {
|
|
fs.mkdirSync(hooksDest, { recursive: true });
|
|
for (const entry of fs.readdirSync(hooksSrc)) {
|
|
const srcFile = path.join(hooksSrc, entry);
|
|
if (!fs.statSync(srcFile).isFile()) continue;
|
|
const destFile = path.join(hooksDest, entry);
|
|
if (entry.endsWith('.js')) {
|
|
fs.writeFileSync(destFile, fs.readFileSync(srcFile, 'utf8'));
|
|
try { fs.chmodSync(destFile, 0o755); } catch { /* Windows */ }
|
|
} else {
|
|
fs.copyFileSync(srcFile, destFile);
|
|
if (entry.endsWith('.sh')) {
|
|
try { fs.chmodSync(destFile, 0o755); } catch { /* Windows */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Build a clean env for spawned installer processes.
|
|
* Must strip GSD_TEST_MODE so the child runs the real install, not the no-op guard. */
|
|
function installerEnv(overrides = {}) {
|
|
// #3156: delegate to the ONE canonical raw-installer-spawn env rather than
|
|
// carrying a second shape of it. The installer writes GSD's own user store to
|
|
// <home>/.gsd/defaults.json through os.homedir() DIRECTLY
|
|
// (bin/install.js writeNonClaudeDefaults, #2834), which reads no GSD variable,
|
|
// so no config-location scrub can reach it — only a sandboxed HOME can. Every
|
|
// caller that already passes an explicit { HOME, USERPROFILE } still wins:
|
|
// overrides spread last.
|
|
const { installSpawnEnv } = require('../helpers.cjs');
|
|
const env = installSpawnEnv(overrides);
|
|
delete env.GSD_TEST_MODE;
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* @param {object} opts
|
|
* @param {string} opts.runtime
|
|
* @param {string} opts.scope
|
|
* @param {string[]} [opts.extraArgs]
|
|
* @param {string} [opts.installScript] - Absolute path to the `bin/install.js` to spawn.
|
|
* Defaults to THIS checkout's own INSTALL_SCRIPT. Overridable (#2767) so a caller can
|
|
* measure a DIFFERENT tree's installer — e.g. the differential baseline builder
|
|
* pointing at a `git worktree` checked out at the base ref, so the emitted manifest it
|
|
* produces reflects that ref's own installer code, not the PR checkout's.
|
|
* @param {string} [opts.root] - Reuse an existing sandbox HOME instead of creating one.
|
|
* When supplied, the caller owns its lifetime and it is NOT removed on failure.
|
|
* @param {object} [opts.extraEnv] - Extra environment variables merged over the
|
|
* installer env (after HOME/USERPROFILE), e.g. a runtime's config-home override.
|
|
*/
|
|
function runMinimalInstall({ runtime, scope, extraArgs = [], installScript = INSTALL_SCRIPT, root: providedRoot = null, extraEnv = {} }) {
|
|
const ownsRoot = providedRoot === null;
|
|
const root = providedRoot ?? fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`));
|
|
try {
|
|
const LOCAL_DIR_NAME = {
|
|
claude: '.claude', opencode: '.opencode', kilo: '.kilo',
|
|
codex: '.codex', copilot: '.github', antigravity: '.agents', cursor: '.cursor',
|
|
windsurf: '.windsurf', augment: '.augment', trae: '.trae', qwen: '.qwen',
|
|
codebuddy: '.codebuddy', cline: '.',
|
|
// #3023: pi was in RUNTIME_META but absent here, so `scope: 'local'` for pi
|
|
// resolved `path.join(root, undefined)` and threw — no local-scope pi install
|
|
// could ever be exercised. pi's local config dir is `.pi`
|
|
// (capabilities/pi/capability.json runtime.localConfigDir).
|
|
pi: '.pi',
|
|
};
|
|
let configDir;
|
|
let cwd = process.cwd();
|
|
const args = [installScript, `--${runtime}`];
|
|
if (scope === 'global') {
|
|
args.push('--global', '--config-dir', root);
|
|
configDir = root;
|
|
} else {
|
|
args.push('--local');
|
|
cwd = root;
|
|
configDir = runtime === 'cline' ? root : path.join(root, LOCAL_DIR_NAME[runtime]);
|
|
}
|
|
args.push(...extraArgs);
|
|
const result = runNode(args, {
|
|
cwd,
|
|
env: installerEnv({ HOME: root, USERPROFILE: root, ...extraEnv }),
|
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
});
|
|
// Kept as a hand-rolled assert (rather than throwIfFailed from
|
|
// git-fixture.cjs) so the embedded stdout+stderr survives verbatim —
|
|
// that is the whole diagnostic value of this message for a failing
|
|
// install, and throwIfFailed's message only carries a trimmed stderr.
|
|
// `result.exitCode` (never `result.status` — the seam has no such key)
|
|
// is `null` for a non-EXITED outcome (TIMED_OUT/KILLED/BUFFER_OVERFLOW/
|
|
// SPAWN_FAILED), so `outcome`/`timedOut`/`signal` are folded into the
|
|
// message too: a bare "expected null to equal 0" would not tell anyone
|
|
// the installer never actually exited.
|
|
assert.strictEqual(result.exitCode, 0,
|
|
`installer exited with status ${result.exitCode} (outcome=${result.outcome}` +
|
|
`${result.timedOut ? ', timedOut=true' : ''}${result.signal ? `, signal=${result.signal}` : ''}) ` +
|
|
`for ${runtime} --${scope}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const manifestPath = path.join(configDir, MANIFEST_NAME);
|
|
const manifest = fs.existsSync(manifestPath)
|
|
? JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
: null;
|
|
return { manifest, configDir, root, stdout: result.stdout, stderr: result.stderr };
|
|
} catch (err) {
|
|
// Only reclaim a root this call created. A caller-supplied root may be
|
|
// shared across several installs (e.g. two runtimes into one HOME), so
|
|
// tearing it down here would destroy the caller's other fixtures.
|
|
if (ownsRoot) fs.rmSync(root, { recursive: true, force: true });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function manifestSkillSet(manifest) {
|
|
if (!manifest || !manifest.files) return new Set();
|
|
const out = new Set();
|
|
for (const key of Object.keys(manifest.files)) {
|
|
if (key.startsWith('skills/')) {
|
|
const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, '');
|
|
out.add(seg);
|
|
} else if (key.startsWith('command/')) {
|
|
// OpenCode/Kilo: command/gsd-<cmd>.md
|
|
const file = key.split('/')[1];
|
|
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
} else if (key.startsWith('commands/gsd/')) {
|
|
// Gemini: commands/gsd/<cmd>.toml (nested, colon-namespaced)
|
|
const file = key.split('/')[2];
|
|
out.add(file.replace(/\.(md|toml)$/, ''));
|
|
} else if (key.startsWith('commands/') && key.split('/').length === 2) {
|
|
// Claude local (#1367 fix): flat commands/gsd-<cmd>.md
|
|
const file = key.split('/')[1];
|
|
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
|
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function manifestAgentCount(manifest) {
|
|
if (!manifest || !manifest.files) return 0;
|
|
return Object.keys(manifest.files).filter((k) => k.startsWith('agents/')).length;
|
|
}
|
|
|
|
/**
|
|
* Collect gsd-* skill/command basenames actually present on disk under configDir.
|
|
*
|
|
* @param {string} configDir
|
|
* @param {string} [runtime] - when provided, the skills-kind destination is
|
|
* resolved via resolveRuntimeArtifactLayout so a skills-kind `home` override
|
|
* (Codex only, ADR-1239 upgrade 3 / #2088: skills -> $HOME/.agents/skills
|
|
* instead of configDir/skills) is honored. Omitted callers keep the prior
|
|
* configDir/skills default.
|
|
* @param {string} [scope='global']
|
|
*/
|
|
function collectSkillBasenamesOnDisk(configDir, runtime, scope = 'global') {
|
|
const out = new Set();
|
|
let skillsDir = path.join(configDir, 'skills');
|
|
if (runtime) {
|
|
try {
|
|
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
|
|
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
|
|
if (skillsKind) skillsDir = path.join(skillsKind.home || configDir, skillsKind.destSubpath);
|
|
} catch { /* fall back to configDir/skills */ }
|
|
}
|
|
if (fs.existsSync(skillsDir)) {
|
|
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
|
out.add(entry.name.replace(/^gsd-/, ''));
|
|
} else if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
|
|
out.add(entry.name.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
}
|
|
}
|
|
}
|
|
const commandDir = path.join(configDir, 'command');
|
|
if (fs.existsSync(commandDir)) {
|
|
for (const file of fs.readdirSync(commandDir)) {
|
|
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
|
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
}
|
|
}
|
|
}
|
|
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
|
|
if (fs.existsSync(commandsGsdDir)) {
|
|
for (const file of fs.readdirSync(commandsGsdDir)) {
|
|
if (file.endsWith('.md') || file.endsWith('.toml')) {
|
|
out.add(file.replace(/\.(md|toml)$/, ''));
|
|
}
|
|
}
|
|
}
|
|
// Claude local (#1367 fix): flat gsd-*.md files at commands/ level
|
|
const flatCommandsDir = path.join(configDir, 'commands');
|
|
if (fs.existsSync(flatCommandsDir)) {
|
|
for (const file of fs.readdirSync(flatCommandsDir)) {
|
|
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
|
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
module.exports = {
|
|
INSTALL_SCRIPT,
|
|
MANIFEST_NAME,
|
|
BUILD_SCRIPT,
|
|
HOOKS_DIST,
|
|
EXPECTED_SH_HOOKS,
|
|
EXPECTED_ALL_HOOKS,
|
|
RUNTIME_META,
|
|
MANIFEST_FAMILIES,
|
|
MINIMUM_MANIFEST_FAMILIES,
|
|
SKILL_RUNTIMES,
|
|
PKG_VERSION,
|
|
VOLATILE_FILES,
|
|
HOOK_CONFIG_FILES,
|
|
HOOK_CONFIG_RELATIVE_PATHS,
|
|
EXCLUDED_PREFIXES,
|
|
stripAnsi,
|
|
walk,
|
|
buildParityManifest,
|
|
buildEmittedSizes,
|
|
buildInstallTree,
|
|
simulateHookCopy,
|
|
installerEnv,
|
|
runMinimalInstall,
|
|
manifestSkillSet,
|
|
manifestAgentCount,
|
|
collectSkillBasenamesOnDisk,
|
|
};
|