import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; type JsonObject = { [key: string]: Json }; const frameworkRoot = fileURLToPath(new URL('../../framework/', import.meta.url)); const basePath = `${frameworkRoot}runtime/claude/settings.json`; const overlayPath = `${frameworkRoot}runtime/claude/lease-overlay.json`; const gatedFixturePath = fileURLToPath( new URL('./fixtures/claude-settings.gated.pre-split.json', import.meta.url), ); function readJson(path: string): JsonObject { return JSON.parse(readFileSync(path, 'utf8')) as JsonObject; } function isObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } // Production composition uses universal last-layer-wins array replacement. The // lease overlay therefore carries complete affected event arrays, including the // two QA carry-forward entries needed to avoid dropping non-lease hooks. function deepMerge(base: Json, overlay: Json): Json { if (Array.isArray(base) && Array.isArray(overlay)) return overlay; if (isObject(base) && isObject(overlay)) { const merged: JsonObject = { ...base }; for (const [key, value] of Object.entries(overlay)) { merged[key] = key in merged ? deepMerge(merged[key]!, value) : value; } return merged; } return overlay; } function normalize(value: Json): Json { if (Array.isArray(value)) { return value .map(normalize) .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); } if (isObject(value)) { return Object.fromEntries( Object.entries(value) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, nested]) => [key, normalize(nested)]), ); } return value; } function hookCommands(settings: JsonObject): string[] { const hooks = settings['hooks']; if (!isObject(hooks)) return []; return Object.values(hooks).flatMap((event) => { if (!Array.isArray(event)) return []; return event.flatMap((entry) => { if (!isObject(entry) || !Array.isArray(entry['hooks'])) return []; return entry['hooks'].flatMap((hook) => isObject(hook) && typeof hook['command'] === 'string' ? [hook['command']] : [], ); }); }); } const sequentialThinking: JsonObject = { command: 'npx', args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], }; describe('canonical Claude base and lease-promotion overlay', () => { it('keeps every lease command out of the ungated base', () => { const base = readJson(basePath); const commands = hookCommands(base); for (const marker of ['mutator-gate', 'receipt-observer', 'promote-', 'revoke-lease']) { expect( commands.some((command) => command.includes(marker)), marker, ).toBe(false); } }); it('reconstructs the pre-split gated hooks while retaining the canonical MCP correction', () => { const base = readJson(basePath); const overlay = readJson(overlayPath); const preSplit = readJson(gatedFixturePath); const expected: JsonObject = { ...preSplit, hooks: { ...(preSplit['hooks'] as JsonObject), Stop: [ { hooks: [ { type: 'command', command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh', timeout: 15, }, ], }, { hooks: [ { type: 'command', command: 'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status', timeout: 15, }, ], }, ], }, mcpServers: { 'sequential-thinking': sequentialThinking }, }; expect(normalize(deepMerge(base, overlay))).toEqual(normalize(expected)); }); it('ships sequential-thinking in the base', () => { const base = readJson(basePath); expect(base['mcpServers']).toEqual({ 'sequential-thinking': sequentialThinking }); }); it('carries six lease commands plus exactly two deliberate QA carry-forward commands', () => { const overlay = readJson(overlayPath); expect(Object.keys(overlay)).toEqual(['hooks']); const commands = hookCommands(overlay); const lease = commands.filter((command) => /mutator-gate|receipt-observer|promote-|revoke-lease/.test(command), ); const qa = commands.filter((command) => /prevent-memory-write|reflect-stop/.test(command)); expect(lease).toHaveLength(6); expect(qa).toHaveLength(2); expect(commands).toHaveLength(8); }); it.each(['prevent-memory-write', 'reflect-stop'])( 'fails lossless reconstruction if QA carry-forward %s is removed', (marker) => { const base = readJson(basePath); const overlay = readJson(overlayPath); const expected = { ...readJson(gatedFixturePath), mcpServers: { 'sequential-thinking': sequentialThinking }, }; const hooks = overlay['hooks'] as JsonObject; const mutated: JsonObject = { hooks: Object.fromEntries( Object.entries(hooks).map(([event, entries]) => [ event, Array.isArray(entries) ? entries.filter((entry) => !JSON.stringify(entry).includes(marker)) : entries, ]), ), }; expect(normalize(deepMerge(base, mutated))).not.toEqual(normalize(expected)); }, ); });