* feat(#1077): phase 5f-2 — drive the hookEvents dialect (PostToolUse/AfterTool) from the descriptor postToolEvent (bin/install.js) and preToolEvent (applySettingsJsonHooks in runtime-hooks-surface.cts) now select the event-name dialect from registry.runtimes[id].runtime.hookEvents instead of the hardcoded (runtime === 'gemini' || runtime === 'antigravity') check: hookEvents === 'gemini' → AfterTool/BeforeTool; else → PostToolUse/PreToolUse. hookEvents threaded into the applySettingsJsonHooks opts bag. Equivalence-preserving (Codex-verified): hookEvents 'gemini' is exactly {gemini, antigravity}, 'claude' the rest; undefined → claude dialect (matches the old else). The per-event SET guards (isQwen||claude → SubagentStop/Stop/PreCompact; runtime==='claude' → FileChanged; isGemini → Gemini agent-events) stay HARDCODED — hookEvents (2-value) is too coarse to drive them (the event set differs within hookEvents='claude'); per-event-set drive tracked in #1076. Registry-parity test (enh-1077): asserts BOTH post-tool (AfterTool/PostToolUse) AND pre-tool (BeforeTool/PreToolUse) dialects are a pure function of hookEvents, for gemini/antigravity/claude/augment — non-vacuous (catches a broken hookEvents thread). Closes #1077 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1077): build hooks/dist in before() so dialect-drive test passes in scoped CI hooks/dist is gitignored and absent in scoped/windows CI jobs that do not pre-run build:hooks. Without it, install() finds no hook files and all AfterTool/BeforeTool/PostToolUse/PreToolUse event arrays come back empty, failing every hook-presence assertion. Added an idempotent ensureHooksDist() called in a top-level before() — mirrors the pattern from bug-376-claude-js-hook-gsd-rewriter.test.cjs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1055): add installSurface/writesSharedSettings/permissionWriter/extendedHookEvents to runtime descriptors Purely additive: four new fields on all 16 runtime capability.json descriptors, validator extended with three new closed-vocab sets, registry regenerated. Test fixtures (VALID_RUNTIME_CAP and makeRuntimeCap) updated to include the new required fields so all 255 capability-registry tests continue to pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1076): drive per-event hook guards from extendedHookEvents descriptor Replace hardcoded runtime-name checks (isQwen||runtime==='claude', runtime==='claude', isGemini) in applySettingsJsonHooks with a single descriptor-driven extendedEvents array derived from the new opts field. Remove isQwen and isGemini derivations (no remaining uses after the three guard blocks are migrated). Wire extendedHookEvents from the capability registry in bin/install.js call site. Add behavioral regression test (enh-1076-extended-hook-events-drive.test.cjs) confirming the drive is purely descriptor-based and runtime-name-agnostic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1055): drive resolveRuntimeConfigIntent from the runtime descriptor; retire hand-kept REGISTRY - Rewrites src/runtime-config-adapter-registry.cts to require capability-registry.cjs and read installSurface / writesSharedSettings / permissionWriter from runtimes[id].runtime; deletes the hand-kept REGISTRY const (ADR-857 phase 5g drive 2). - ALLOWED_CONFIG_RUNTIMES is now derived from descriptor entries that have installSurface. - Fixes the configFormat parity gate in scripts/gen-capability-registry.cjs to read installSurface directly from capMap descriptor bodies, breaking the require cycle (adapter now requires the generated registry; gen-script must not require the adapter). - Adds golden-master test tests/enh-1055-config-intent-descriptor-drive.test.cjs (41 tests) pinning all 16 runtimes' return shapes and the TypeError-on-unknown contract. - Updates scripts/lint-test-file-count.allowlist.json (config module, +1 file). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1076): make hooksSurface descriptor load-bearing for the settings-json hook-skip - Adds hooksSurface?: string to ApplySettingsJsonHooksOpts and destructuring in applySettingsJsonHooks (src/runtime-hooks-surface.cts). - Replaces the hardcoded !isOpencode && !isKilo hook-skip guard with hooksSurface !== 'none'; removes the now-unused isOpencode/isKilo derivations (ADR-857 phase 5g drive 3). - Passes hooksSurface from the runtime descriptor at the applySettingsJsonHooks call site in bin/install.js using the established _capabilityRegistry?.runtimes?.[runtime]?.runtime?.hooksSurface idiom. - Extends tests/enh-1076-extended-hook-events-drive.test.cjs with two new suites proving: (a) hooksSurface:'none' writes no hooks regardless of runtime name; (b) hooksSurface:'settings-json' writes hooks even for 'opencode' (previously hardcoded to skip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: record installSurface/writesSharedSettings/permissionWriter/extendedHookEvents descriptor axes in ADR-1016 Add Decision 7a documenting the four axes added in the 5f-completion pass, update axis counts from "six" to "twelve", note 5f-completion drives as done in Decision 8's ladder, update Out of scope to reflect #1055/#1076 are done and 5g (InstallPlan capstone) remains the only open phase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1055): parity gate must fire on configFormat↔installSurface mismatch (read installSurface at the descriptor level) The test fixture makeRuntimeCapMap did not include installSurface in the runtime object, so the gate's typeof r.installSurface !== 'string' guard always skipped the entry and never threw. Added installSurface as an optional third parameter to makeRuntimeCapMap and passed the correct installSurface values ('settings-json' for claude, 'codex-toml' for codex) to the two THROWS tests. The gate implementation already reads r.installSurface correctly from the descriptor level. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1076): add installSurface↔hooksSurface + extendedHookEvents↔hookEvents consistency gates with rejection tests GATE A: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES map in validateRuntimeBody enforces that a runtime's hooksSurface is valid for its installSurface (e.g. profile-marker-only only allows none, codex-toml only allows codex-hooks-json). Derived from the 16 real runtime descriptors. GATE B: validateRuntimeBody checks that if extendedHookEvents contains Gemini agent-events (BeforeAgent/AfterAgent/BeforeModel), hookEvents must be 'gemini'; if it contains Claude-family events (SubagentStop/Stop/PreCompact/FileChanged), hookEvents must be 'claude'. Added 10 rejection tests in suite 27 covering each gate + each new field validator. All 16 real runtimes satisfy both gates (verified before coding). Exports: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, VALID_INSTALL_SURFACES, VALID_EXTENDED_HOOK_EVENTS, VALID_PERMISSION_WRITERS, validateRuntimeBody. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1076): strengthen hooksSurface-drive assertions; defensive hooksSurface fallback; drop vacuous dup 1. bin/install.js: add explicit literal fallback for hooksSurface when the committed capability registry fails to load (opencode/kilo → 'none', all others → 'settings-json'). The descriptor is always the source of truth in normal operation. 2. enh-1076 Suite 7: change SessionStart assertion from key-presence (hasOwnProperty) to at least-one-command (hasHooksFor), so the test fails if hooks are initialized-but-empty. ensureHooksDist() in before() guarantees hook files exist. 3. enh-1055 Test 2: remove vacuous duplicate suite that re-asserted intent.runtime === row.runtime already fully covered by Test 1's deepStrictEqual over all four fields. 4. capability-registry.test.cjs: fix stale comments in the grok-skip test that claimed the parity gate uses the adapter registry; gate reads purely from the descriptor (installSurface absent → typeof r.installSurface !== 'string' → soft-skip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1082): materialize the InstallPlan — collect install-level descriptor axes into resolveInstallPlan; route install()/finishInstall() through it (ADR-58/5g) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: record 5g InstallPlan materialization (ADR-58 Accepted, ADR-1016 phase-5 complete) ADR-1016 Decision 8 step 7 updated to DONE: resolveInstallPlan(runtime) in runtime-config-adapter-registry collects install-level descriptor axes into the typed InstallPlan consumed by install()/finishInstall(). Out-of-scope section updated: 5g capstone is complete, phase 5 fully materialized. ADR-1016 line ~20 updated: InstallPlan IS now materialized (both halves). ADR-58 Implementation note added (2026-06-11): realized in runtime-config-adapter-registry (co-located with adapter-selection). CONTEXT.md Runtime Config Adapter Registry entry extended to document resolveInstallPlan and both-halves realization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1082): update install drift guard to the resolveInstallPlan seam (5g) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
202 lines
9.6 KiB
JavaScript
202 lines
9.6 KiB
JavaScript
'use strict';
|
|
|
|
// Issue #57 — Runtime Install No-Drift Tests.
|
|
//
|
|
// Protects the Runtime Install Policy Module boundary (ADR-58) and the explicit
|
|
// Runtime Config Adapter Registry (#60) now that the policy boundary (#58),
|
|
// explicit adapter registry (#60), and legacy directory-helper retirement (#56)
|
|
// have landed. These guards FAIL when:
|
|
//
|
|
// (AC1) supported-runtime metadata is added to an installer/query call site
|
|
// without going through the runtime registry projection, or
|
|
// (AC2) config-mutation dispatch bypasses the explicit adapter registry.
|
|
//
|
|
// (AC3) Assertions are behavioral (require + reflect on live exports) wherever
|
|
// behavior can cover the contract; the two source-text assertions are structural
|
|
// guards that behavioral checks cannot replace, and are annotated per repo
|
|
// convention. (AC4) The existing installer / runtime-policy / runtime-global-skills
|
|
// suites must stay green — verified by running them alongside this file, not
|
|
// asserted here.
|
|
//
|
|
// Known INTENTIONAL asymmetries — these are not drift; do not "fix" them by
|
|
// tightening the invariants:
|
|
// - `grok` appears in runtime-homes.cjs's getGlobalConfigDir switch but NOT in
|
|
// the registry / artifact-layout supported sets (it resolves a config-dir home
|
|
// but is not an installable artifact target). So runtime-homes' full switch set
|
|
// is never tied into the equality invariant — it is only probed forward, per
|
|
// installable runtime.
|
|
// - getGlobalConfigDir() falls back to ~/.claude for an UNKNOWN runtime instead
|
|
// of throwing (a deliberately liberal projection). Only the registry and
|
|
// artifact-layout projections are loud gates, so only those are asserted to
|
|
// throw on an unknown runtime.
|
|
//
|
|
// Coverage boundary (deliberate, see #57 follow-up): the structural guard below
|
|
// catches a NEW inline `runtime === '...'` branch against an UNREGISTERED runtime.
|
|
// It cannot catch a duplicate inline config write added for an ALREADY-registered
|
|
// runtime — distinguishing that from the ~169 legitimate per-runtime comparisons in
|
|
// the installer requires driving install()/finishInstall() against a mocked
|
|
// filesystem and asserting the written surfaces match resolveRuntimeConfigIntent().
|
|
// That behavioral install-driver harness is out of scope for this no-drift pass.
|
|
//
|
|
// The forward invariant `allRuntimes ⊆ artifact-layout` is already covered by
|
|
// tests/install-runtime-artifacts.test.cjs; this file does not duplicate it.
|
|
|
|
process.env.GSD_TEST_MODE = '1'; // must precede require of bin/install.js
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const LIB = path.join(ROOT, 'gsd-core', 'bin', 'lib');
|
|
|
|
const { allRuntimes, runtimeMap } = require(path.join(ROOT, 'bin', 'install.js'));
|
|
const {
|
|
resolveRuntimeConfigIntent,
|
|
ALLOWED_CONFIG_RUNTIMES,
|
|
INSTALL_SURFACES,
|
|
} = require(path.join(LIB, 'runtime-config-adapter-registry.cjs'));
|
|
const { resolveRuntimeArtifactLayout } = require(
|
|
path.join(LIB, 'runtime-artifact-layout.cjs'),
|
|
);
|
|
const { getGlobalConfigDir } = require(path.join(LIB, 'runtime-homes.cjs'));
|
|
|
|
const sorted = (iterable) => [...iterable].sort();
|
|
|
|
// A runtime name that is deliberately not real and is not a prototype-chain key.
|
|
const SENTINEL = '__drift_sentinel_runtime__';
|
|
|
|
describe('issue-57 AC1 — supported-runtime metadata has one projected source of truth', () => {
|
|
test('installer allRuntimes, interactive runtimeMap, and registry agree on the supported set', () => {
|
|
const installable = sorted(allRuntimes);
|
|
assert.deepStrictEqual(
|
|
installable,
|
|
sorted(Object.values(runtimeMap)),
|
|
'Drift: bin/install.js `allRuntimes` and the interactive `runtimeMap` selection menu '
|
|
+ 'diverged. A runtime selectable in the prompt but absent from allRuntimes (or vice '
|
|
+ 'versa) is a supported-runtime call site that skipped the projection.',
|
|
);
|
|
assert.deepStrictEqual(
|
|
installable,
|
|
sorted(ALLOWED_CONFIG_RUNTIMES),
|
|
'Drift: bin/install.js `allRuntimes` and `ALLOWED_CONFIG_RUNTIMES` (runtime config '
|
|
+ 'adapter registry) diverged. A runtime added to an installer call site without a '
|
|
+ 'registry adapter entry bypasses the registry projection — register it in '
|
|
+ 'src/runtime-config-adapter-registry.cts.',
|
|
);
|
|
});
|
|
|
|
test('every installable runtime resolves a config intent through the registry', () => {
|
|
for (const runtime of allRuntimes) {
|
|
const intent = resolveRuntimeConfigIntent(runtime);
|
|
assert.equal(
|
|
intent.runtime,
|
|
runtime,
|
|
`${runtime} must resolve its own config intent through resolveRuntimeConfigIntent`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('every installable runtime resolves a global config dir through runtime-homes', () => {
|
|
for (const runtime of allRuntimes) {
|
|
const dir = getGlobalConfigDir(runtime);
|
|
assert.equal(typeof dir, 'string', `${runtime} config dir must be a string`);
|
|
assert.ok(dir.length > 0, `${runtime} must resolve a non-empty global config dir`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit registry', () => {
|
|
test('every config intent uses a registry-declared install surface', () => {
|
|
const surfaces = new Set(INSTALL_SURFACES);
|
|
for (const runtime of allRuntimes) {
|
|
const { installSurface } = resolveRuntimeConfigIntent(runtime);
|
|
assert.ok(
|
|
surfaces.has(installSurface),
|
|
`${runtime} dispatches config via unregistered surface "${installSurface}" — add it `
|
|
+ 'to INSTALL_SURFACES in the registry instead of branching on it inline.',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('every finishInstall permission writer is null or a registry-known runtime', () => {
|
|
// Registry-derived (no hand-maintained vocabulary): a permission writer either
|
|
// names a runtime that is itself in the registry, or is null. A writer pointing
|
|
// at an unregistered runtime would mean finishInstall dispatches a config mutation
|
|
// outside the registry's known set.
|
|
for (const runtime of allRuntimes) {
|
|
const { finishPermissionWriter } = resolveRuntimeConfigIntent(runtime);
|
|
assert.ok(
|
|
finishPermissionWriter === null || ALLOWED_CONFIG_RUNTIMES.has(finishPermissionWriter),
|
|
`${runtime} uses finishPermissionWriter "${finishPermissionWriter}", which is neither `
|
|
+ 'null nor a registry-known runtime — route it through a registered adapter.',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('unknown runtime fails loudly through both strict projections (no silent fallthrough)', () => {
|
|
assert.throws(
|
|
() => resolveRuntimeConfigIntent(SENTINEL),
|
|
TypeError,
|
|
'config adapter registry must reject an unknown runtime, not dispatch it silently',
|
|
);
|
|
assert.throws(
|
|
() => resolveRuntimeArtifactLayout(SENTINEL, path.join(os.tmpdir(), 'gsd-57'), 'global'),
|
|
TypeError,
|
|
'artifact-layout projection must reject an unknown runtime',
|
|
);
|
|
});
|
|
|
|
test('registry rejects prototype-chain keys (no proto-pollution dispatch bypass)', () => {
|
|
for (const key of ['__proto__', 'constructor', 'prototype', 'toString']) {
|
|
assert.throws(
|
|
() => resolveRuntimeConfigIntent(key),
|
|
TypeError,
|
|
`${key} must throw, not resolve via the prototype chain`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// allow-test-rule: structural guard over bin/install.js source. Behavioral assertions
|
|
// cannot observe inline `runtime === '...'` config branching, so this enforces that
|
|
// every inline per-runtime branch references a runtime the adapter registry knows
|
|
// about — a NEW branch against an unregistered runtime name fails here. It matches
|
|
// positive equality only (`runtime === '<name>'` / `runtime === "<name>"`, both quote
|
|
// styles), so `runtime !== 'string'`-style type guards are not implicated. See the
|
|
// "coverage boundary" note at the top of the file for what this can and cannot catch.
|
|
test('every inline `runtime === "..."` branch references a registry-known runtime', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
|
|
const literals = new Set(
|
|
[...src.matchAll(/runtime === (?:'([a-z][a-z0-9-]*)'|"([a-z][a-z0-9-]*)")/g)]
|
|
.map((m) => m[1] ?? m[2]),
|
|
);
|
|
assert.ok(literals.size > 0, 'expected to find inline runtime comparisons in bin/install.js');
|
|
const unregistered = [...literals].filter((r) => !ALLOWED_CONFIG_RUNTIMES.has(r));
|
|
assert.deepStrictEqual(
|
|
unregistered,
|
|
[],
|
|
`inline 'runtime === "..."' branch(es) reference runtimes absent from the config adapter `
|
|
+ `registry: ${unregistered.join(', ')} — register them in `
|
|
+ 'src/runtime-config-adapter-registry.cts or route the logic through '
|
|
+ 'resolveRuntimeConfigIntent instead of branching inline.',
|
|
);
|
|
});
|
|
|
|
// allow-test-rule: delegation-presence guard. Catches wholesale removal of the registry
|
|
// dispatch (a regression to scattered per-runtime config branching). Presence-style, not
|
|
// absence-grep, so it does not bite on incidental non-config `runtime === '...'` checks.
|
|
test('bin/install.js requires the config adapter registry and dispatches through it', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
|
|
assert.ok(
|
|
src.includes('runtime-config-adapter-registry'),
|
|
'bin/install.js no longer requires the runtime config adapter registry',
|
|
);
|
|
assert.ok(
|
|
src.includes('resolveInstallPlan('),
|
|
'bin/install.js no longer dispatches config through resolveInstallPlan',
|
|
);
|
|
});
|
|
});
|