* 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>
669 lines
25 KiB
JavaScript
669 lines
25 KiB
JavaScript
'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[31m';
|
||
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);
|
||
});
|
||
});
|