Files
msd-core/tests/installer-migration-config-root-marker.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

345 lines
13 KiB
JavaScript

// docs-guard-exempt: docs/installer-migrations.md is cited only in comments as design rationale; never read.
'use strict';
/**
* Installer migration coverage for
* 2026-07-28-retire-config-root-commonjs-marker (#2544).
*
* #2544 moved MSD's `{"type":"commonjs"}` marker out of the runtime config root
* and into the directories MSD actually fills. Without this migration an
* UPGRADED install keeps both markers, so the config root stays pinned to
* CommonJS and the fix's own claim — that MSD no longer writes the shared
* config root — is false for every install made before it.
*
* The migration is unusual in one respect, and that is what most of this file
* pins: the config-root marker was never recorded in `msd-file-manifest.json`,
* so `classifyArtifact` answers `unknown` for it and the planner's own guard
* downgrades a `remove-managed` on an `unknown` classification to
* `preserve-user`. The migration therefore supplies the "purpose-built detector
* for an old MSD-owned shape" that docs/installer-migrations.md#remove-managed
* sanctions, and DECLARES the resulting classification on the action. If that
* declaration ever stops being honoured the migration silently does nothing, so
* the end-to-end planner test below is a negative control, not a formality.
*
* Authoring-workflow coverage (docs/installer-migrations.md#authoring-workflow):
* dry-run plan output ....... "plan() emits remove-managed"
* apply behaviour ........... "applies through the real planner + executor"
* locally modified file ..... "a user-authored package.json is never touched"
* user-owned files nearby ... "leaves sibling files alone"
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
const migration = require('../msd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs');
const {
classifyArtifact: realClassifyArtifact,
readInstallManifest,
planInstallerMigrations,
applyInstallerMigrationPlan,
} = require('../msd-core/bin/lib/installer-migrations.cjs');
// The shared teardown helper, not a local rmSync: it chdir's out of the target
// first (Windows cannot remove a directory that is the CWD) and retries
// 20 x 250ms to absorb the deferred-scan handle Windows Defender holds on
// newly-written files. This repo runs a windows-latest lane, so both matter.
const { cleanup } = require('./helpers.cjs');
const MARKER = '{"type":"commonjs"}';
const ROOT_REL = 'package.json';
/** The shape #2544 was filed about — an OpenCode config-root manifest. */
const USER_PACKAGE_JSON = JSON.stringify(
{
name: 'my-opencode-config',
type: 'module',
dependencies: { shescape: '^2.1.0' },
},
null,
2,
) + '\n';
function createTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'msd-migration-007-test-'));
}
function writeFile(root, relPath, content) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
function writeManifest(root, files) {
fs.writeFileSync(
path.join(root, 'msd-file-manifest.json'),
JSON.stringify(
{ version: '1.8.0', timestamp: '2026-07-28T00:00:00.000Z', mode: 'full', files },
null,
2,
),
'utf8',
);
}
function makePlanCtx(configDir) {
const manifest = readInstallManifest(configDir);
return {
configDir,
classifyArtifact: (relPath) => realClassifyArtifact(configDir, relPath, manifest),
};
}
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
// ---------------------------------------------------------------------------
// 1. Metadata
// ---------------------------------------------------------------------------
describe('migration 007 metadata', () => {
test('exports a single migration object with the required authoring fields', () => {
assert.equal(typeof migration, 'object');
assert.equal(typeof migration.id, 'string');
assert.ok(migration.id.length > 0, 'id must be non-empty');
assert.equal(typeof migration.title, 'string');
assert.equal(typeof migration.description, 'string');
assert.equal(typeof migration.introducedIn, 'string');
assert.ok(Array.isArray(migration.scopes), 'scopes must be an array');
assert.ok(migration.scopes.includes('global'));
assert.ok(migration.scopes.includes('local'));
assert.strictEqual(migration.destructive, true);
assert.equal(typeof migration.plan, 'function');
});
test('applies to every runtime — the marker was written for all of them', () => {
// "All runtimes" is expressed by OMITTING `runtimes`, never by `[]`. The two
// halves of the framework disagree about the empty array and only one of
// them runs on the record: `validateStringArray` requires the field to be a
// NON-EMPTY string array when present and throws otherwise, while the
// runtime filter (`Array.isArray(runtimes) && runtimes.length > 0`) would
// have treated `[]` as "all". A `runtimes: []` record therefore throws at
// plan time and the migration never runs at all — which is precisely how
// this migration was first written.
assert.equal(migration.runtimes, undefined, 'runtimes must be omitted, not []');
});
test('id carries the expected date prefix and names the retired artifact', () => {
assert.ok(migration.id.startsWith('2026-07-28-'), `unexpected id: ${migration.id}`);
assert.match(migration.id, /config-root-commonjs-marker/);
});
});
// ---------------------------------------------------------------------------
// 2. plan() behaviour
// ---------------------------------------------------------------------------
describe('migration 007 plan()', () => {
test('emits no actions when the config root has no package.json (fresh install)', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeManifest(dir, {});
assert.deepEqual(migration.plan(makePlanCtx(dir)), [], 'no file -> no actions');
});
test('emits remove-managed for the exact legacy marker, with declared ownership', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, ROOT_REL, `${MARKER}\n`);
writeManifest(dir, {});
const actions = migration.plan(makePlanCtx(dir));
assert.equal(actions.length, 1);
const [action] = actions;
assert.equal(action.type, 'remove-managed');
assert.equal(action.relPath, ROOT_REL);
assert.ok(action.ownershipEvidence && action.ownershipEvidence.length > 0);
// The declared classification IS the ownership proof — see the planner test.
assert.equal(action.classification, 'managed-pristine');
assert.equal(action.originalHash, action.currentHash);
assert.equal(action.currentHash, sha256(`${MARKER}\n`));
});
test('tolerates trailing-whitespace variants of the marker', (t) => {
for (const content of [MARKER, `${MARKER}\n`, `${MARKER}\n\n`, ` ${MARKER} \n`]) {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, ROOT_REL, content);
writeManifest(dir, {});
assert.equal(
migration.plan(makePlanCtx(dir)).length,
1,
`expected a match for ${JSON.stringify(content)}`,
);
}
});
test('a user-authored package.json is never touched', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, ROOT_REL, USER_PACKAGE_JSON);
writeManifest(dir, {});
assert.deepEqual(
migration.plan(makePlanCtx(dir)),
[],
'a package.json that is not exactly the marker must produce no action',
);
});
test('a marker with any extra key is foreign — no backup-and-remove branch', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
// Semantically "the marker plus something the user added". The exact-content
// predicate rejects it, and there is deliberately no modified-file branch:
// this is somebody else's file, not a patched MSD artifact.
writeFile(dir, ROOT_REL, `${JSON.stringify({ type: 'commonjs', name: 'mine' })}\n`);
writeManifest(dir, {});
assert.deepEqual(migration.plan(makePlanCtx(dir)), []);
});
test('a symlinked package.json is never followed or planned', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
const outside = path.join(dir, 'outside.json');
fs.writeFileSync(outside, `${MARKER}\n`);
fs.symlinkSync(outside, path.join(dir, ROOT_REL));
writeManifest(dir, {});
assert.deepEqual(
migration.plan(makePlanCtx(dir)),
[],
'a symlink is not something MSD wrote — never ours to remove',
);
assert.ok(fs.existsSync(outside), 'the link target must survive');
});
test('a directory at the marker path is never planned', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
fs.mkdirSync(path.join(dir, ROOT_REL));
writeManifest(dir, {});
assert.deepEqual(migration.plan(makePlanCtx(dir)), []);
});
test('leaves sibling files in the config root alone', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, ROOT_REL, `${MARKER}\n`);
writeFile(dir, 'opencode.json', '{"theme":"mine"}\n');
writeManifest(dir, {});
const actions = migration.plan(makePlanCtx(dir));
assert.equal(actions.length, 1);
assert.equal(actions[0].relPath, ROOT_REL, 'only the marker is ever planned');
});
});
// ---------------------------------------------------------------------------
// 3. End-to-end through the REAL planner + executor
// ---------------------------------------------------------------------------
describe('migration 007 through the real planner', () => {
test('the declared classification survives the planner (negative control)', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, ROOT_REL, `${MARKER}\n`);
writeManifest(dir, {});
// The config-root marker is NOT in the manifest, so the planner's own
// classify() answers 'unknown' for it...
const manifest = readInstallManifest(dir);
assert.equal(
realClassifyArtifact(dir, ROOT_REL, manifest).classification,
'unknown',
'precondition: the marker was never manifest-recorded',
);
// ...and `remove-managed` on an 'unknown' classification is downgraded to
// `preserve-user`. This assertion is the whole reason the migration declares
// its own classification: without that declaration the plan below would
// contain a preserve-user action and the migration would be inert.
const plan = planInstallerMigrations({
configDir: dir,
runtime: 'opencode',
scope: 'global',
migrations: [migration],
});
const actions = plan.actions.filter((a) => a.relPath === ROOT_REL);
assert.equal(actions.length, 1, 'the marker must survive planning as one action');
assert.equal(
actions[0].type,
'remove-managed',
'declared ownership must NOT be downgraded to preserve-user',
);
});
test('applying the plan removes the marker and journals a rollback', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, ROOT_REL, `${MARKER}\n`);
writeFile(dir, 'opencode.json', '{"theme":"mine"}\n');
writeManifest(dir, {});
const plan = planInstallerMigrations({
configDir: dir,
runtime: 'opencode',
scope: 'global',
migrations: [migration],
});
applyInstallerMigrationPlan({ configDir: dir, plan, runtime: 'opencode', scope: 'global' });
assert.ok(
!fs.existsSync(path.join(dir, ROOT_REL)),
'the stale config-root marker must be gone after apply',
);
assert.ok(
fs.existsSync(path.join(dir, 'opencode.json')),
'sibling config files must survive',
);
});
test('is idempotent — a second run plans nothing', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, ROOT_REL, `${MARKER}\n`);
writeManifest(dir, {});
const first = planInstallerMigrations({
configDir: dir, runtime: 'opencode', scope: 'global', migrations: [migration],
});
applyInstallerMigrationPlan({ configDir: dir, plan: first, runtime: 'opencode', scope: 'global' });
// Re-plan against the post-apply tree. The file is gone, so plan() short-
// circuits at the lstat and there is nothing left to do.
assert.deepEqual(migration.plan(makePlanCtx(dir)), []);
});
test('a user-authored package.json survives the full plan+apply cycle', (t) => {
const dir = createTempDir();
t.after(() => cleanup(dir));
writeFile(dir, ROOT_REL, USER_PACKAGE_JSON);
writeManifest(dir, {});
const before = sha256(fs.readFileSync(path.join(dir, ROOT_REL)));
const plan = planInstallerMigrations({
configDir: dir, runtime: 'opencode', scope: 'global', migrations: [migration],
});
applyInstallerMigrationPlan({ configDir: dir, plan, runtime: 'opencode', scope: 'global' });
assert.ok(fs.existsSync(path.join(dir, ROOT_REL)), 'the user file must survive');
assert.equal(
sha256(fs.readFileSync(path.join(dir, ROOT_REL))),
before,
'the user file must be byte-identical after the migration runs',
);
});
});