Files
msd-core/tests/install-scope.test.cjs
Tom Boucher c2f24265f2 feat(#2870): resolve install scope as a value (#3278)
* test(#2870): failing-first suite for the Install Scope Module

19 tests over the 50-test-matrix rows 1-19. RED by construction: the
module under test does not exist yet, so the suite fails at require with
MODULE_NOT_FOUND until src/install-scope.cts lands.

Every row asserts a returned value with injected env/home/existsSync --
no filesystem, per the issue's acceptance criterion that tests assert the
resolved value directly.

Row 7 asserts the RELATION rank(global) > rank(local) rather than a
literal, so Phase 2 (#2871) can re-base the numbers without a fixture
edit. Row 4 iterates the real runtime registry rather than a hardcoded
list, excluding vscode, which declares configHome.kind none and is never
CLI-installed.

* feat(#2870): add the Install Scope Module

Scope becomes one resolved value instead of a bare string re-derived at
every layer. resolveScope({id, runtime, ...}) returns
{id, configHome, settingsFile, consentRequired, hostPrecedenceRank}.

It COMPOSES resolveConfigHomeFromDescriptor rather than extending it.
That function has 60 dependents across 13 files and 2 process flows -- a
CRITICAL blast radius -- so adding a scope parameter to it, which the
issue's framing invites, would ripple through all of them. Composing
costs nothing and leaves every existing caller byte-identical.

The module owns the InstallScope type name, which previously lived
privately in runtime-artifact-install-plan.cts; that module now imports
it. A fifth spelling of the same concept would have defeated the phase.

settingsFile is null for the 18 runtimes that declare no
settingsFileByScope -- absence is a value, not an error, and inventing a
Claude-shaped default would leak that host's shape onto every other one.

hostPrecedenceRank ships unread: Phase 2 (#2871) is its first consumer.
It is carried as data only, per this issue's out-of-scope note that
precedence semantics belong to that phase.

Vocabulary: the install axis standardizes on local. ConsentRecord.scope
keeps project deliberately -- that literal is serialized into consent
records in the user's home, and renaming it would silently deactivate
every project-scoped capability on the machine. CONTEXT.md records the
boundary mapping instead.

Every environmental input is injectable (env, home, existsSync, cwd), so
the resolved value is assertable with no filesystem at all.

Registration ripple: .gitignore, eslint.config.mjs, CONTEXT.md glossary,
docs/INVENTORY.md, and the inventory manifest (regenerated after
build:lib, never before).

Verified via the remote runner.

* refactor(#2870): route scope re-derivations through the module

bin/install.js resolves scope once per function instead of inline at
each of its 12 sites, and the settingsFileByScope consumer reads it
through resolveScope().

Seven downstream boolean re-derivations now call the module's
isGlobalScope() instead of comparing the literal independently:
runtime-artifact-install-plan, both runtime-artifact-layout kind
builders, dispatchKindEntry, surface, and two install-engine sites. The
fifth through seventh were not named in the issue -- they are the same
re-derivation class, and leaving them would have made the acceptance
criterion false.

_computePathPrefix keeps its isGlobal boolean API, so the projection is
centralized rather than eliminated. resolveScope and isGlobalScope share
one validator, so the two surfaces cannot drift.

TWO SITES DELIBERATELY NOT ROUTED: runtime-artifact-conversion's
rewriteStagedSkillBodies and rewriteStagedCommandBodies. ADR-1508 fixes
the direction as installer/layout -> conversion, never upward, and
install-scope composes runtime-homes, so importing it into the
conversion module would invert that direction. Left as-is on purpose.

Behavior-preserving throughout. Each step was proven by capturing full
layout and plan output -- including every kind's home field and the
hashed contents of emitted files -- before and after, across both scopes
for claude, codex, opencode, hermes, kimi and kilo. Byte-identical.

surface.cts keeps a scope ?? 'global' default before the call because
Layout.scope is optional there; isGlobalScope throws where the old
inline compare returned false, and that difference would have been a
placement regression.

Verified via the remote runner.

* fix(#2870): cover the no-config-home throw and document the strictness

Two findings from the isolated adversarial review.

The vscode case was implemented but untested. resolveScope throws for a
runtime whose descriptor declares configHome.kind 'none', which is the
design's own behavior-table row 13, but the registry sweep excluded
vscode rather than asserting the throw -- so the behavior shipped with
no test. The exclusion is now legitimate because the case has its own
test naming the runtime in the assertion.

isGlobalScope throws where the inline compare it replaced returned
false. No reachable caller can deliver an out-of-union value today, but
the types are not enforced at runtime, so a future caller passing an
optional Layout.scope would crash rather than silently misroute. That is
the better failure -- misrouting writes artifacts to the wrong place --
but it was undocumented, so the reason is now on the function.

Adds the changeset the acceptance criteria require.

* refactor(#2870): route the last two sites; correct the ADR-1508 claim

The previous commit declined to route runtime-artifact-conversion's
rewriteStagedSkillBodies and rewriteStagedCommandBodies, claiming
ADR-1508's dependency direction forbade the import. That reasoning was
wrong, and this commit corrects it.

Two independent reviewers checked the actual import graph:
runtime-artifact-conversion already imports capability-registry,
command-roster, runtime-name-policy and shell-command-projection -- it
depends on leaf-tier siblings today. install-scope imports only
runtime-homes plus node builtins, and runtime-homes imports only node
builtins, so there is no cycle at any depth. ADR-1508 governs the
installer/layout to conversion boundary, not a leaf-to-leaf sibling
import of the same shape conversion already makes.

With those two routed, every isGlobal re-derivation in the tree now goes
through one owner and acceptance criterion 1 is fully met rather than
partially. Nine sites, not the four the issue enumerated.

Also from the review:

Tests were falling through to the real process.cwd() at five local-scope
call sites, which contradicts the acceptance criterion that the resolved
value be assertable with no filesystem. Every one now injects a cwd. One
of the five was a site the review had not spotted.

bin/install.js carried two near-identical copies of the guarded
resolveScope block, one in install() and one in uninstall() -- duplicated
scope logic in the phase whose purpose is removing it. Extracted to one
helper, and the new sites use the file's existing ternary idiom rather
than the if/else that replaced it.

Equivalence re-proven across both scopes for claude, codex, opencode,
kilo and hermes, now including the staged skill and command body
rewrites hashed per file, since those decide the literal spec-root path
baked into every emitted artifact. Byte-identical.

Verified via the remote runner.

* fix(#2870): assert configHome portably instead of with a native separator

The windows-latest node24 shard failed on two install-scope assertions.
The module was right and the tests were wrong: they built their expected
value with path.join, which emits \fake\home\.claude on Windows, while
resolveScope normalizes separators unconditionally to /fake/home/.claude.

That unconditional normalization is deliberate -- backslash paths arrive
on Linux too, so normalizing via path.sep is the documented defect this
repo guards against. Weakening it to make the assertion pass would have
inverted the fix.

Every path.join-built expectation in the suite now goes through
toPosixPath from tests/helpers.cjs, which is the pattern the
no-path-literal-in-assert rule's own valid-case list sanctions. It splits
on the running platform's path.sep and rejoins with forward slashes, so
it reverses whatever path.join produced on that same platform and the
expectation is invariant everywhere.

Two more call sites had the same latent problem and passed on Linux and
macOS by luck; they are fixed too.

This is the class of defect the remote runner structurally cannot catch
-- its matrix is Linux-only, so a green pass there is not evidence of
portability, and CI's Windows lane is the only place it surfaces.

Verified via the remote runner.

---------

Co-authored-by: sim <sim@local>
2026-08-09 20:16:23 -04:00

281 lines
12 KiB
JavaScript

'use strict';
/**
* Failing-first suite for the Install Scope Module (#2870, ADR-2866).
*
* Asserts `resolveScope`'s contract from
* .gsd/phase/feat-2870-install-scope-module/40-design.md against the test
* matrix at .gsd/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
* gsd-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 } = 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');
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),
);
});
});