enhance(#2872): record scope and runtime in the install manifest (#3323)

* enhance(#2872): record scope and runtime in the install manifest

gsd-file-manifest.json gains manifestVersion, runtime and scope, and a new
read-only Installed Surface Resolver Module reads both install scopes for a
runtime in one call -- the first code path in the repo that does.

Phase 3 of epic #2866 (ADR-2866). Blocks Phase 4 (#2873), which resolves
#2218: the resolver's shadowedBy field is that defect expressed as a value
for the first time. It ships computed-and-unread here.

Installed-ness is decided by manifest PRESENCE, never by the new fields, so
a manifest written by an older GSD stays fully functional and no user needs
to reinstall. Recorded runtime/scope are corroboration: a disagreement with
the probed config dir is reported as declaredScopeMatchesProbe: false, never
silently corrected.

readInstallManifest is widened additively -- version/timestamp/mode/files
keep their exact names, types and meanings for all four existing callers.
manifestVersion is a new field rather than a reinterpretation of version,
which holds the package version and is read by the golden-parity fixtures.

Stems are derived from the installed manifest's own file keys, the inverse
of Phase 2's filename composition, guarded by a fast-check round-trip
property plus a kebab-case charset check so a crafted manifest key cannot
put a traversal segment, control character or ANSI escape into a trigger
that Phase 4 renders back to the user.

Also fixes two defects found while working:
- bin/install.js hardcoded manifestVersion: 2 while the reader owned
  MANIFEST_SCHEMA_VERSION = 2. Now single-sourced, with a parity test.
- docs/installer-migrations.md documented an install-state schema of five
  snake_case fields that have never been written; InstallState has only ever
  been { schemaVersion, appliedMigrations }. Corrected with a dated note.

Verification runs on the remote runner.

* fix(#2872): fold review findings from three independent engines

Standards axis:
- convert the manifest-schema suite from a hybrid setup(t) closure to
  beforeEach/afterEach (CONTRIBUTING.md:319-354 Pattern 1). The hybrid was
  neither approved pattern and a new test forgetting the call got no warning.
- SCOPE_ORDER was declared twice with no parity test -- this repo's recorded
  generative-fix-divergence class. Give the ordering one owner: install-scope
  exports it frozen, the layout module and the resolver both import it, and a
  test locks it against scopeRank so the constant and the ranks cannot drift.
- drop the defaultReadManifest passthrough (Middle Man).

Spec axis:
- add the VOLATILE_FILES exclusion test and source comment the acceptance
  table promised and did not deliver. gsd-file-manifest.json stays excluded:
  the new fields are deterministic, but timestamp -- the original reason --
  is unchanged.

Security axis:
- bound the reported manifest runtime at 64 chars, matching the
  truncatePostureValue convention already used in this subsystem. It reached
  declaredRuntime unbounded while the adjacent stems were gated by SAFE_STEM;
  an inconsistent posture on the same attacker-influenceable document. The
  charset stays ungated on purpose -- declaredRuntimeMatchesProbe needs to see
  the real value -- so Phase 4 must sanitize before rendering, recorded in the
  design's Known limits.

Both new parity tests were verified to FAIL when the two sides are made to
disagree, then pass again on revert. Verification runs on the remote runner.

* chore(#2872): backfill changeset pr number to 3323

* fix(#2872): give git fixture construction its own timeout class

PR #3323's full test (windows-latest, 22, shard 2/3) failed with

  gitOrThrow: 'git init' failed -- outcome=timed_out exitCode=null
  gitOrThrow: 'git commit --allow-empty' failed -- outcome=timed_out

from drift-detection.test.cjs's beforeEach, a file this branch never touched.
Every other lane passed the same commit, including windows-latest node 24 on
all three shards, and next is green.

Root cause is a bound sized for the wrong class. DEFAULT_GIT_TIMEOUT_MS is
15000 and its own comment scopes it to plumbing READS -- rev-parse, branch,
log -- against an existing repo. createFixture uses it for six sequential
repo-CONSTRUCTION spawns: init, three config writes, add -A, commit. init and
commit each write dozens of files, and on Windows every spawn is
Defender-scanned. Sibling tests in the failing block took 15.6-22.0s against
a 15000ms bound.

This repo already diagnosed this exact shape once: timeouts.cjs's
HOOK_FANOUT_TIMEOUT_MS records PR #3285 failing in the SAME job with the SAME
outcome=timed_out exitCode=null signature at the SAME bound while every other
lane passed, and concludes 'a bound sized for the wrong class, not a slow
machine'. It was fixed by splitting out a heavier class-norm at 60000. Same
remedy here: GIT_FIXTURE_TIMEOUT_MS = 60000, 4x the bound that failed and half
INSTALL_TIMEOUT_MS.

DEFAULT_GIT_TIMEOUT_MS deliberately stays at 15000 -- a blanket raise would
stop a genuinely hung plumbing read from surfacing fast.

Verified the value reaches the spawn rather than being an ignored option:
spawnSync was monkeypatched before requiring the fixture module, and all six
git construction calls were captured carrying timeout: 60000.

This branch's two new test files shift shard composition, which is how a
pre-existing fragility landed in the heaviest shard on the slowest lane.
Fixed here rather than deferred, per the no-defer rule.

Verification runs on the remote runner.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-10 15:50:55 -04:00
committed by GitHub
parent 80734a9694
commit 7a7bf19fc1
23 changed files with 2344 additions and 46 deletions

View File

@@ -1166,3 +1166,158 @@ describe('cmdValidateAgents surfaces the Codex posture result (#3242 row 20)', (
);
});
});
// ─── #2872 (ADR-2866 Phase 3): checkAgentsInstalled unchanged by the ──────
// manifest-reader de-duplication (50-test-matrix.md section 5, rows A1-A5)
//
// installer-migrations.cts's readInstallManifest now records manifestVersion
// /runtime/scope, but checkAgentsInstalled only ever consumed `.files`
// before AND after the substitution (agent-install-check.cts:191-194
// swapped an inlined try/readFileSync/JSON.parse for the shared reader,
// zero new branches) — see 40-design.md's "Rejected" #4 for why this
// function is not rewired onto the two-scope resolver instead. These rows
// prove the substitution changed nothing observable here.
describe('checkAgentsInstalled — unchanged by the manifest-reader substitution (#2872 A1-A5)', () => {
describe('A1-A4: v1/v2 manifest parity', () => {
let tmpDir;
let agentsDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-agent-check-schema-');
agentsDir = path.join(tmpDir, 'agents');
process.env['GSD_AGENTS_DIR'] = agentsDir;
fs.mkdirSync(agentsDir, { recursive: true });
for (const agent of EXPECTED_AGENTS) {
fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`);
}
});
afterEach(() => {
cleanup(tmpDir);
});
// A1 — a v1 manifest (no manifestVersion/runtime/scope key at all)
// beside the agents dir: result identical to pre-#2872 behavior.
test('A1: checkAgentsInstalled is unchanged for a v1 manifest', () => {
const manifestFiles = {};
for (const agent of EXPECTED_AGENTS) manifestFiles[`agents/${agent}.md`] = 'somehash';
fs.writeFileSync(
path.join(tmpDir, 'gsd-file-manifest.json'),
JSON.stringify({
version: '1.49.0',
timestamp: '2026-05-10T00:00:00.000Z',
mode: 'full',
files: manifestFiles,
}),
);
const result = agentInstallCheck.checkAgentsInstalled();
assert.strictEqual(result.agents_installed, true);
assert.deepStrictEqual(result.missing_agents, []);
assert.deepStrictEqual(result.incomplete_agents, []);
});
// A2 — a v2 manifest (carrying manifestVersion/runtime/scope) produces
// the exact same result: the new fields change nothing here.
test('A2: checkAgentsInstalled is unchanged for a v2 manifest', () => {
const manifestFiles = {};
for (const agent of EXPECTED_AGENTS) manifestFiles[`agents/${agent}.md`] = 'somehash';
fs.writeFileSync(
path.join(tmpDir, 'gsd-file-manifest.json'),
JSON.stringify({
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime: 'claude',
scope: 'global',
files: manifestFiles,
}),
);
const result = agentInstallCheck.checkAgentsInstalled();
assert.strictEqual(result.agents_installed, true);
assert.deepStrictEqual(result.missing_agents, []);
assert.deepStrictEqual(result.incomplete_agents, []);
});
// A3 — no manifest at all (readInstallManifest's absent-file branch):
// the completeness check is skipped, no throw — today's behavior.
// Presence (the .md files above) still makes the install look complete.
test('A3: an unreadable/absent manifest still skips the completeness check', () => {
let result;
assert.doesNotThrow(() => {
result = agentInstallCheck.checkAgentsInstalled();
});
assert.deepStrictEqual(result.incomplete_agents, []);
assert.strictEqual(result.agents_installed, true);
});
// A4 — manifest present, one agent's .toml tracked but absent on disk:
// still reported as incomplete, same as before the substitution.
test('A4: still reports an incomplete agent', () => {
const targetAgent = EXPECTED_AGENTS[0];
const manifestFiles = {};
for (const agent of EXPECTED_AGENTS) manifestFiles[`agents/${agent}.md`] = 'somehash';
manifestFiles[`agents/${targetAgent}.toml`] = 'somehash';
fs.writeFileSync(
path.join(tmpDir, 'gsd-file-manifest.json'),
JSON.stringify({
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime: 'claude',
scope: 'global',
files: manifestFiles,
}),
);
const result = agentInstallCheck.checkAgentsInstalled();
assert.ok(
result.incomplete_agents.includes(targetAgent),
`expected ${targetAgent} in incomplete_agents, got: ${JSON.stringify(result.incomplete_agents)}`,
);
assert.strictEqual(result.agents_installed, false);
});
});
// A5 — getAgentsDir's local-install probe is deliberately NOT rewired to
// readInstallManifest: it stays an lstat-based (symlink-unaware) presence
// check (agent-install-check.cts:123, "Not touched" in 40-design.md's
// blast-radius table), so a manifest path that is itself a SYMLINK is
// still ignored, exactly as before the substitution.
test('A5: getAgentsDir still ignores a symlinked manifest', (t) => {
const projectRoot = createTempDir('gsd-local-codex-schema-');
const globalHome = createTempDir('gsd-global-codex-schema-');
const localConfigDir = path.join(projectRoot, '.codex');
const localAgentsDir = path.join(localConfigDir, 'agents');
const realManifestTarget = path.join(projectRoot, 'real-manifest.json');
t.after(() => cleanup(projectRoot));
t.after(() => cleanup(globalHome));
fs.mkdirSync(localAgentsDir, { recursive: true });
for (const agent of EXPECTED_AGENTS) {
fs.writeFileSync(path.join(localAgentsDir, `${agent}.toml`), `name = "${agent}"\n`);
}
fs.writeFileSync(realManifestTarget, JSON.stringify({ files: {} }));
const manifestPath = path.join(localConfigDir, 'gsd-file-manifest.json');
createCompleteAgents(path.join(globalHome, 'agents'));
process.env['CODEX_HOME'] = globalHome;
try {
fs.symlinkSync(realManifestTarget, manifestPath, 'file');
} catch (error) {
if (error && ['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) {
t.skip('symlink creation is not available on this platform');
return;
}
throw error;
}
// getAgentsDir's own probe (`fs.lstatSync(manifestPath).isFile()`) sees
// the manifest path as a symlink, not a regular file — isFile() is false
// for the link itself even though its target is a regular file — so the
// local install is NOT selected; the global fallback wins instead.
assert.strictEqual(agentInstallCheck.getAgentsDir('codex', projectRoot), path.join(globalHome, 'agents'));
assert.notStrictEqual(agentInstallCheck.getAgentsDir('codex', projectRoot), localAgentsDir);
});
});

View File

@@ -1,7 +1,7 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { gitOrThrow } = require('../helpers/git-fixture.cjs');
const { gitOrThrow, GIT_FIXTURE_TIMEOUT_MS } = require('../helpers/git-fixture.cjs');
/**
* Create a temp test fixture directory with canonical planning layout.
@@ -36,16 +36,20 @@ function createFixture(options = {}) {
}
if (git) {
gitOrThrow(['init'], { cwd: tmpDir });
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: tmpDir });
gitOrThrow(['config', 'user.name', 'Test'], { cwd: tmpDir });
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: tmpDir });
gitOrThrow(['add', '-A'], { cwd: tmpDir });
// Construction calls (init/config/add/commit), a heavier class than a
// plumbing read — each of `init`/`commit` writes dozens of files, and on
// Windows every spawn is Defender-scanned (#3323).
const gitOpts = { cwd: tmpDir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS };
gitOrThrow(['init'], gitOpts);
gitOrThrow(['config', 'user.email', 'test@test.com'], gitOpts);
gitOrThrow(['config', 'user.name', 'Test'], gitOpts);
gitOrThrow(['config', 'commit.gpgsign', 'false'], gitOpts);
gitOrThrow(['add', '-A'], gitOpts);
// `--allow-empty`: a fixture with `git: true, planning: false,
// projectDoc: false` (e.g. a "greenfield" starting world) stages nothing,
// so a plain `git commit` would fail with "nothing to commit" and the
// caller would never get a usable repo (there'd be no HEAD at all).
gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], { cwd: tmpDir });
gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], gitOpts);
}
return tmpDir;

View File

@@ -48,6 +48,13 @@ test('install-shared.cjs exports the canonical buildParityManifest + exclusion c
installShared.VOLATILE_FILES instanceof Set,
'VOLATILE_FILES must be a Set'
);
assert.ok(
installShared.VOLATILE_FILES.has('gsd-file-manifest.json'),
"VOLATILE_FILES must keep 'gsd-file-manifest.json' excluded (#2872 acceptance " +
'criterion: the manifest gained manifestVersion/runtime/scope, but its `timestamp` ' +
'field is unchanged and still varies per install — if this ever stops being true, ' +
'the golden fixtures start hashing a per-install timestamp)'
);
assert.ok(
installShared.HOOK_CONFIG_FILES instanceof Set,
'HOOK_CONFIG_FILES must be a Set'

View File

@@ -50,6 +50,31 @@ const { runGit, OUTCOME } = require('./process-seam.cjs');
*/
const DEFAULT_GIT_TIMEOUT_MS = 15000;
/**
* Timeout for git calls that CONSTRUCT a fixture repository, in milliseconds.
*
* A distinct class from `DEFAULT_GIT_TIMEOUT_MS` above, which is sized for
* plumbing READS (rev-parse, branch, log) against an existing repo.
* `createFixture` (`tests/fixtures/index.cjs`) issues SIX sequential spawns to
* build one repo — `init`, three `config` writes, `add -A`, `commit` — and
* `init`/`commit` each write dozens of files. On Windows every one of those
* spawns is Defender-scanned, so the construction sequence is materially
* heavier than any single read.
*
* CI (PR #3323, `full test (windows-latest, 22, shard 2/3)`) recorded
* `gitOrThrow: git init failed — outcome=timed_out exitCode=null` and the same
* for `git commit --allow-empty`, with sibling tests in the same block taking
* 15.6-22.0s, while every other lane — including windows-latest node 24, all
* three shards — passed the same commit. That is a bound sized for the wrong
* class, not a slow machine: the identical conclusion, in the identical job,
* that `HOOK_FANOUT_TIMEOUT_MS` records for PR #3285.
*
* 60000ms is 4x the bound that failed and half `INSTALL_TIMEOUT_MS` — the same
* ratio `HOOK_FANOUT_TIMEOUT_MS` uses, and the right order for a call that is
* far heavier than a plumbing read but much lighter than a full installer run.
*/
const GIT_FIXTURE_TIMEOUT_MS = 60000;
/**
* Throw on anything other than a clean (exit 0) process-seam result,
* preserving the legacy `execSync`/`execFileSync` throw-on-failure idiom
@@ -147,4 +172,10 @@ function toLegacyResult(result) {
return { status: result.exitCode, stdout: result.stdout, stderr: result.stderr };
}
module.exports = { gitOrThrow, throwIfFailed, toLegacyResult, DEFAULT_GIT_TIMEOUT_MS };
module.exports = {
gitOrThrow,
throwIfFailed,
toLegacyResult,
DEFAULT_GIT_TIMEOUT_MS,
GIT_FIXTURE_TIMEOUT_MS,
};

View File

@@ -154,6 +154,11 @@ const PKG_VERSION = require('../../package.json').version;
// 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',

View File

@@ -21,7 +21,7 @@
* sites onto a shared value that doesn't describe them.
*/
const { DEFAULT_GIT_TIMEOUT_MS } = require('./git-fixture.cjs');
const { DEFAULT_GIT_TIMEOUT_MS, GIT_FIXTURE_TIMEOUT_MS } = require('./git-fixture.cjs');
/**
* A single short CLI query or `node -e` probe against a temp fixture —
@@ -62,6 +62,13 @@ const HOOK_FANOUT_TIMEOUT_MS = 60000;
*/
const GIT_TIMEOUT_MS = DEFAULT_GIT_TIMEOUT_MS;
/**
* Git fixture CONSTRUCTION calls (init/config/add/commit) — a heavier class
* than `GIT_TIMEOUT_MS`. See `tests/helpers/git-fixture.cjs`'s
* `GIT_FIXTURE_TIMEOUT_MS` for the full rationale (PR #3323); re-exported
* here rather than restated so the two can never disagree.
*/
/**
* Hooks bundling via `scripts/build-hooks.js` (not a full project build —
* see per-site comments for sites that run a heavier build and therefore
@@ -83,6 +90,7 @@ module.exports = {
PROBE_TIMEOUT_MS,
HOOK_FANOUT_TIMEOUT_MS,
GIT_TIMEOUT_MS,
GIT_FIXTURE_TIMEOUT_MS,
BUILD_TIMEOUT_MS,
INSTALL_TIMEOUT_MS,
};

View File

@@ -23,7 +23,7 @@ const assert = require('node:assert/strict');
const path = require('node:path');
const { toPosixPath } = require('./helpers.cjs');
const { resolveScope, isGlobalScope } = require('../gsd-core/bin/lib/install-scope.cjs');
const { resolveScope, isGlobalScope, SCOPE_ORDER, scopeRank } = require('../gsd-core/bin/lib/install-scope.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { createRuntimeArtifactInstallPlan } = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs');
@@ -278,3 +278,29 @@ describe('isGlobalScope', () => {
);
});
});
describe('SCOPE_ORDER (#2872 review finding — single source of the scope axis ordering)', () => {
// `runtime-artifact-layout.cts` and `installed-surface-resolver.cts` both
// consume this constant instead of each re-declaring their own
// `['global', 'local']` literal (the "generative fix divergence" class
// recorded in this repo's CLAUDE.md). Locking the exact value here is what
// makes a future re-declaration in either consumer fail somewhere, rather
// than silently drifting.
test('is exactly [\'global\', \'local\']', () => {
assert.deepStrictEqual(SCOPE_ORDER, ['global', 'local']);
});
test('is frozen', () => {
assert.strictEqual(Object.isFrozen(SCOPE_ORDER), true);
});
// Derives the expected order from scopeRank rather than hardcoding
// ['global', 'local'] a second time: this fails if someone reorders
// SCOPE_ORDER without changing the ranks, and equally fails if someone
// changes the ranks without reordering SCOPE_ORDER — the two can never
// silently drift apart.
test('is sorted by scopeRank descending', () => {
const expected = [...SCOPE_ORDER].sort((a, b) => scopeRank(b) - scopeRank(a));
assert.deepStrictEqual(SCOPE_ORDER, expected);
});
});

View File

@@ -0,0 +1,783 @@
'use strict';
/**
* Failing-first suite for `resolveInstalledSurfaces` (#2872 Phase 3).
*
* Implements sections 3 ("resolveInstalledSurfaces") and 4 ("stem derivation
* bijection") of `.gsd/phase/feat-2872-manifest-scope-runtime/50-test-matrix.md`
* (rows S1-S21, B1-B16). Sections 1, 2 and 5 are owned by sibling suites —
* `tests/install-manifest-scope-runtime.test.cjs`,
* `tests/installer-migrations-manifest-schema.test.cjs`,
* `tests/agent-install-check.test.cjs`.
*
* The module under test exposes only ONE runtime value —
* `resolveInstalledSurfaces` itself (the private stem-derivation helpers are
* not exported) — so every row, including the bijection rows in section 4,
* is driven end to end through that single entry point and asserted against
* the `InstalledScopeRecord.stems` field it returns.
*
* ── Why `resolveScope` is never overridden by `opts.registry` ──────────────
* Per the design doc's "Correction" note (`40-design.md`, bottom): an
* injected `opts.registry` reaches only the layout lookup
* (`resolveRuntimeArtifactLayoutFromRegistry`) and `resolveTriggerSurface` —
* never `resolveScope`, which always consults the REAL
* `capability-registry.cjs`. So every test below uses a REAL registered
* runtime id (`claude`, `cursor`, `cline`, `windsurf`) for scope resolution,
* and reaches for `opts.registry` only when a row needs a layout shape the
* real registry does not currently ship (namespaced-by-dir commands, B2/B8).
* C7/C8 (the all-runtimes sweep) are asserted against the real registry only
* — a fully synthetic registry of invented ids would make `resolveScope`
* throw for every one of them and the sweep would vacuously return `[]`.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const fc = require('./helpers/fast-check-setup.cjs');
const { resolveInstalledSurfaces } = require('../gsd-core/bin/lib/installed-surface-resolver.cjs');
const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs');
const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { isNamespacedByDir, composeCommandFilename } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
// ─── Fixture helpers ─────────────────────────────────────────────────────
/** The `readInstallManifest` result shape for "nothing here". */
const ABSENT_MANIFEST = Object.freeze({ manifestVersion: null, runtime: null, scope: null, files: {} });
/** Build a normalized manifest-read result (the shape `opts.readManifest`
* must already return — no re-normalization happens inside the resolver). */
function manifest({ manifestVersion = null, runtime = null, scope = null, files = {} } = {}) {
return { manifestVersion, runtime, scope, files };
}
/**
* Injectable `readManifest` backed by a plain Map keyed on the EXACT
* `configHome` string `resolveScope` will produce for a given runtime/scope
* under the same `home`/`cwd`/`env`/`existsSync` passed to
* `resolveInstalledSurfaces`. Never a hardcoded path literal — keys are
* always computed via the real `resolveScope` (see `scopeHomes` below), so a
* platform-specific separator can never leak into a fixture.
*/
function mkReadManifest(byConfigHome) {
return (configDir) => byConfigHome.get(configDir) ?? ABSENT_MANIFEST;
}
/** The real global/local `configHome` for `runtime` under `home`/`cwd` —
* computed via the actual `resolveScope`, never re-derived by hand, so a
* fixture can never silently drift from what the module under test will
* itself resolve to. */
function scopeHomes(runtime, home, cwd) {
const base = { runtime, env: {}, home, existsSync: () => false, cwd };
return {
global: resolveScope({ ...base, id: 'global' }).configHome,
local: resolveScope({ ...base, id: 'local' }).configHome,
};
}
function baseOpts(home, cwd, overrides = {}) {
return { home, cwd, env: {}, existsSync: () => false, ...overrides };
}
function scopeOf(result, scopeId) {
return result[0].scopes.find((s) => s.scope === scopeId);
}
describe('resolveInstalledSurfaces — scope presence (S1-S3)', () => {
test('reports both scopes uninstalled when nothing is present', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) }));
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].scopes.length, 2);
for (const record of result[0].scopes) {
assert.strictEqual(record.installed, false);
assert.strictEqual(record.manifestVersion, null);
assert.deepStrictEqual(record.stems, []);
}
assert.deepStrictEqual(result[0].triggers, []);
});
test('a global-only install shadows nothing', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
const global = scopeOf(result, 'global');
const local = scopeOf(result, 'local');
assert.strictEqual(global.installed, true);
assert.deepStrictEqual(global.stems, ['plan-phase']);
assert.strictEqual(local.installed, false);
assert.ok(result[0].triggers.length > 0, 'expected at least one trigger');
for (const t of result[0].triggers) assert.strictEqual(t.shadowedBy, null);
});
test('a local-only install shadows nothing', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
const global = scopeOf(result, 'global');
const local = scopeOf(result, 'local');
assert.strictEqual(local.installed, true);
assert.deepStrictEqual(local.stems, ['plan-phase']);
assert.strictEqual(global.installed, false);
assert.ok(result[0].triggers.length > 0, 'expected at least one trigger');
for (const t of result[0].triggers) assert.strictEqual(t.shadowedBy, null);
});
});
describe('resolveInstalledSurfaces — shadowing (S4-S7, acceptance criterion S4)', () => {
test('a claude install at both scopes reports the local command surface as shadowed', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
const triggers = result[0].triggers;
const localCommands = triggers.filter((t) => t.kind === 'commands' && t.scope === 'local');
const globalSkills = triggers.filter((t) => t.kind === 'skills' && t.scope === 'global');
assert.ok(localCommands.length > 0, 'expected at least one local commands trigger');
assert.ok(globalSkills.length > 0, 'expected at least one global skills trigger');
for (const t of localCommands) {
assert.deepStrictEqual(t.shadowedBy, { kind: 'skills', scope: 'global' });
}
for (const t of globalSkills) {
assert.strictEqual(t.shadowedBy, null);
}
});
test('a skills-at-both-scopes runtime reports a same-kind shadow', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('cursor', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
[homes.local, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'local', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('cursor', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
const group = result[0].triggers.filter((t) => t.trigger === 'gsd-plan-phase' && t.kind === 'skills');
const winner = group.find((t) => t.scope === 'global');
const loser = group.find((t) => t.scope === 'local');
assert.ok(winner);
assert.ok(loser);
assert.strictEqual(winner.shadowedBy, null);
assert.deepStrictEqual(loser.shadowedBy, { kind: 'skills', scope: 'global' });
});
test('windsurf does not report a shadow it does not have', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('windsurf', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'windsurf', scope: 'global', files: { 'agents/gsd-planner.md': 'a' } })],
[homes.local, manifest({ manifestVersion: 2, runtime: 'windsurf', scope: 'local', files: { 'workflows/gsd-plan-phase.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('windsurf', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.strictEqual(scopeOf(result, 'global').installed, true);
assert.strictEqual(scopeOf(result, 'local').installed, true);
// Global emits agents only — not trigger-bearing — so its stems are empty.
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
assert.ok(result[0].triggers.length > 0, 'expected at least the local trigger');
assert.ok(result[0].triggers.every((t) => t.shadowedBy === null), 'windsurf must report no shadow at all');
});
test('a runtime with no local emission reports no shadow', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('cline', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'cline', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
// cline's local layout declares no kinds at all — a manifest present
// there still counts as "installed", but contributes no stems.
[homes.local, manifest({ manifestVersion: 2, runtime: 'cline', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('cline', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'local').stems, []);
assert.ok(result[0].triggers.length > 0);
assert.ok(result[0].triggers.every((t) => t.shadowedBy === null));
assert.ok(result[0].triggers.every((t) => t.scope === 'global'));
});
});
describe('resolveInstalledSurfaces — the all-runtimes sweep (S8-S11)', () => {
test('sweeps every installable runtime in a stable order, excluding vscode (S8/S9)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const result = resolveInstalledSurfaces(undefined, baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) }));
const registeredSorted = Object.keys(capabilityRegistry.runtimes).sort();
assert.ok(registeredSorted.includes('vscode'), 'fixture assumption: vscode is registered');
const expected = registeredSorted.filter((id) => id !== 'vscode');
const actual = result.map((r) => r.runtime);
assert.ok(expected.length > 0);
assert.deepStrictEqual(actual, expected, 'the sweep must be sorted and exclude non-installable runtimes');
});
test('throws for an explicitly requested non-installable runtime (S10)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
assert.throws(
() => resolveInstalledSurfaces('vscode', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })),
(err) => err instanceof TypeError,
);
});
test('throws for an unknown runtime (S11)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
assert.throws(
() => resolveInstalledSurfaces('not-a-real-runtime-xyz', baseOpts(home, cwd, { readManifest: mkReadManifest(new Map()) })),
(err) => err instanceof TypeError,
);
});
});
describe('resolveInstalledSurfaces — v1/v2 manifest reporting (S12-S14, acceptance criterion S12)', () => {
test('a v1 manifest is fully functional without reinstall', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 1, runtime: null, scope: null, files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
const global = scopeOf(result, 'global');
assert.strictEqual(global.installed, true);
assert.strictEqual(global.manifestVersion, 1);
assert.strictEqual(global.declaredRuntime, null);
assert.strictEqual(global.declaredScope, null);
assert.strictEqual(global.declaredScopeMatchesProbe, null);
assert.strictEqual(global.declaredRuntimeMatchesProbe, null);
assert.deepStrictEqual(global.stems, ['plan-phase']);
assert.ok(result[0].triggers.length > 0, 'triggers must still be resolved for a v1 install');
});
test('reports a declared-scope mismatch instead of correcting it', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
// Declares 'local' while probed at 'global' — e.g. a manifest copied
// between config dirs.
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: {} })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
const global = scopeOf(result, 'global');
assert.strictEqual(global.scope, 'global', 'the record stays keyed by the PROBED scope');
assert.strictEqual(global.declaredScope, 'local');
assert.strictEqual(global.declaredScopeMatchesProbe, false);
});
test('reports a declared-runtime mismatch', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'cursor', scope: 'global', files: {} })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
const global = scopeOf(result, 'global');
assert.strictEqual(global.declaredRuntime, 'cursor');
assert.strictEqual(global.declaredRuntimeMatchesProbe, false);
});
});
describe('resolveInstalledSurfaces — negative space (S15, S16, S20, S21)', () => {
test('one physical install is never reported as shadowing itself (S15)', () => {
// claude: global name '.claude', local localConfigDir '.claude' — setting
// cwd === home makes both scopes resolve to the SAME configHome (the
// "project at $HOME" case the design calls out).
const shared = '/fixture/shared-home';
const homes = scopeHomes('claude', shared, shared);
assert.strictEqual(homes.global, homes.local, 'fixture assumption: both scopes collapse to one configHome');
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(shared, shared, { readManifest: mkReadManifest(byConfigHome) }));
assert.strictEqual(scopeOf(result, 'global').installed, true);
assert.strictEqual(scopeOf(result, 'local').installed, true);
assert.ok(result[0].triggers.length > 0);
assert.ok(result[0].triggers.every((t) => t.shadowedBy === null), 'a single physical install must never shadow itself');
});
test('an empty manifest is installed with no triggers (S16)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: {} })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
const global = scopeOf(result, 'global');
assert.strictEqual(global.installed, true);
assert.deepStrictEqual(global.stems, []);
assert.deepStrictEqual(result[0].triggers, []);
});
test('non-trigger-bearing manifest keys yield no stems (S20)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({
manifestVersion: 2, runtime: 'claude', scope: 'global',
files: { 'hooks/foo.json': 'a', 'gsd-core/VERSION': 'b', 'settings.json': 'c' },
})],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
});
test('a gsd-prefixed key outside a declared subpath is not a trigger (S21)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'gsd-something.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
});
});
describe('resolveInstalledSurfaces — filesystem failure and the STEP 1 fix (S17)', () => {
test('an unreadable config home degrades instead of throwing', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, {
readManifest: () => { throw new Error('EACCES: permission denied'); },
}));
assert.strictEqual(result.length, 1);
for (const record of result[0].scopes) {
assert.strictEqual(record.installed, false);
assert.strictEqual(record.manifestVersion, null);
assert.deepStrictEqual(record.stems, []);
}
assert.deepStrictEqual(result[0].triggers, []);
});
test('a layout failure yields no stems but never reports the scope uninstalled', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
]);
// A synthetic registry that reproduces the exact TypeError
// `resolveRuntimeArtifactLayoutFromRegistry` throws for a skills entry
// with `converter: null` (`dispatchKindEntry`'s `case 'skills'` guard).
// `resolveTriggerSurface` does not call `dispatchKindEntry` at all, so it
// is unaffected by this — which is exactly what isolates "stems failed"
// from "the trigger call failed" in this fixture.
const realClaude = JSON.parse(JSON.stringify(capabilityRegistry.runtimes.claude));
realClaude.runtime.artifactLayout.global[0].converter = null;
const registry = { runtimes: { claude: realClaude } };
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, {
readManifest: mkReadManifest(byConfigHome),
registry,
}));
const global = scopeOf(result, 'global');
assert.strictEqual(global.installed, true, 'a layout failure must not report the scope uninstalled');
assert.strictEqual(global.manifestVersion, 2);
assert.deepStrictEqual(global.stems, [], 'stems degrade to empty on a layout-lookup failure');
});
});
describe('resolveInstalledSurfaces — purity (S18, S19)', () => {
test('a mutated result cannot corrupt a later call (S18)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'global', files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
[homes.local, manifest({ manifestVersion: 2, runtime: 'claude', scope: 'local', files: { 'commands/gsd-plan-phase.md': 'a' } })],
]);
const opts = baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) });
const first = resolveInstalledSurfaces('claude', opts);
const pristine = JSON.parse(JSON.stringify(first));
first[0].scopes[0].installed = false;
first[0].scopes[0].stems.push('HACKED');
first[0].triggers.push({ trigger: 'INJECTED' });
first.push({ runtime: 'INJECTED' });
const second = resolveInstalledSurfaces('claude', opts);
assert.deepStrictEqual(second, pristine, 'a second call must be unaffected by mutation of the first result');
});
test('resolveInstalledSurfaces mutates nothing on disk (S19, acceptance criterion)', (t) => {
const root = createTempDir('gsd-installed-surface-resolver-');
t.after(() => cleanup(root));
const home = path.join(root, 'home');
const cwd = path.join(root, 'project');
const globalDir = path.join(home, '.claude');
const localDir = path.join(cwd, '.claude');
fs.mkdirSync(globalDir, { recursive: true });
fs.mkdirSync(localDir, { recursive: true });
const nowIso = new Date().toISOString();
const globalManifest = {
version: '1.10.0',
timestamp: nowIso,
mode: 'full',
files: { 'skills/gsd-plan-phase/SKILL.md': 'sha-global' },
manifestVersion: 2,
runtime: 'claude',
scope: 'global',
};
const localManifest = {
version: '1.10.0',
timestamp: nowIso,
mode: 'full',
files: { 'commands/gsd-plan-phase.md': 'sha-local' },
manifestVersion: 2,
runtime: 'claude',
scope: 'local',
};
fs.writeFileSync(path.join(globalDir, 'gsd-file-manifest.json'), JSON.stringify(globalManifest, null, 2));
fs.writeFileSync(path.join(localDir, 'gsd-file-manifest.json'), JSON.stringify(localManifest, null, 2));
// A stray, unrelated file — proves the resolver doesn't touch anything it
// doesn't need either.
fs.writeFileSync(path.join(globalDir, 'settings.json'), '{}');
function snapshot(dir) {
const out = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const full = path.join(d, entry.name);
if (entry.isDirectory()) {
out.push([full, { dir: true }]);
walk(full);
} else if (entry.isFile()) {
const st = fs.statSync(full);
out.push([full, { dir: false, size: st.size, mtimeMs: st.mtimeMs }]);
}
}
};
walk(dir);
return out;
}
const before = snapshot(root);
const result = resolveInstalledSurfaces('claude', { home, cwd, env: {}, existsSync: fs.existsSync });
const after = snapshot(root);
assert.strictEqual(scopeOf(result, 'global').installed, true);
assert.strictEqual(scopeOf(result, 'local').installed, true);
assert.deepStrictEqual(after, before, 'the resolver must perform no writes and create no new files/dirs');
assert.deepStrictEqual(after.map((e) => e[0]), before.map((e) => e[0]), 'no new paths must appear');
});
});
// ─── Section 4 — stem derivation bijection + hostile-stem rejection (B1-B16) ─
//
// The private derivation helpers (`deriveStemsForKindEntry`,
// `deriveStemsFromManifest`) are not exported — every row here is driven
// through `resolveInstalledSurfaces` and asserted against the returned
// `InstalledScopeRecord.stems` field, exactly as the module's own public
// contract exposes it.
describe('resolveInstalledSurfaces — stem derivation (B1-B15)', () => {
test('derives a stem from a skills manifest key (B1)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-plan-phase/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, ['plan-phase']);
});
test('derives a stem from a namespaced-by-dir command key (B2)', () => {
// No shipped runtime declares a namespaced-by-dir commands layout today
// (destSubpath's basename === prefix minus its trailing '-') — same gap
// the sibling resolveTriggerSurface suite documents for its own row 12.
// `opts.registry` overrides ONLY the layout lookup (never `resolveScope`,
// per the design's Correction note), so `claude` still resolves via the
// REAL registry while its layout is read from this synthetic one.
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const registry = {
runtimes: {
claude: {
runtime: {
artifactLayout: {
global: [],
local: [
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null },
],
},
},
},
},
};
const byConfigHome = new Map([
[homes.local, manifest({ manifestVersion: 2, files: { 'commands/gsd/plan-phase.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome), registry }));
assert.deepStrictEqual(scopeOf(result, 'local').stems, ['plan-phase']);
});
test('derives a stem from a prefixed command key (B3)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.local, manifest({ manifestVersion: 2, files: { 'commands/gsd-plan-phase.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'local').stems, ['plan-phase']);
});
test('multiple files under one skill dir yield one stem (B4)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({
manifestVersion: 2,
files: {
'skills/gsd-plan-phase/SKILL.md': 'a',
'skills/gsd-plan-phase/reference.md': 'b',
},
})],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, ['plan-phase']);
});
test('normalizes backslash keys unconditionally (B5)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { 'skills\\gsd-plan-phase\\SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, ['plan-phase']);
});
test('a non-markdown command key yields no stem (B6)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.local, manifest({ manifestVersion: 2, files: { 'commands/gsd-plan-phase': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'local').stems, []);
});
test('an empty stem is not emitted (B7)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.local, manifest({ manifestVersion: 2, files: { 'commands/gsd-.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'local').stems, []);
});
test('a traversal segment in a skills key is rejected (B9)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-../SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
});
test("the reviewer's exact hostile payload yields no stem (B10)", () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-../../../x/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
});
test('a stem containing a control character / newline is rejected (B11)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-x\ny/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
});
test('a stem containing an ANSI escape sequence is rejected (B12)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-xy/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
});
test('a stem containing an RTL-override codepoint is rejected (B13)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-x‮y/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
});
test('an uppercase stem is rejected (B14)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd-PlanPhase/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
});
test('a stem starting with a hyphen is rejected (B15)', () => {
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { 'skills/gsd--x/SKILL.md': 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
});
});
describe('resolveInstalledSurfaces — stem derivation is the exact inverse of Phase 2 filename composition (B8, property)', () => {
// Real stem alphabet: lowercase alnum segments joined by single hyphens
// (e.g. 'plan-phase', 'x', 'a1-b2-c3'), bounded so generated manifest keys
// stay realistic in length.
const stemArb = fc.stringMatching(/^[a-z0-9]{1,8}(-[a-z0-9]{1,8}){0,2}$/);
const PREFIX = 'gsd-';
const home = '/fixture/home';
const cwd = '/fixture/project';
const homes = scopeHomes('claude', home, cwd);
// The namespaced-by-dir shape needs a synthetic layout override (no shipped
// runtime declares one today — see B2 above); built once, outside the
// property body, since it never varies across runs.
const namespacedRegistry = {
runtimes: {
claude: {
runtime: {
artifactLayout: {
global: [],
local: [
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: PREFIX, nesting: 'flat', recursive: false, converter: null },
],
},
},
},
},
};
test('property: prefixed commands round-trip', () => {
fc.assert(
fc.property(stemArb, (stem) => {
// isNamespacedByDir/composeCommandFilename are the SAME two exports
// the resolver's own derivation consumes — binding both halves of
// the bijection genuinely, not by re-deriving either rule here.
const namespacedByDir = isNamespacedByDir('commands', 'commands', PREFIX);
assert.strictEqual(namespacedByDir, false, 'fixture assumption: claude local commands is the prefixed shape');
const filename = composeCommandFilename(namespacedByDir, PREFIX, stem);
const byConfigHome = new Map([
[homes.local, manifest({ manifestVersion: 2, files: { [`commands/${filename}`]: 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'local').stems, [stem]);
}),
{ numRuns: 50 },
);
// On failure, fast-check prints the pinned seed and the exact failing
// stem (its shrunk counterexample) as part of the thrown AssertionError
// — sufficient to replay the run deterministically without re-running
// the whole suite.
});
test('property: namespaced-by-dir commands round-trip', () => {
fc.assert(
fc.property(stemArb, (stem) => {
const namespacedByDir = isNamespacedByDir('commands', 'commands/gsd', PREFIX);
assert.strictEqual(namespacedByDir, true, 'fixture assumption: the synthetic layout is the namespaced-by-dir shape');
const filename = composeCommandFilename(namespacedByDir, PREFIX, stem);
const byConfigHome = new Map([
[homes.local, manifest({ manifestVersion: 2, files: { [`commands/gsd/${filename}`]: 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, {
readManifest: mkReadManifest(byConfigHome),
registry: namespacedRegistry,
}));
assert.deepStrictEqual(scopeOf(result, 'local').stems, [stem]);
}),
{ numRuns: 50 },
);
});
test('property: skills round-trip', () => {
fc.assert(
fc.property(stemArb, (stem) => {
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { [`skills/${PREFIX}${stem}/SKILL.md`]: 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, [stem]);
}),
{ numRuns: 50 },
);
});
test('property: any dirSegment suffix that is not a bare kebab-case token yields no stem', () => {
// Complement of `SAFE_STEM` (`^[a-z0-9][a-z0-9-]*$`) — any non-empty
// string that does not match it must never survive into `stems`, no
// matter what a manifest key throws at the derivation (security
// boundary; see FINDING 1). Bounded and seeded like the round-trip
// properties above.
const hostileArb = fc.string({ minLength: 1, maxLength: 12 })
// Excludes '/' and '\\' — either would split the manifest key into
// extra path segments and stop `hostile` from landing whole inside
// `dirSegment`, which is what this property needs to exercise.
.filter((s) => s.length > 0 && !s.includes('/') && !s.includes('\\') && !/^[a-z0-9][a-z0-9-]*$/.test(s));
fc.assert(
fc.property(hostileArb, (hostile) => {
const byConfigHome = new Map([
[homes.global, manifest({ manifestVersion: 2, files: { [`skills/${PREFIX}${hostile}/SKILL.md`]: 'a' } })],
]);
const result = resolveInstalledSurfaces('claude', baseOpts(home, cwd, { readManifest: mkReadManifest(byConfigHome) }));
assert.deepStrictEqual(scopeOf(result, 'global').stems, []);
}),
{ numRuns: 100, seed: 42 },
);
});
});

View File

@@ -0,0 +1,668 @@
'use strict';
/**
* Installer Migration Module — manifest DOCUMENT schema contract, both
* directions (#2872, ADR-2866 Phase 3, suite `unit` + suite `install`).
*
* Seams:
* - gsd-core/bin/lib/installer-migrations.cjs -> readInstallManifest (read path)
* - bin/install.js -> writeManifest(configDir, runtime, { mode, scope }) (write path)
*
* Covers rows R1-R21 (read path, section 2) and W1-W9 (write path, section 1)
* of `.gsd/phase/feat-2872-manifest-scope-runtime/50-test-matrix.md`. The
* resolver (S1-S21) and the stem bijection (B1-B8) belong to a different test
* file entirely and are deliberately NOT covered here.
*
* Both the writer and the reader are asserted in this ONE file, side by side,
* because they share a single on-disk format (`gsd-file-manifest.json`):
* `writeManifest` produces it, `readInstallManifest` consumes it, and there
* is no independent schema authority arbitrating between them other than
* this test file. Splitting the two across files would let a writer/reader
* divergence — the writer emitting a shape the reader silently misreads, or
* vice versa — pass both suites individually while breaking the real
* contract; keeping them together makes that class of defect fail in one
* place instead of two.
*
* The manifest is a JSON config document WRITTEN by the code under test —
* reading it back with JSON.parse and asserting field equality is the
* correct, expected shape here (never `.includes()`/`.match()` on raw text;
* that would trip `local/no-source-grep`, and this isn't a source file
* anyway).
*
* `writeManifest` is driven DIRECTLY (in-process, via `require('../bin/
* install.js')`) rather than through a full spawned install for every row.
* `bin/install.js` guards its CLI entrypoint behind `require.main === module`
* (bin/install.js:13806), so requiring it as a module — the same thing
* tests/install-runtime-artifacts.test.cjs already does — never runs the
* installer's CLI path; it only exposes the exported functions, including
* `writeManifest` itself (see its export block).
*
* W1/W2 pass the exact three-argument shape production always uses
* (`writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope:
* _installScopeId })`, all 5 call sites in bin/install.js) so the suite
* proves the property real callers exercise, not a degenerate one. W5-W7
* deliberately use degenerate/omitted shapes — a third-party caller's
* defense-in-depth case per 40-design.md row A4.
*/
const { describe, test, beforeEach, afterEach } = 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 { createTempDir, cleanup } = require('./helpers.cjs');
const { MANIFEST_SCHEMA_VERSION, readInstallManifest } = require('../gsd-core/bin/lib/installer-migrations.cjs');
const { writeManifest } = require('../bin/install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
function writeRawManifest(dir, content) {
fs.writeFileSync(path.join(dir, MANIFEST_NAME), content, 'utf8');
}
function writeJsonManifest(dir, value) {
writeRawManifest(dir, JSON.stringify(value));
}
function readManifest(dir) {
return JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_NAME), 'utf8'));
}
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
describe('readInstallManifest — manifest schema (#2872 R1-R21)', () => {
let dir;
beforeEach(() => {
dir = createTempDir('gsd-manifest-schema-');
});
afterEach(() => {
cleanup(dir);
});
// R1 — no manifest file at all. manifestVersion must be null, distinct from
// the `1` a v1 manifest reports (locked together with R2/R6 elsewhere), and
// scope/runtime null too.
test('R1: absent manifest reports manifestVersion null, distinct from 1', () => {
const result = readInstallManifest(dir);
assert.strictEqual(result.manifestVersion, null);
assert.notStrictEqual(result.manifestVersion, 1);
assert.strictEqual(result.runtime, null);
assert.strictEqual(result.scope, null);
assert.deepStrictEqual(result.files, {});
assert.strictEqual(result.version, null);
assert.strictEqual(result.timestamp, null);
assert.strictEqual(result.mode, null);
});
// R2 — the must-have acceptance criterion (AC2): a REAL v1 manifest, with
// exactly the four pre-#2872 fields and nothing else, reads without error
// and without requiring a reinstall.
test('R2 (must-have AC2): reads a v1 manifest without error and without reinstall', () => {
const v1 = {
version: '1.49.0',
timestamp: '2026-05-10T00:00:00.000Z',
mode: 'full',
files: { 'gsd-core/hooks/dist/gsd-check-update.js': 'deadbeef' },
};
writeJsonManifest(dir, v1);
let result;
assert.doesNotThrow(() => {
result = readInstallManifest(dir);
});
assert.strictEqual(result.manifestVersion, 1);
assert.strictEqual(result.runtime, null);
assert.strictEqual(result.scope, null);
// Byte-identical to what today's (pre-#2872) reader produced for the same input.
assert.strictEqual(result.version, v1.version);
assert.strictEqual(result.timestamp, v1.timestamp);
assert.strictEqual(result.mode, v1.mode);
assert.deepStrictEqual(result.files, v1.files);
});
// R3/R5 — a v2 manifest surfaces all three new fields.
test('R3: reads scope and runtime from a v2 manifest', () => {
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime: 'claude',
scope: 'local',
files: { 'agents/gsd-planner.md': 'abc123' },
});
const result = readInstallManifest(dir);
assert.strictEqual(result.manifestVersion, 2);
assert.strictEqual(result.runtime, 'claude');
assert.strictEqual(result.scope, 'local');
});
// R4 — a FUTURE writer's manifestVersion is reported verbatim, never
// clamped and never thrown on.
test('R4: reports a future manifestVersion verbatim', () => {
writeJsonManifest(dir, {
manifestVersion: 3,
version: '1.99.0',
timestamp: '2026-09-01T00:00:00.000Z',
mode: 'full',
runtime: 'codex',
scope: 'global',
files: {},
});
let result;
assert.doesNotThrow(() => {
result = readInstallManifest(dir);
});
assert.strictEqual(result.manifestVersion, 3);
});
// R6/R7/R8/R9 — the manifestVersion normalization boundary set, in one
// table-driven test so the limit-1/limit/limit+1 + malformed classes sit
// together.
const manifestVersionCases = [
{ label: 'R6: explicit 1 matches an implicit v1', raw: 1, expected: 1 },
{ label: 'R7: 0 is out-of-range, rejected to 1', raw: 0, expected: 1 },
{ label: 'R7: -1 is out-of-range, rejected to 1', raw: -1, expected: 1 },
{ label: 'R8: 2.5 is non-integer, rejected to 1', raw: 2.5, expected: 1 },
{ label: 'R8: NaN is non-integer, rejected to 1', raw: NaN, expected: 1 },
{ label: 'R8: Infinity is non-integer, rejected to 1', raw: Infinity, expected: 1 },
{ label: 'R9: stringified "2" is not a version claim, rejected to 1', raw: '2', expected: 1 },
];
for (const { label, raw, expected } of manifestVersionCases) {
test(label, () => {
writeJsonManifest(dir, {
manifestVersion: raw,
version: '1.50.0',
timestamp: '2026-05-11T00:00:00.000Z',
mode: 'full',
files: {},
});
const result = readInstallManifest(dir);
assert.strictEqual(result.manifestVersion, expected);
});
}
// R6 (paired assertion) — an explicit manifestVersion:1 is indistinguishable
// from one with the key entirely absent.
test('R6: an explicit manifestVersion 1 matches an implicit one', (t) => {
const explicitDir = createTempDir('gsd-manifest-schema-explicit-');
const implicitDir = createTempDir('gsd-manifest-schema-implicit-');
t.after(() => cleanup(explicitDir));
t.after(() => cleanup(implicitDir));
const base = { version: '1.50.0', timestamp: '2026-05-11T00:00:00.000Z', mode: 'full', files: {} };
writeJsonManifest(explicitDir, { manifestVersion: 1, ...base });
writeJsonManifest(implicitDir, base);
assert.strictEqual(readInstallManifest(explicitDir).manifestVersion, 1);
assert.strictEqual(
readInstallManifest(explicitDir).manifestVersion,
readInstallManifest(implicitDir).manifestVersion,
);
});
// R10 — the negative-space row: consent/lifecycle vocabulary ('project')
// must never be read as an install scope, and must NEVER collapse to
// 'local'.
test('R10 (negative space): does not read the consent vocabulary as an install scope', () => {
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime: 'claude',
scope: 'project',
files: {},
});
const result = readInstallManifest(dir);
assert.strictEqual(result.scope, null);
assert.notStrictEqual(result.scope, 'local');
});
// R11 — hostile scope values all reject to null.
for (const scope of ['GLOBAL', '', 7, null]) {
test(`R11: rejects an invalid scope (${JSON.stringify(scope)}) to null`, () => {
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime: 'claude',
scope,
files: {},
});
assert.strictEqual(readInstallManifest(dir).scope, null);
});
}
// R12 — malformed runtime values (empty, whitespace-only, non-string)
// reject to null.
for (const runtime of ['', ' ', 42]) {
test(`R12: rejects an empty or non-string runtime (${JSON.stringify(runtime)}) to null`, () => {
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime,
scope: 'global',
files: {},
});
assert.strictEqual(readInstallManifest(dir).runtime, null);
});
}
// R13 — an unregistered runtime string is a fact about the file, reported
// verbatim; the reader does not validate against the runtime registry.
test('R13: reports an unregistered runtime string verbatim', () => {
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime: 'not-a-registered-runtime',
scope: 'global',
files: {},
});
assert.strictEqual(readInstallManifest(dir).runtime, 'not-a-registered-runtime');
});
// R14 — unparseable JSON reads as absent, no throw (Known limit, B9).
test('R14: an unparseable manifest reads as absent', () => {
writeRawManifest(dir, '{not valid json');
let result;
assert.doesNotThrow(() => {
result = readInstallManifest(dir);
});
assert.strictEqual(result.manifestVersion, null);
assert.strictEqual(result.runtime, null);
assert.strictEqual(result.scope, null);
assert.deepStrictEqual(result.files, {});
});
// R15 — valid JSON that is NOT an object (0, "str", true, null) all read
// as absent, no throw.
for (const value of [0, 'str', true, null]) {
test(`R15 (negative space): valid non-object JSON (${JSON.stringify(value)}) reads as absent`, () => {
writeJsonManifest(dir, value);
let result;
assert.doesNotThrow(() => {
result = readInstallManifest(dir);
});
assert.strictEqual(result.manifestVersion, null);
assert.strictEqual(result.runtime, null);
assert.strictEqual(result.scope, null);
assert.deepStrictEqual(result.files, {});
});
}
// R16 — valid JSON `[]` documents the current branch: typeof [] === 'object'
// passes the guard, but `files` is absent on an array so it collapses to {}.
test('R16 (negative space): an array manifest yields an empty file map', () => {
writeJsonManifest(dir, []);
let result;
assert.doesNotThrow(() => {
result = readInstallManifest(dir);
});
assert.deepStrictEqual(result.files, {});
});
// R17 — a zero-byte manifest file reads as absent, no throw.
test('R17 (negative space): an empty manifest file reads as absent', () => {
writeRawManifest(dir, '');
let result;
assert.doesNotThrow(() => {
result = readInstallManifest(dir);
});
assert.strictEqual(result.manifestVersion, null);
assert.strictEqual(result.runtime, null);
assert.strictEqual(result.scope, null);
assert.deepStrictEqual(result.files, {});
});
// R18 — a manifest with zero files still reports its version: "present but
// empty" is a distinct state from "absent".
test('R18: a manifest with no files still reports its version', () => {
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime: 'claude',
scope: 'global',
files: {},
});
const result = readInstallManifest(dir);
assert.strictEqual(result.manifestVersion, 2);
assert.deepStrictEqual(result.files, {});
});
// R19 — CRLF line endings inside the JSON text must not change the parse.
test('R19: CRLF line endings do not change the parse', (t) => {
const lfDir = createTempDir('gsd-manifest-schema-lf-');
const crlfDir = createTempDir('gsd-manifest-schema-crlf-');
t.after(() => cleanup(lfDir));
t.after(() => cleanup(crlfDir));
const lfJson = [
'{',
' "manifestVersion": 2,',
' "version": "1.60.0",',
' "timestamp": "2026-08-01T00:00:00.000Z",',
' "mode": "full",',
' "runtime": "claude",',
' "scope": "local",',
' "files": { "agents/gsd-planner.md": "abc123" }',
'}',
'',
].join('\n');
writeRawManifest(lfDir, lfJson);
writeRawManifest(crlfDir, lfJson.replace(/\n/g, '\r\n'));
assert.deepStrictEqual(readInstallManifest(crlfDir), readInstallManifest(lfDir));
});
// R20 — a `files` key literally named `__proto__` must never pollute
// Object.prototype.
test('R20 (hostile): a __proto__ manifest key does not pollute the prototype', () => {
// Built as raw JSON TEXT (never a JS object literal) so the on-disk bytes
// contain a literal `"__proto__"` key. An object-literal spelling
// (`{ '__proto__': ... }`) is special-cased by the ObjectLiteral grammar
// and would set the JS object's prototype at construction time instead
// of producing an own enumerable key — which would not reproduce what
// `JSON.parse` actually hands the reader when a hostile manifest is read
// from disk (JSON.parse's InternalizeJSONProperty creates a plain own
// property named "__proto__", not a prototype rewire).
const raw = [
'{',
' "manifestVersion": 2,',
' "version": "1.60.0",',
' "timestamp": "2026-08-01T00:00:00.000Z",',
' "mode": "full",',
' "runtime": "claude",',
' "scope": "global",',
' "files": { "__proto__": { "polluted": "yes" }, "constructor": { "polluted": "also-yes" } }',
'}',
].join('\n');
writeRawManifest(dir, raw);
assert.doesNotThrow(() => readInstallManifest(dir));
assert.strictEqual(({}).polluted, undefined);
assert.strictEqual(Object.getOwnPropertyNames(Object.prototype).includes('polluted'), false);
});
// R21 — independence: the four v1 callers read only `files`. A manifest
// carrying just the four v1 fields must yield the exact same `files` map
// the pre-#2872 reader produced for that input.
test('R21 (independence): existing callers see an unchanged files map', () => {
const files = {
'gsd-core/hooks/dist/gsd-check-update.js': 'aaa111',
'agents/gsd-planner.md': 'bbb222',
'skills/gsd-plan-phase/SKILL.md': 'ccc333',
};
writeJsonManifest(dir, {
version: '1.49.0',
timestamp: '2026-05-10T00:00:00.000Z',
mode: 'full',
files,
});
const result = readInstallManifest(dir);
assert.deepStrictEqual(result.files, files);
});
// R22 — boundary (limit): a runtime string of exactly 64 chars is reported
// unchanged, no truncation marker.
test('R22: reports a 64-char runtime unchanged', () => {
const runtime = 'a'.repeat(64);
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime,
scope: 'global',
files: {},
});
assert.strictEqual(readInstallManifest(dir).runtime, runtime);
});
// R23 — boundary (limit+1): one char past the cap is truncated to 64 chars
// plus the ellipsis marker (same convention as `truncatePostureValue`,
// agent-install-check.cts:75-77).
test('R23: truncates a 65-char runtime to 64 chars plus an ellipsis', () => {
const runtime = 'a'.repeat(65);
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime,
scope: 'global',
files: {},
});
const result = readInstallManifest(dir);
assert.strictEqual(result.runtime, `${'a'.repeat(64)}…`);
assert.strictEqual(result.runtime.length, 65);
});
// R24 — boundary (limit-1): one char under the cap is reported unchanged.
test('R24: reports a 63-char runtime unchanged', () => {
const runtime = 'a'.repeat(63);
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime,
scope: 'global',
files: {},
});
assert.strictEqual(readInstallManifest(dir).runtime, runtime);
});
// R25 — hostile: the manifest is attacker-influenceable (a project-local
// one lives inside a repository a user may merely have cloned), so a very
// long `runtime` string must never reach a consumer unbounded, and must
// never throw.
test('R25: bounds a very long runtime without throwing', () => {
const runtime = 'x'.repeat(100_000);
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime,
scope: 'global',
files: {},
});
let result;
assert.doesNotThrow(() => {
result = readInstallManifest(dir);
});
assert.strictEqual(result.runtime.length, 65);
});
// R26 — negative space, documented deliberately: the CHARSET of `runtime`
// is NOT gated, only its length. `declaredRuntimeMatchesProbe` needs to see
// the actual value to detect a mismatch, so a charset gate here would
// destroy that signal. A future reader must not "fix" this by adding one —
// Phase 4 (#2873) owns sanitizing `declaredRuntime` before it is rendered.
test('R26: still reports a runtime containing a control character', () => {
const runtime = 'claude';
writeJsonManifest(dir, {
manifestVersion: 2,
version: '1.60.0',
timestamp: '2026-08-01T00:00:00.000Z',
mode: 'full',
runtime,
scope: 'global',
files: {},
});
assert.strictEqual(readInstallManifest(dir).runtime, runtime);
});
});
describe('writeManifest — scope + runtime recording (#2872 W1-W9)', () => {
let dir;
beforeEach(() => {
dir = createTempDir('gsd-write-manifest-');
});
afterEach(() => {
cleanup(dir);
});
// W1 — global install, claude: manifestVersion 2, runtime + scope recorded.
test('records manifestVersion, runtime and scope for a global install', () => {
writeManifest(dir, 'claude', { mode: 'full', scope: 'global' });
const manifest = readManifest(dir);
assert.strictEqual(manifest.manifestVersion, 2);
assert.strictEqual(manifest.runtime, 'claude');
assert.strictEqual(manifest.scope, 'global');
});
// W2 — local install, claude: same runtime, scope local.
test('records scope local for a --local install', () => {
writeManifest(dir, 'claude', { mode: 'full', scope: 'local' });
const manifest = readManifest(dir);
assert.strictEqual(manifest.manifestVersion, 2);
assert.strictEqual(manifest.runtime, 'claude');
assert.strictEqual(manifest.scope, 'local');
});
// W3 — a non-claude runtime is recorded as itself, not a hardcoded default.
test('records the installing runtime, not a default', () => {
writeManifest(dir, 'codex', { mode: 'full', scope: 'global' });
const manifest = readManifest(dir);
assert.strictEqual(manifest.runtime, 'codex');
});
// W4 — an install over a pre-existing v1 manifest rebuilds it whole, as v2,
// with `files` still reflecting what's actually on disk (not wiped).
test('an install over a v1 manifest rewrites it as v2', () => {
const gsdCoreDir = path.join(dir, 'gsd-core');
fs.mkdirSync(gsdCoreDir, { recursive: true });
const trackedContent = 'console.log("hook");\n';
fs.writeFileSync(path.join(gsdCoreDir, 'gsd-check-update.js'), trackedContent);
fs.writeFileSync(
path.join(dir, MANIFEST_NAME),
JSON.stringify({
version: '1.49.0',
timestamp: '2026-05-10T00:00:00.000Z',
mode: 'full',
files: { 'some/stale/path.md': 'stalehash' },
}),
);
writeManifest(dir, 'claude', { mode: 'full', scope: 'global' });
const manifest = readManifest(dir);
assert.strictEqual(manifest.manifestVersion, 2);
assert.strictEqual(manifest.runtime, 'claude');
assert.strictEqual(manifest.scope, 'global');
// `files` reflects what's actually on disk now, not the stale v1 entry.
assert.strictEqual(
manifest.files['gsd-core/gsd-check-update.js'],
sha256(trackedContent),
);
assert.strictEqual(manifest.files['some/stale/path.md'], undefined);
});
// W5 — options omitted entirely: scope defaults to 'global', never
// null/absent (A3: the SAME fallback the skills-root line already
// applies, read from one place).
test('defaults scope to global when options are omitted', () => {
writeManifest(dir, 'claude');
const manifest = readManifest(dir);
assert.strictEqual(manifest.scope, 'global');
assert.notStrictEqual(manifest.scope, null);
});
// W6 — a junk options.scope coerces to 'global' without throwing (A4:
// defense-in-depth for a third-party caller; junk cannot reach here from
// bin/install.js itself).
for (const junkScope of ['GLOBAL', '', 0, null]) {
test(`coerces an unrecognized scope (${JSON.stringify(junkScope)}) to global without throwing`, () => {
let manifest;
assert.doesNotThrow(() => {
writeManifest(dir, 'claude', { mode: 'full', scope: junkScope });
manifest = readManifest(dir);
});
assert.strictEqual(manifest.scope, 'global');
});
}
// W7 — runtime omitted: records DEFAULT_RUNTIME, never null.
test('records the default runtime when none is passed', () => {
writeManifest(dir);
const manifest = readManifest(dir);
assert.strictEqual(typeof manifest.runtime, 'string');
assert.ok(manifest.runtime.length > 0);
assert.notStrictEqual(manifest.runtime, null);
});
// W8 — independence: the four pre-existing manifest fields keep their
// names and types.
test('does not change any pre-existing manifest field', () => {
writeManifest(dir, 'claude', { mode: 'full', scope: 'global' });
const manifest = readManifest(dir);
assert.strictEqual(typeof manifest.version, 'string');
assert.strictEqual(typeof manifest.timestamp, 'string');
assert.strictEqual(typeof manifest.mode, 'string');
assert.strictEqual(typeof manifest.files, 'object');
assert.ok(manifest.files !== null);
assert.ok(!Array.isArray(manifest.files));
});
// W9 — idempotent apart from timestamp: writing twice over the same dir
// produces two manifests that differ ONLY in their timestamp. Never
// asserts on elapsed wall-clock time — only on structural equality once
// `timestamp` is removed from both sides.
test('is idempotent apart from timestamp', () => {
writeManifest(dir, 'claude', { mode: 'full', scope: 'global' });
const first = readManifest(dir);
writeManifest(dir, 'claude', { mode: 'full', scope: 'global' });
const second = readManifest(dir);
assert.strictEqual(typeof first.timestamp, 'string');
assert.strictEqual(typeof second.timestamp, 'string');
delete first.timestamp;
delete second.timestamp;
assert.deepStrictEqual(first, second);
});
// W10 (parity) — the divergence guard this repo requires when two surfaces
// share a constant: `writeManifest` (bin/install.js) and
// `readInstallManifest` (gsd-core/bin/lib/installer-migrations.cjs) must
// agree on the manifest schema version via the SAME owned constant, never
// two independent literals that can drift apart (the "generative fix
// divergence" class). This fails if either side is changed alone.
test('W10 (parity): the version writeManifest emits is the same constant readInstallManifest owns', () => {
writeManifest(dir, 'claude', { mode: 'full', scope: 'global' });
const manifest = readManifest(dir);
assert.strictEqual(manifest.manifestVersion, MANIFEST_SCHEMA_VERSION);
assert.strictEqual(readInstallManifest(dir).manifestVersion, MANIFEST_SCHEMA_VERSION);
});
});