/** * Recurrence gate for openspec/changes/e2e-suite-recovery §4: **a hook whose * framework call is refused fails, rather than continuing with an answer it * invented** (spec delta `specs/module-lifecycle/spec.md`, scenario "The * pattern cannot be reintroduced"). * * The rule itself lives in `./module-script-scan`, because this is not the only * place it runs — `.netapp` packaging applies the same scan at publish time. * One definition, two callers. * * The two sites this gate exists for (e2e-suite-recovery tasks 4.1/4.2, all * still in the tree on the swallow debt list; a third, knot-unbound-internal * scripts/on-install.ts:69, converted on 2026-09-07 by ce-pvii and deleted * from both debt lists in the same change): * modules/celilo-mgmt/scripts/on_backup.ts:52 catch { return [] } * modules/wireguard/scripts/health-check.ts:73 catch { return null } */ import { describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { join, resolve } from 'node:path'; import { SWALLOWED_REFUSAL_DEBT, SWALLOWED_REFUSAL_RULE, formatViolations, moduleScriptFiles, scanModuleDirectory, scanModuleScriptSource, } from './module-script-scan'; /** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */ function repoRoot(): string { let dir = import.meta.dir; for (let i = 0; i < 8; i++) { if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir; dir = resolve(dir, '..'); } throw new Error('could not locate repo root (no ancestor with modules/ + apps/)'); } function moduleDirs(): string[] { const modulesRoot = join(repoRoot(), 'modules'); return readdirSync(modulesRoot) .map((m) => join(modulesRoot, m)) .filter((d) => statSync(d).isDirectory() && existsSync(join(d, 'scripts'))); } /** Swallow-rule violations only — the other rules are different gates with their own tests. */ function swallowRule(violations: ReturnType) { return violations.filter((v) => v.rule === SWALLOWED_REFUSAL_RULE); } describe('recurrence gate: a hook does not swallow a refused framework call', () => { describe('the shapes it must reject', () => { test('catch returning a literal below a spawned framework call (on_backup shape)', () => { const source = [ 'function snapshot(): unknown[] {', ' try {', " const stdout = execSync('celilo machine list --json', { encoding: 'utf-8' });", ' return JSON.parse(stdout);', ' } catch {', ' // older celilo, or no machines yet', ' return [];', ' }', '}', ].join('\n'); const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source)); expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1); expect(hits[0].line).toBe(3); // the framework-reaching call, not the catch }); test('comment-only catch below an injected CLI-runner call (on_install shape)', () => { const source = [ 'function repoint(ip: string, runCelilo: (args: string[]) => string): void {', ' let prior: string | undefined;', ' try {', " prior = parse(runCelilo(['system', 'config', 'get', 'dns.primary']));", ' } catch {', ' // dns.primary not set yet — nothing to demote.', ' }', ' runCelilo(["system", "apply-config", `dns.primary=${ip}`]);', '}', ].join('\n'); const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source)); expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1); expect(hits[0].line).toBe(4); }); test('catch returning null below execFileSync (health-check shape)', () => { const source = [ 'function read(key: string): string | null {', ' try {', " const out = execFileSync('celilo', ['system', 'config', 'get', key], { encoding: 'utf-8' });", ' return parse(key, out);', ' } catch {', ' return null;', ' }', '}', ].join('\n'); const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source)); expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1); expect(hits[0].line).toBe(3); }); test('log-and-continue catch below a spawned framework call', () => { const source = [ 'try {', " execSync('celilo system apply-config dns.primary=10.0.0.1');", '} catch (err) {', ' logger.warn(`repoint failed: ${err}`);', '}', 'logger.success("dns.primary repointed");', ].join('\n'); const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source)); expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1); }); }); describe('the shapes it must accept', () => { test('a typed-absent reader: no catch between the hook and the answer', () => { const source = [ 'export async function healthCheck(deps: HealthDeps): Promise {', ' const { readConfig } = deps;', ' const value = readConfig("server_public_key");', ' if (!value.present) return { status: "absent", key: "server_public_key" };', ' return { status: "ok", key: value.data };', '}', ].join('\n'); expect(swallowRule(scanModuleScriptSource('scripts/x.ts', source))).toEqual([]); }); test('a catch that rethrows: the refusal propagates', () => { const source = [ 'try {', " execSync('celilo system apply-config dns.primary=10.0.0.1');", '} catch (err) {', ' throw new Error(`dns.primary repoint refused: ${String(err)}`);', '}', ].join('\n'); expect(swallowRule(scanModuleScriptSource('scripts/x.ts', source))).toEqual([]); }); test('a catch below work that never reaches the framework', () => { const source = [ 'function parse(raw: string): unknown[] {', ' try {', ' const parsed: unknown = JSON.parse(raw);', ' return Array.isArray(parsed) ? parsed : [];', ' } catch {', ' return [];', ' }', '}', ].join('\n'); expect(swallowRule(scanModuleScriptSource('scripts/x.ts', source))).toEqual([]); }); }); test('reach: the scan walks every scripts/ directory and flags every planted violation', () => { // Mirror of the real layout (modules//scripts/**), planted per the // reach rule: a violating hook in EVERY directory the scan should reach — // the module scripts root and a nested subdirectory — plus a clean module // the scan must come back empty on. const root = join(repoRoot(), '.tmp-swallow-reach-fixture'); rmSync(root, { recursive: true, force: true }); const violating = (name: string) => [ `// ${name}`, 'export function run() {', ' try {', " execSync('celilo system config get dns.primary');", ' } catch {', ' return null;', ' }', '}', ].join('\n'); try { const planted = [ 'modules/alpha/scripts/one.ts', 'modules/alpha/scripts/nested/two.ts', 'modules/beta/scripts/three.ts', ]; for (const p of planted) { const f = join(root, p); mkdirSync(resolve(f, '..'), { recursive: true }); writeFileSync(f, violating(p)); } const cleanDir = join(root, 'modules/clean/scripts'); mkdirSync(cleanDir, { recursive: true }); writeFileSync( join(cleanDir, 'clean.ts'), 'export function run(readConfig: (k: string) => { present: boolean }) {\n return readConfig("k");\n}\n', ); // Per-module expectation, in the module-relative paths the scan // reports: every planted file came back exactly once, and the clean // module contributed nothing. const flagged = (m: string) => new Set(swallowRule(scanModuleDirectory(join(root, 'modules', m))).map((v) => v.file)); expect([...flagged('alpha')].sort()).toEqual(['scripts/nested/two.ts', 'scripts/one.ts']); expect([...flagged('beta')]).toEqual(['scripts/three.ts']); expect([...flagged('clean')]).toEqual([]); } finally { rmSync(root, { recursive: true, force: true }); } }); test('today’s tree: no swallowed refusals anywhere, and the scan still reaches every module', () => { // Raw scan, deliberately NOT scanModuleDirectory: the debt list below // exempts the same files so the merged gate stays green while // hook-owned-state converts them, and this assertion has to outlive that // exemption to prove the rule still reaches them. const violations = moduleDirs().flatMap((d) => swallowRule( moduleScriptFiles(join(d, 'scripts')).flatMap((f) => scanModuleScriptSource(f, readFileSync(f, 'utf-8')), ), ), ); const sites = violations.map((v) => v.file.replace(`${repoRoot()}/`, '')).sort(); // Empty since ce-y0we (2026-09-09): on_backup.ts:52 was the last site — // its machine-pool read is staged input now — and the two before it // converted on 2026-09-07 (ce-pvii and 01575380). A NEW site appearing // here is the next instance this scan exists to stop. expect(sites, `Module script policy violations:\n${formatViolations(violations)}`).toEqual([]); }); }); describe('recurrence gate: the swallow debt list is honest', () => { // Same three ways this list could silently rot as the jailed-CLI-spawn debt // guard, same guard for each. test('every entry names an existing file carrying exactly its pinned swallow count', () => { for (const entry of SWALLOWED_REFUSAL_DEBT) { const f = join(repoRoot(), 'modules', entry.module, entry.file); expect( existsSync(f), `${entry.module}/${entry.file} no longer exists — delete its debt entry`, ).toBe(true); const hits = scanModuleScriptSource(entry.file, readFileSync(f, 'utf-8')).filter( (v) => v.rule === SWALLOWED_REFUSAL_RULE, ); expect( hits.length, `${entry.module}/${entry.file} carries ${hits.length} swallowed refusals, the debt entry pins ${entry.matches} (${entry.reason}) — convert or update the entry`, ).toBe(entry.matches); } }); test('the debt list is retired (empty), and this test pins that fact', () => { // The list held three entries when the gate landed; ce-pvii and 01575380 // converted two on 2026-09-07 and ce-y0we retired the last (on_backup's // machine-pool read) on 2026-09-09. Kept as an explicit pin so a future // entry has to be justified against this sentence: a swallowed refusal is // a defect to fix, never debt to track. expect(SWALLOWED_REFUSAL_DEBT).toEqual([]); }); });