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.
307 lines
13 KiB
JavaScript
307 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Failing-first suite for the Install Scope Module (#2870, ADR-2866).
|
|
*
|
|
* Asserts `resolveScope`'s contract from
|
|
* .msd/phase/feat-2870-install-scope-module/40-design.md against the test
|
|
* matrix at .msd/phase/feat-2870-install-scope-module/50-test-matrix.md
|
|
* (rows 1-19; row 20 lands with the bin/install.js call-site migration and
|
|
* is deliberately out of scope here).
|
|
*
|
|
* Every case injects `env` / `home` / `existsSync` — mirroring
|
|
* `runtime-homes.cts`'s `ResolveConfigHomeOpts` shape — instead of touching
|
|
* the real filesystem or the real home directory: no `mkdtempSync`, no
|
|
* `os.homedir()`. The module under test does not exist yet, so requiring it
|
|
* below throws `MODULE_NOT_FOUND`. That is the point: this suite is RED
|
|
* until src/install-scope.cts lands and builds to
|
|
* msd-core/bin/lib/install-scope.cjs.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const { toPosixPath } = require('./helpers.cjs');
|
|
|
|
const { resolveScope, isGlobalScope, SCOPE_ORDER, scopeRank } = require('../msd-core/bin/lib/install-scope.cjs');
|
|
const registry = require('../msd-core/bin/lib/capability-registry.cjs');
|
|
const { createRuntimeArtifactInstallPlan } = require('../msd-core/bin/lib/runtime-artifact-install-plan.cjs');
|
|
|
|
const FAKE_HOME = '/fake/home';
|
|
const NO_EXISTS = () => false;
|
|
|
|
function fixture(overrides) {
|
|
return { env: {}, home: FAKE_HOME, existsSync: NO_EXISTS, ...overrides };
|
|
}
|
|
|
|
// #2103: `vscode` enters `registry.runtimes` (role:runtime, kept for
|
|
// validator / host-integration coverage) but declares
|
|
// `configHome.kind === 'none'` — no file-projected config directory exists
|
|
// to resolve at all — and it is never CLI-installed (no --vscode flag,
|
|
// absent from bin/install.js's `allRuntimes`). This is the same carve-out
|
|
// tests/runtime-flags.test.cjs's `NON_INSTALLABLE_RUNTIMES` documents:
|
|
// install-scope's global-configHome resolution has nothing to resolve for a
|
|
// runtime with no config directory, so it is excluded from the "every
|
|
// runtime" sweep below rather than assumed to resolve like every other
|
|
// registered runtime. The excluded case itself is asserted directly by
|
|
// 'rejects a runtime with no config home' below (design row 13) — it is
|
|
// covered, not dropped.
|
|
const NON_INSTALLABLE_RUNTIMES = new Set(['vscode']);
|
|
const INSTALL_SCOPE_RUNTIME_IDS = Object.keys(registry.runtimes)
|
|
.filter((id) => !NON_INSTALLABLE_RUNTIMES.has(id));
|
|
|
|
describe('resolveScope', () => {
|
|
// Row 1
|
|
test('resolves claude global settings file', () => {
|
|
const result = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
|
|
assert.strictEqual(result.settingsFile, 'settings.json');
|
|
});
|
|
|
|
// Row 2
|
|
test('resolves claude local settings file', () => {
|
|
const result = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
|
|
assert.strictEqual(result.settingsFile, 'settings.local.json');
|
|
});
|
|
|
|
// Row 3
|
|
test('returns null settingsFile for runtimes that declare none', () => {
|
|
const result = resolveScope(fixture({ id: 'global', runtime: 'codex' }));
|
|
assert.strictEqual(result.settingsFile, null);
|
|
});
|
|
|
|
// Row 4
|
|
test('resolves for every registered runtime at both scopes', () => {
|
|
assert.ok(INSTALL_SCOPE_RUNTIME_IDS.length > 0, 'registry must contain at least one installable runtime');
|
|
for (const runtime of INSTALL_SCOPE_RUNTIME_IDS) {
|
|
for (const id of ['global', 'local']) {
|
|
const result = resolveScope(fixture({ id, runtime, cwd: '/fake/project' }));
|
|
assert.strictEqual(typeof result.configHome, 'string', `${runtime}/${id}: configHome must be a string`);
|
|
assert.ok(result.configHome.length > 0, `${runtime}/${id}: configHome must be non-empty`);
|
|
assert.ok(
|
|
result.settingsFile === null || typeof result.settingsFile === 'string',
|
|
`${runtime}/${id}: settingsFile must be string or null`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Row 5
|
|
test('global scope requires no consent record', () => {
|
|
const result = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
|
|
assert.strictEqual(result.consentRequired, false);
|
|
});
|
|
|
|
// Row 6
|
|
test('local scope requires a consent record', () => {
|
|
const result = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
|
|
assert.strictEqual(result.consentRequired, true);
|
|
});
|
|
|
|
// Row 7 — assert the RELATION, never a literal rank number (unread this
|
|
// phase; Phase 2 may re-base the literal values).
|
|
test('global outranks local in hostPrecedenceRank', () => {
|
|
const globalScope = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
|
|
const localScope = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
|
|
assert.ok(
|
|
globalScope.hostPrecedenceRank > localScope.hostPrecedenceRank,
|
|
`expected global rank (${globalScope.hostPrecedenceRank}) > local rank (${localScope.hostPrecedenceRank})`,
|
|
);
|
|
});
|
|
|
|
// Row 8
|
|
test('explicit config dir overrides descriptor resolution', () => {
|
|
const result = resolveScope(fixture({ id: 'global', runtime: 'claude', explicitDir: '/custom/config/dir' }));
|
|
assert.strictEqual(result.configHome, '/custom/config/dir');
|
|
});
|
|
|
|
// Row 9
|
|
test('rejects the consent-vocabulary spelling', () => {
|
|
assert.throws(
|
|
() => resolveScope(fixture({ id: 'project', runtime: 'claude' })),
|
|
(err) => err instanceof TypeError && /global/i.test(err.message) && /local/i.test(err.message),
|
|
);
|
|
});
|
|
|
|
// Row 10
|
|
test('rejects case variants', () => {
|
|
assert.throws(
|
|
() => resolveScope(fixture({ id: 'Global', runtime: 'claude' })),
|
|
TypeError,
|
|
);
|
|
});
|
|
|
|
// Row 11
|
|
test('rejects empty and missing scope id', () => {
|
|
assert.throws(() => resolveScope(fixture({ id: '', runtime: 'claude' })), TypeError, 'id: empty string');
|
|
assert.throws(() => resolveScope(fixture({ id: undefined, runtime: 'claude' })), TypeError, 'id: undefined');
|
|
const { home, env, existsSync } = fixture({});
|
|
assert.throws(() => resolveScope({ runtime: 'claude', home, env, existsSync }), TypeError, 'id: absent key');
|
|
});
|
|
|
|
// Row 12
|
|
test('rejects unknown runtime with the established error contract', () => {
|
|
assert.throws(
|
|
() => resolveScope(fixture({ id: 'global', runtime: 'no-such-runtime' })),
|
|
(err) => err instanceof TypeError && err.message.includes('no-such-runtime'),
|
|
);
|
|
});
|
|
|
|
// Design row 13: a runtime whose descriptor has `configHome.kind ===
|
|
// 'none'` — vscode is the only one — has no installable config directory
|
|
// to resolve at all. Same contract as row 9 (unknown runtime): TypeError
|
|
// naming the runtime, one catch shape for callers.
|
|
test('rejects a runtime with no config home', () => {
|
|
assert.throws(
|
|
() => resolveScope(fixture({ id: 'global', runtime: 'vscode' })),
|
|
(err) => err instanceof TypeError
|
|
&& err.message === "resolveScope: runtime 'vscode' has no installable config directory (configHome.kind === 'none')",
|
|
);
|
|
assert.throws(
|
|
() => resolveScope(fixture({ id: 'local', runtime: 'vscode' })),
|
|
(err) => err instanceof TypeError
|
|
&& err.message === "resolveScope: runtime 'vscode' has no installable config directory (configHome.kind === 'none')",
|
|
);
|
|
});
|
|
|
|
// Row 13
|
|
test('rejects non-string scope ids without coercion', () => {
|
|
for (const id of [0, null, {}, ['global']]) {
|
|
assert.throws(
|
|
() => resolveScope(fixture({ id, runtime: 'claude' })),
|
|
TypeError,
|
|
`id=${JSON.stringify(id)} must throw TypeError, not coerce`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// Row 14
|
|
test('preserves opencode/kilo config-file precedence', () => {
|
|
const filePath = '/home/x/custom/opencode-config.json';
|
|
const result = resolveScope(fixture({
|
|
id: 'global',
|
|
runtime: 'opencode',
|
|
env: { OPENCODE_CONFIG: filePath },
|
|
}));
|
|
assert.strictEqual(result.configHome, path.dirname(filePath));
|
|
});
|
|
|
|
// Row 15
|
|
test('blank env override does not win', () => {
|
|
const expected = toPosixPath(path.join(FAKE_HOME, '.claude'));
|
|
const empty = resolveScope(fixture({ id: 'global', runtime: 'claude', env: { CLAUDE_CONFIG_DIR: '' } }));
|
|
const whitespace = resolveScope(fixture({ id: 'global', runtime: 'claude', env: { CLAUDE_CONFIG_DIR: ' ' } }));
|
|
assert.strictEqual(empty.configHome, expected);
|
|
assert.strictEqual(whitespace.configHome, expected);
|
|
});
|
|
|
|
// Row 16
|
|
test('is pure and does not mutate its input', () => {
|
|
const input = Object.freeze(fixture({ id: 'global', runtime: 'claude' }));
|
|
const first = resolveScope(input);
|
|
const second = resolveScope(input);
|
|
assert.deepStrictEqual(first, second);
|
|
});
|
|
|
|
// Row 17
|
|
test('normalizes backslash paths on every platform', () => {
|
|
const result = resolveScope(fixture({ id: 'global', runtime: 'claude', home: 'C:\\Users\\x' }));
|
|
assert.ok(!result.configHome.includes('\\'), `configHome must not contain backslashes: ${result.configHome}`);
|
|
assert.strictEqual(result.configHome, 'C:/Users/x/.claude');
|
|
});
|
|
|
|
// Row 18
|
|
test('returned value cannot be corrupted by a caller', () => {
|
|
const input = fixture({ id: 'global', runtime: 'claude' });
|
|
const first = resolveScope(input);
|
|
const originalConfigHome = first.configHome;
|
|
try {
|
|
first.configHome = 'HACKED';
|
|
} catch {
|
|
// A frozen result rejecting the mutation outright is an acceptable
|
|
// defense too — either way, a second resolution must be unaffected.
|
|
}
|
|
const second = resolveScope(input);
|
|
assert.strictEqual(second.configHome, originalConfigHome);
|
|
});
|
|
|
|
// Row 19
|
|
test('install-plan imports the shared InstallScope type', () => {
|
|
const globalScope = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
|
|
const localScope = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
|
|
for (const scope of [globalScope, localScope]) {
|
|
const result = createRuntimeArtifactInstallPlan({
|
|
layout: {
|
|
runtime: 'claude',
|
|
configDir: scope.configHome,
|
|
scope: scope.id,
|
|
kinds: [],
|
|
},
|
|
resolvedProfile: { name: 'core' },
|
|
});
|
|
assert.strictEqual(
|
|
result.ok,
|
|
true,
|
|
`runtime-artifact-install-plan must accept install-scope's '${scope.id}' spelling directly`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// Row 16 follow-up: local scope's configHome must be assertable via an
|
|
// injected cwd, never the real process.cwd().
|
|
test('local scope resolves configHome against an injected cwd', () => {
|
|
const first = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project-a' }));
|
|
const second = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project-b' }));
|
|
assert.notStrictEqual(first.configHome, second.configHome);
|
|
assert.strictEqual(first.configHome, toPosixPath(path.join('/fake/project-a', '.claude')));
|
|
assert.strictEqual(second.configHome, toPosixPath(path.join('/fake/project-b', '.claude')));
|
|
});
|
|
});
|
|
|
|
describe('isGlobalScope', () => {
|
|
// #2870: the shared boolean projection that replaced four independent
|
|
// inline `scope === 'global'` re-derivations.
|
|
test('returns true only for global', () => {
|
|
assert.strictEqual(isGlobalScope('global'), true);
|
|
});
|
|
|
|
test('returns false for local', () => {
|
|
assert.strictEqual(isGlobalScope('local'), false);
|
|
});
|
|
|
|
// Parity assertion: isGlobalScope must throw the SAME TypeError contract
|
|
// resolveScope's invalid-id case (Row 9) throws, since both share
|
|
// install-scope.cts's one validator — never a second, divergent one.
|
|
test('throws TypeError for an invalid id, matching resolveScope\'s contract', () => {
|
|
assert.throws(
|
|
() => isGlobalScope('project'),
|
|
(err) => err instanceof TypeError && /global/i.test(err.message) && /local/i.test(err.message),
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|