Files
msd-core/tests/installer-migrations-manifest-schema.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

689 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Installer Migration Module — manifest DOCUMENT schema contract, both
* directions (#2872, ADR-2866 Phase 3, suite `unit` + suite `install`).
*
* Seams:
* - msd-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 `.msd/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 (`msd-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('../msd-core/bin/lib/installer-migrations.cjs');
const { writeManifest } = require('../bin/install.js');
const MANIFEST_NAME = 'msd-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('msd-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: { 'msd-core/hooks/dist/msd-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/msd-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('msd-manifest-schema-explicit-');
const implicitDir = createTempDir('msd-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('msd-manifest-schema-lf-');
const crlfDir = createTempDir('msd-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/msd-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 = {
'msd-core/hooks/dist/msd-check-update.js': 'aaa111',
'agents/msd-planner.md': 'bbb222',
'skills/msd-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);
});
// R27 (regression, #2873 test-matrix row B9) — valid JSON that parses to a
// non-object top-level value must degrade to "absent", identically to R1,
// on every JS typeof-'object' member: `0`, a string, `true`, `null`
// (JSON.parse('null') is a real value), and — the one the `typeof !==
// 'object'` guard alone misses, since `typeof [] === 'object'` in JS — a
// bare array. Found while implementing #2873's shadow-report test matrix:
// `[]` was misread as manifestVersion 1 (a v1 install), reporting a
// completely absent manifest as "installed". `readInstallManifest` now
// explicitly excludes `Array.isArray` from the object-shape check.
for (const raw of ['0', '"a string"', 'true', 'null', '[]']) {
test(`R27: a valid-JSON, non-object manifest body (${raw}) reads as absent`, () => {
writeRawManifest(dir, raw);
const result = readInstallManifest(dir);
assert.strictEqual(result.manifestVersion, null, `${raw}: manifestVersion must be null, not a v1 guess`);
assert.strictEqual(result.runtime, null);
assert.strictEqual(result.scope, null);
assert.deepStrictEqual(result.files, {});
});
}
});
describe('writeManifest — scope + runtime recording (#2872 W1-W9)', () => {
let dir;
beforeEach(() => {
dir = createTempDir('msd-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 msdCoreDir = path.join(dir, 'msd-core');
fs.mkdirSync(msdCoreDir, { recursive: true });
const trackedContent = 'console.log("hook");\n';
fs.writeFileSync(path.join(msdCoreDir, 'msd-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['msd-core/msd-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` (msd-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);
});
});