Files
msd-core/tests/enh-1077-install-hook-events-dialect-drive.test.cjs
Tom Boucher 94872662e9 feat(#1082): complete phase 5 — descriptor-drive all install surfaces + materialize the InstallPlan — ADR-857/1016/58 (#1080)
* 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>
2026-06-11 22:36:47 -04:00

430 lines
18 KiB
JavaScript

'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* ADR-857 phase 5f-2: hook-events dialect is driven from the registry descriptor.
*
* Before this change, postToolEvent and preToolEvent were hardcoded strings
* derived from runtime-name checks:
*
* (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse'
* (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse'
*
* After phase 5f-2, both are driven by the registry descriptor's
* `hookEvents` field: hookEvents === 'gemini' → AfterTool/BeforeTool;
* any other value (or missing) → PostToolUse/PreToolUse.
*
* Equivalence (i.e. identical observable behaviour for all runtimes):
* hookEvents === 'gemini' iff runtime ∈ {gemini, antigravity}
*
* This suite asserts the equivalence and the registry-parity invariant:
* any runtime whose descriptor carries hookEvents='gemini' gets the
* AfterTool/BeforeTool dialect; all others get PostToolUse/PreToolUse.
*/
const { test, describe, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { install } = require('../bin/install.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
// ─── hooks/dist build guard ───────────────────────────────────────────────────
//
// hooks/dist/ is gitignored and only produced by `npm run build:hooks`.
// In CI the scoped/windows test jobs do NOT run build:hooks before running
// tests, so install() finds no hook files → event arrays come back empty →
// every "expected AfterTool/PostToolUse/BeforeTool/PreToolUse hooks" assertion
// fails. This mirrors the pattern in bug-376-claude-js-hook-gsd-rewriter.test.cjs.
const REPO_ROOT = path.resolve(__dirname, '..');
const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist');
const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
/**
* Idempotently ensure hooks/dist contains built .js files.
* Runs build-hooks.js only when the directory is absent or empty of .js files.
*/
function ensureHooksDist() {
if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) {
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
}
}
before(() => {
ensureHooksDist();
});
// ─── Registry lookup ──────────────────────────────────────────────────────────
const REGISTRY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs');
const registry = (() => {
try { return require(REGISTRY_PATH); } catch { return undefined; }
})();
/**
* Return the hookEvents dialect for a runtime ID from the live registry.
* Returns undefined when the registry is absent or the runtime has no descriptor.
*/
function registryHookEvents(runtimeId) {
return registry?.runtimes?.[runtimeId]?.runtime?.hookEvents;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Collect all hook commands registered under a settings event key. */
function hooksForEvent(settings, eventName) {
if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return [];
return settings.hooks[eventName].flatMap(entry =>
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
.map(h => h && h.command)
.filter(Boolean)
);
}
/** True if at least one hook is registered under eventName. */
function hasHooksFor(settings, eventName) {
return hooksForEvent(settings, eventName).length > 0;
}
// ─── Suite 1: Gemini-dialect runtimes use AfterTool/BeforeTool ───────────────
//
// Registry runtimes with hookEvents='gemini': gemini, antigravity
describe('enh-1077 phase 5f-2: gemini hookEvents dialect → AfterTool/BeforeTool', () => {
// ── gemini ──
describe('gemini install uses AfterTool for post-tool hooks', () => {
let tmpDir;
let previousCwd;
let settings;
beforeEach(() => {
tmpDir = createTempDir('gsd-1077-gemini-');
previousCwd = process.cwd();
process.chdir(tmpDir);
const geminiDir = path.join(tmpDir, '.gemini');
fs.mkdirSync(geminiDir, { recursive: true });
const result = install(false, 'gemini');
settings = result && result.settings;
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('registry confirms gemini hookEvents is "gemini"', () => {
// Parity assertion: if the registry changes, this test fails first.
const he = registryHookEvents('gemini');
if (he !== undefined) {
assert.strictEqual(he, 'gemini',
'Registry descriptor for gemini must declare hookEvents="gemini"');
}
});
test('gemini install returns a settings object', () => {
assert.ok(settings !== null && typeof settings === 'object',
'gemini install must return a non-null settings object');
});
test('gemini install registers at least one hook under AfterTool (post-tool)', () => {
assert.ok(hasHooksFor(settings, 'AfterTool'),
`Expected AfterTool hooks on gemini; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
});
test('gemini install does NOT register context-monitor under PostToolUse (wrong dialect)', () => {
const cmds = hooksForEvent(settings, 'PostToolUse');
const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor'));
assert.strictEqual(hasMonitor, false,
`gemini must NOT use PostToolUse for context-monitor; got PostToolUse commands: ${JSON.stringify(cmds)}`);
});
test('gemini install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => {
const cmds = hooksForEvent(settings, 'BeforeTool');
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
assert.ok(hasPromptGuard,
`Expected prompt-guard hook under BeforeTool on gemini; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
});
test('gemini install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => {
const cmds = hooksForEvent(settings, 'PreToolUse');
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
assert.strictEqual(hasPromptGuard, false,
`gemini must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`);
});
});
// ── antigravity ──
describe('antigravity install uses AfterTool/BeforeTool (gemini dialect)', () => {
let tmpDir;
let previousCwd;
let settings;
beforeEach(() => {
tmpDir = createTempDir('gsd-1077-antigrav-');
previousCwd = process.cwd();
process.chdir(tmpDir);
const agDir = path.join(tmpDir, '.gemini', 'antigravity');
fs.mkdirSync(agDir, { recursive: true });
const result = install(false, 'antigravity');
settings = result && result.settings;
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('registry confirms antigravity hookEvents is "gemini"', () => {
const he = registryHookEvents('antigravity');
if (he !== undefined) {
assert.strictEqual(he, 'gemini',
'Registry descriptor for antigravity must declare hookEvents="gemini"');
}
});
test('antigravity install returns a settings object', () => {
assert.ok(settings !== null && typeof settings === 'object',
'antigravity install must return a non-null settings object');
});
test('antigravity install registers at least one hook under AfterTool', () => {
assert.ok(hasHooksFor(settings, 'AfterTool'),
`Expected AfterTool hooks on antigravity; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
});
test('antigravity install does NOT register context-monitor under PostToolUse', () => {
const cmds = hooksForEvent(settings, 'PostToolUse');
const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor'));
assert.strictEqual(hasMonitor, false,
`antigravity must NOT use PostToolUse for context-monitor; got: ${JSON.stringify(cmds)}`);
});
test('antigravity install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => {
const cmds = hooksForEvent(settings, 'BeforeTool');
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
assert.ok(hasPromptGuard,
`Expected prompt-guard hook under BeforeTool on antigravity; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
});
test('antigravity install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => {
const cmds = hooksForEvent(settings, 'PreToolUse');
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
assert.strictEqual(hasPromptGuard, false,
`antigravity must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`);
});
});
});
// ─── Suite 2: Claude-dialect runtimes use PostToolUse/PreToolUse ──────────────
//
// Registry runtimes with hookEvents='claude': claude, augment
describe('enh-1077 phase 5f-2: claude hookEvents dialect → PostToolUse/PreToolUse', () => {
// ── claude ──
describe('claude install uses PostToolUse for post-tool hooks', () => {
let tmpDir;
let previousCwd;
let settings;
beforeEach(() => {
tmpDir = createTempDir('gsd-1077-claude-');
previousCwd = process.cwd();
process.chdir(tmpDir);
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const result = install(false, 'claude');
settings = result && result.settings;
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('registry confirms claude hookEvents is "claude"', () => {
const he = registryHookEvents('claude');
if (he !== undefined) {
assert.strictEqual(he, 'claude',
'Registry descriptor for claude must declare hookEvents="claude"');
}
});
test('claude install returns a settings object', () => {
assert.ok(settings !== null && typeof settings === 'object',
'claude install must return a non-null settings object');
});
test('claude install registers at least one hook under PostToolUse', () => {
assert.ok(hasHooksFor(settings, 'PostToolUse'),
`Expected PostToolUse hooks on claude; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
});
test('claude install does NOT register context-monitor under AfterTool (wrong dialect)', () => {
const cmds = hooksForEvent(settings, 'AfterTool');
const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor'));
assert.strictEqual(hasMonitor, false,
`claude must NOT use AfterTool for context-monitor; got AfterTool commands: ${JSON.stringify(cmds)}`);
});
test('claude install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => {
const cmds = hooksForEvent(settings, 'PreToolUse');
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
assert.ok(hasPromptGuard,
`Expected prompt-guard hook under PreToolUse on claude; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
});
test('claude install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => {
const cmds = hooksForEvent(settings, 'BeforeTool');
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
assert.strictEqual(hasPromptGuard, false,
`claude must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`);
});
});
// ── augment ──
describe('augment install uses PostToolUse/PreToolUse (claude dialect)', () => {
let tmpDir;
let previousCwd;
let settings;
beforeEach(() => {
tmpDir = createTempDir('gsd-1077-augment-');
previousCwd = process.cwd();
process.chdir(tmpDir);
const augDir = path.join(tmpDir, '.augment');
fs.mkdirSync(augDir, { recursive: true });
const result = install(false, 'augment');
settings = result && result.settings;
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('registry confirms augment hookEvents is "claude"', () => {
const he = registryHookEvents('augment');
if (he !== undefined) {
assert.strictEqual(he, 'claude',
'Registry descriptor for augment must declare hookEvents="claude"');
}
});
test('augment install returns a settings object', () => {
assert.ok(settings !== null && typeof settings === 'object',
'augment install must return a non-null settings object');
});
test('augment install registers at least one hook under PostToolUse', () => {
assert.ok(hasHooksFor(settings, 'PostToolUse'),
`Expected PostToolUse hooks on augment; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
});
test('augment install does NOT register context-monitor under AfterTool', () => {
const cmds = hooksForEvent(settings, 'AfterTool');
const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor'));
assert.strictEqual(hasMonitor, false,
`augment must NOT use AfterTool for context-monitor; got: ${JSON.stringify(cmds)}`);
});
test('augment install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => {
const cmds = hooksForEvent(settings, 'PreToolUse');
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
assert.ok(hasPromptGuard,
`Expected prompt-guard hook under PreToolUse on augment; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
});
test('augment install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => {
const cmds = hooksForEvent(settings, 'BeforeTool');
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
assert.strictEqual(hasPromptGuard, false,
`augment must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`);
});
});
});
// ─── Suite 3: Registry-parity invariant ──────────────────────────────────────
//
// For every runtime in the registry that exposes a settings.json surface
// (i.e. hookEvents is defined), assert that the installed hook dialect matches
// the registry value. This is the generative-fix parity assertion
// (DEFECT.GENERATIVE-FIX): adding a new runtime with hookEvents to the
// registry automatically requires a passing install test for that runtime.
describe('enh-1077 phase 5f-2: registry-parity — hookEvents descriptor drives install dialect', () => {
test('all registry runtimes with hookEvents use the matching install dialect', () => {
if (!registry || !registry.runtimes) {
// Registry absent — skip parity check (equivalence still verified above)
return;
}
// Runtimes that have settings.json surfaces and a hookEvents descriptor
const SETTINGS_JSON_RUNTIMES = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy'];
const failures = [];
for (const runtimeId of SETTINGS_JSON_RUNTIMES) {
const he = registryHookEvents(runtimeId);
if (he === undefined) continue; // no hookEvents in descriptor — skip
const expectedPostEvent = he === 'gemini' ? 'AfterTool' : 'PostToolUse';
const unexpectedPostEvent = he === 'gemini' ? 'PostToolUse' : 'AfterTool';
const expectedPreEvent = he === 'gemini' ? 'BeforeTool' : 'PreToolUse';
const unexpectedPreEvent = he === 'gemini' ? 'PreToolUse' : 'BeforeTool';
const previousCwd = process.cwd();
const tmpDir = createTempDir(`gsd-1077-parity-${runtimeId}-`);
try {
process.chdir(tmpDir);
const result = install(false, runtimeId);
const settings = result && result.settings;
if (!settings) continue; // non-settings-json surface, skip
// Post-tool event assertions
const hasExpected = hasHooksFor(settings, expectedPostEvent);
const hasUnexpected = hooksForEvent(settings, unexpectedPostEvent)
.some(c => c && c.includes('gsd-context-monitor'));
if (!hasExpected) {
failures.push(`${runtimeId}: expected context-monitor hook under ${expectedPostEvent} (hookEvents=${he}), but none found`);
}
if (hasUnexpected) {
failures.push(`${runtimeId}: must NOT register context-monitor under ${unexpectedPostEvent}, but it was found`);
}
// Pre-tool event assertions: prompt-guard must land under the dialect-correct key.
const preToolCmdsExpected = hooksForEvent(settings, expectedPreEvent);
const hasPromptGuardExpected = preToolCmdsExpected.some(c => c && c.includes('gsd-prompt-guard'));
const preToolCmdsUnexpected = hooksForEvent(settings, unexpectedPreEvent);
const hasPromptGuardUnexpected = preToolCmdsUnexpected.some(c => c && c.includes('gsd-prompt-guard'));
if (!hasPromptGuardExpected) {
failures.push(`${runtimeId}: expected prompt-guard hook under ${expectedPreEvent} (hookEvents=${he}), but none found; ${expectedPreEvent} cmds: ${JSON.stringify(preToolCmdsExpected)}`);
}
if (hasPromptGuardUnexpected) {
failures.push(`${runtimeId}: must NOT register prompt-guard under ${unexpectedPreEvent} (hookEvents=${he}), but it was found`);
}
} finally {
process.chdir(previousCwd);
cleanup(tmpDir);
}
}
assert.deepEqual(failures, [],
'Registry-parity failures (hookEvents descriptor must drive install dialect):\n' +
failures.join('\n'));
});
});