* 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>
428 lines
14 KiB
JavaScript
428 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
/**
|
|
* ADR-857 phase 5f-3: extended hook event guards are driven by the
|
|
* extendedHookEvents descriptor field, not hardcoded runtime-name checks.
|
|
*
|
|
* Before this change:
|
|
* - SubagentStop/Stop/PreCompact were wired only when (isQwen || runtime==='claude')
|
|
* - FileChanged was wired only when (runtime === 'claude')
|
|
* - BeforeAgent/AfterAgent/BeforeModel were wired only when (isGemini)
|
|
*
|
|
* After this change:
|
|
* - All three guard blocks are driven purely by extendedEvents.includes(eventName)
|
|
* - Any runtime (or arbitrary string) that passes the right extendedHookEvents
|
|
* array gets exactly those events registered, regardless of its runtime name.
|
|
*
|
|
* This suite proves descriptor-drive by calling applySettingsJsonHooks directly
|
|
* with a controlled extendedHookEvents array and asserting on settings.hooks.
|
|
* No source-grep; purely behavioral.
|
|
*/
|
|
|
|
const { test, describe, before } = 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 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. */
|
|
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();
|
|
});
|
|
|
|
const { applySettingsJsonHooks } = require('../bin/install.js');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Return all hook commands registered under an 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 any hook is registered under eventName. */
|
|
function hasHooksFor(settings, eventName) {
|
|
return hooksForEvent(settings, eventName).length > 0;
|
|
}
|
|
|
|
/**
|
|
* Create a temporary directory with stub hook files so fs.existsSync guards pass.
|
|
* Returns the targetDir path.
|
|
*/
|
|
function createStubTargetDir() {
|
|
const tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-1076-'));
|
|
const hooksDir = path.join(tmpDir, 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
// Stubs for the hooks applySettingsJsonHooks existsSync-checks
|
|
const stubs = [
|
|
'gsd-check-update.js',
|
|
'gsd-context-monitor.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-read-injection-scanner.js',
|
|
'gsd-config-reload.js',
|
|
'gsd-workflow-guard.js',
|
|
'gsd-worktree-path-guard.js',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-session-state.sh',
|
|
'gsd-phase-boundary.sh',
|
|
'gsd-graphify-update.sh',
|
|
];
|
|
const hooksDistDir = path.join(REPO_ROOT, 'hooks', 'dist');
|
|
for (const stub of stubs) {
|
|
const dest = path.join(hooksDir, stub);
|
|
const distSrc = path.join(hooksDistDir, stub);
|
|
if (fs.existsSync(distSrc)) {
|
|
fs.copyFileSync(distSrc, dest);
|
|
} else {
|
|
// Minimal stub so existsSync passes
|
|
const ext = path.extname(stub);
|
|
fs.writeFileSync(dest, ext === '.sh' ? '#!/bin/bash\n# stub\n' : '#!/usr/bin/env node\n// stub\n');
|
|
}
|
|
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
|
|
}
|
|
return tmpDir;
|
|
}
|
|
|
|
function cleanupDir(dir) {
|
|
cleanup(dir);
|
|
}
|
|
|
|
/**
|
|
* Build the minimal opts bag for applySettingsJsonHooks.
|
|
* postToolEvent: 'PostToolUse' (default dialect).
|
|
* All commands: non-null strings so the "command truthy" guard passes.
|
|
*/
|
|
function buildOpts(targetDir, { runtime, extendedHookEvents }) {
|
|
const hookOpts = { platform: process.platform, runtime };
|
|
const node = process.execPath;
|
|
return {
|
|
runtime,
|
|
isGlobal: true,
|
|
targetDir,
|
|
postToolEvent: 'PostToolUse',
|
|
hookEvents: undefined, // not the hookEvents dialect — we're testing extendedHookEvents
|
|
extendedHookEvents,
|
|
updateCheckCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-check-update.js')}"`,
|
|
contextMonitorCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-context-monitor.js')}"`,
|
|
promptGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-prompt-guard.js')}"`,
|
|
readGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-guard.js')}"`,
|
|
readInjectionScannerCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js')}"`,
|
|
configReloadCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-config-reload.js')}"`,
|
|
hookOpts,
|
|
localCmd: () => null,
|
|
localShellCmd: () => null,
|
|
};
|
|
}
|
|
|
|
// ─── Suite 1: claude shape (SubagentStop+Stop+PreCompact+FileChanged) ─────────
|
|
|
|
describe('enh-1076 phase 5f-3: claude extendedHookEvents → SubagentStop/Stop/PreCompact/FileChanged', () => {
|
|
let targetDir;
|
|
let settings;
|
|
|
|
before(() => {
|
|
targetDir = createStubTargetDir();
|
|
settings = { hooks: {} };
|
|
const opts = buildOpts(targetDir, {
|
|
runtime: 'claude',
|
|
extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'],
|
|
});
|
|
applySettingsJsonHooks(settings, opts);
|
|
});
|
|
|
|
test('SubagentStop is wired (descriptor-driven)', () => {
|
|
assert.ok(
|
|
hasHooksFor(settings, 'SubagentStop'),
|
|
`Expected SubagentStop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
|
);
|
|
});
|
|
|
|
test('Stop is wired (descriptor-driven)', () => {
|
|
assert.ok(
|
|
hasHooksFor(settings, 'Stop'),
|
|
`Expected Stop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
|
);
|
|
});
|
|
|
|
test('PreCompact is wired (descriptor-driven)', () => {
|
|
assert.ok(
|
|
hasHooksFor(settings, 'PreCompact'),
|
|
`Expected PreCompact hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
|
);
|
|
});
|
|
|
|
test('FileChanged is wired (descriptor-driven)', () => {
|
|
assert.ok(
|
|
hasHooksFor(settings, 'FileChanged'),
|
|
`Expected FileChanged hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
|
);
|
|
});
|
|
|
|
test('cleanup', () => {
|
|
cleanupDir(targetDir);
|
|
});
|
|
});
|
|
|
|
// ─── Suite 2: qwen shape (SubagentStop+Stop+PreCompact, no FileChanged) ───────
|
|
|
|
describe('enh-1076 phase 5f-3: qwen extendedHookEvents → SubagentStop/Stop/PreCompact only', () => {
|
|
let targetDir;
|
|
let settings;
|
|
|
|
before(() => {
|
|
targetDir = createStubTargetDir();
|
|
settings = { hooks: {} };
|
|
const opts = buildOpts(targetDir, {
|
|
runtime: 'qwen',
|
|
extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'],
|
|
});
|
|
applySettingsJsonHooks(settings, opts);
|
|
});
|
|
|
|
test('SubagentStop is wired', () => {
|
|
assert.ok(hasHooksFor(settings, 'SubagentStop'));
|
|
});
|
|
|
|
test('Stop is wired', () => {
|
|
assert.ok(hasHooksFor(settings, 'Stop'));
|
|
});
|
|
|
|
test('PreCompact is wired', () => {
|
|
assert.ok(hasHooksFor(settings, 'PreCompact'));
|
|
});
|
|
|
|
test('FileChanged is NOT wired (not in extendedHookEvents)', () => {
|
|
assert.strictEqual(
|
|
hasHooksFor(settings, 'FileChanged'),
|
|
false,
|
|
`FileChanged must NOT be wired for qwen shape; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
|
);
|
|
});
|
|
|
|
test('cleanup', () => {
|
|
cleanupDir(targetDir);
|
|
});
|
|
});
|
|
|
|
// ─── Suite 3: gemini shape (BeforeAgent+AfterAgent+BeforeModel) ───────────────
|
|
|
|
describe('enh-1076 phase 5f-3: gemini extendedHookEvents → BeforeAgent/AfterAgent/BeforeModel', () => {
|
|
let targetDir;
|
|
let settings;
|
|
|
|
before(() => {
|
|
targetDir = createStubTargetDir();
|
|
settings = { hooks: {} };
|
|
const opts = buildOpts(targetDir, {
|
|
runtime: 'gemini',
|
|
extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'],
|
|
});
|
|
applySettingsJsonHooks(settings, opts);
|
|
});
|
|
|
|
test('BeforeAgent is wired', () => {
|
|
assert.ok(
|
|
hasHooksFor(settings, 'BeforeAgent'),
|
|
`Expected BeforeAgent hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
|
);
|
|
});
|
|
|
|
test('AfterAgent is wired', () => {
|
|
assert.ok(hasHooksFor(settings, 'AfterAgent'));
|
|
});
|
|
|
|
test('BeforeModel is wired', () => {
|
|
assert.ok(hasHooksFor(settings, 'BeforeModel'));
|
|
});
|
|
|
|
test('SubagentStop is NOT wired (not in extendedHookEvents)', () => {
|
|
assert.strictEqual(
|
|
hasHooksFor(settings, 'SubagentStop'),
|
|
false,
|
|
'SubagentStop must NOT be wired for gemini shape'
|
|
);
|
|
});
|
|
|
|
test('FileChanged is NOT wired (not in extendedHookEvents)', () => {
|
|
assert.strictEqual(
|
|
hasHooksFor(settings, 'FileChanged'),
|
|
false,
|
|
'FileChanged must NOT be wired for gemini shape'
|
|
);
|
|
});
|
|
|
|
test('cleanup', () => {
|
|
cleanupDir(targetDir);
|
|
});
|
|
});
|
|
|
|
// ─── Suite 4: empty extendedHookEvents → none of the extended events ──────────
|
|
|
|
describe('enh-1076 phase 5f-3: empty extendedHookEvents → no extended events wired', () => {
|
|
let targetDir;
|
|
let settings;
|
|
|
|
before(() => {
|
|
targetDir = createStubTargetDir();
|
|
settings = { hooks: {} };
|
|
// Use runtime='someruntime' to prove it's the descriptor, not the name, that matters
|
|
const opts = buildOpts(targetDir, {
|
|
runtime: 'someruntime',
|
|
extendedHookEvents: [],
|
|
});
|
|
applySettingsJsonHooks(settings, opts);
|
|
});
|
|
|
|
const EXTENDED_EVENTS = [
|
|
'SubagentStop', 'Stop', 'PreCompact', 'FileChanged',
|
|
'BeforeAgent', 'AfterAgent', 'BeforeModel',
|
|
];
|
|
|
|
for (const event of EXTENDED_EVENTS) {
|
|
test(`${event} is NOT wired when extendedHookEvents is empty`, () => {
|
|
assert.strictEqual(
|
|
hasHooksFor(settings, event),
|
|
false,
|
|
`${event} must not be wired when extendedHookEvents=[] (runtime=someruntime); hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
|
);
|
|
});
|
|
}
|
|
|
|
test('cleanup', () => {
|
|
cleanupDir(targetDir);
|
|
});
|
|
});
|
|
|
|
// ─── Suite 5: descriptor-drive is runtime-name-agnostic ───────────────────────
|
|
// Pass an arbitrary runtime name ('hypothetical') with SubagentStop in its
|
|
// extendedHookEvents. This could NEVER have worked under the old hardcoded check.
|
|
// Under the new descriptor-driven guard it MUST work.
|
|
|
|
describe('enh-1076 phase 5f-3: arbitrary runtime with SubagentStop in descriptor gets it wired', () => {
|
|
let targetDir;
|
|
let settings;
|
|
|
|
before(() => {
|
|
targetDir = createStubTargetDir();
|
|
settings = { hooks: {} };
|
|
const opts = buildOpts(targetDir, {
|
|
runtime: 'hypothetical', // NOT 'claude' or 'qwen' — would have been skipped before
|
|
extendedHookEvents: ['SubagentStop'],
|
|
});
|
|
applySettingsJsonHooks(settings, opts);
|
|
});
|
|
|
|
test('SubagentStop IS wired for a hypothetical runtime when descriptor includes it', () => {
|
|
assert.ok(
|
|
hasHooksFor(settings, 'SubagentStop'),
|
|
`SubagentStop must be wired via descriptor even for unknown runtime names; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
|
);
|
|
});
|
|
|
|
test('Stop is NOT wired (not in extendedHookEvents)', () => {
|
|
assert.strictEqual(hasHooksFor(settings, 'Stop'), false);
|
|
});
|
|
|
|
test('cleanup', () => {
|
|
cleanupDir(targetDir);
|
|
});
|
|
});
|
|
|
|
// ─── Suite 6: hooksSurface drive (ADR-857 phase 5g drive 3) ──────────────────
|
|
//
|
|
// applySettingsJsonHooks is gated by opts.hooksSurface !== 'none'.
|
|
// - hooksSurface:'none' → entire body is skipped; no hooks written
|
|
// - hooksSurface:'settings-json'→ hooks are written (even for a runtime whose
|
|
// name was previously hardcoded to skip, e.g. 'opencode')
|
|
//
|
|
// This proves the skip is driven by the descriptor field, not the runtime name.
|
|
|
|
describe('enh-1076 phase 5g drive 3: hooksSurface:none skips all hooks regardless of runtime', () => {
|
|
let targetDir;
|
|
let settings;
|
|
|
|
before(() => {
|
|
targetDir = createStubTargetDir();
|
|
settings = { hooks: {} };
|
|
// 'claude' would normally write hooks, but hooksSurface:'none' must skip entirely.
|
|
const opts = {
|
|
...buildOpts(targetDir, { runtime: 'claude', extendedHookEvents: ['SubagentStop'] }),
|
|
hooksSurface: 'none',
|
|
};
|
|
applySettingsJsonHooks(settings, opts);
|
|
});
|
|
|
|
test('SessionStart is NOT written when hooksSurface is "none"', () => {
|
|
assert.strictEqual(
|
|
hasHooksFor(settings, 'SessionStart'),
|
|
false,
|
|
`SessionStart must not be written when hooksSurface="none"; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
|
);
|
|
});
|
|
|
|
test('PostToolUse is NOT written when hooksSurface is "none"', () => {
|
|
assert.strictEqual(hasHooksFor(settings, 'PostToolUse'), false);
|
|
});
|
|
|
|
test('PreToolUse is NOT written when hooksSurface is "none"', () => {
|
|
assert.strictEqual(hasHooksFor(settings, 'PreToolUse'), false);
|
|
});
|
|
|
|
test('cleanup', () => {
|
|
cleanupDir(targetDir);
|
|
});
|
|
});
|
|
|
|
describe('enh-1076 phase 5g drive 3: hooksSurface:settings-json writes hooks even for previously-skipped runtime name', () => {
|
|
let targetDir;
|
|
let settings;
|
|
|
|
before(() => {
|
|
targetDir = createStubTargetDir();
|
|
settings = { hooks: {} };
|
|
// 'opencode' previously was hardcoded to skip hooks; with descriptor drive it
|
|
// should write hooks whenever hooksSurface !== 'none'.
|
|
const opts = {
|
|
...buildOpts(targetDir, { runtime: 'opencode', extendedHookEvents: [] }),
|
|
hooksSurface: 'settings-json',
|
|
};
|
|
applySettingsJsonHooks(settings, opts);
|
|
});
|
|
|
|
test('SessionStart IS written with at least one command when hooksSurface is "settings-json" (even for opencode name)', () => {
|
|
// ensureHooksDist() in before() guarantees hooks/dist is built, so the
|
|
// existsSync guards inside applySettingsJsonHooks pass and commands are registered.
|
|
assert.ok(
|
|
settings.hooks && typeof settings.hooks === 'object',
|
|
`settings.hooks must be initialized when hooksSurface="settings-json"`,
|
|
);
|
|
assert.ok(
|
|
hasHooksFor(settings, 'SessionStart'),
|
|
`settings.hooks.SessionStart must contain at least one registered command when hooksSurface="settings-json"; ` +
|
|
`keys: ${JSON.stringify(Object.keys(settings.hooks))}`,
|
|
);
|
|
});
|
|
|
|
test('cleanup', () => {
|
|
cleanupDir(targetDir);
|
|
});
|
|
});
|