feat(#1680): ADR-1239 Phase C-1 — hook-bus + stateIO seams [AC4] (#1805)

* feat(#1680): ADR-1239 Phase C-1 — hook-bus + stateIO seams [AC4]

Phase 3 slice 4 (AC4, final #1680 slice). The last two adapter seams behind
the negotiated hookBus/stateIO axes:

- src/hook-bus.cts: createHookBus({bus}, {hostEmit?}) -> host/engine/none.
  engine = in-process pub/sub (handler errors isolated); host = host-owned,
  fail-closed emit until a host emitter is bound; none = silent no-op (degrade
  to rule-text). PORTABLE_EVENT_FLOOR = SessionStart/PreToolUse/PostToolUse/
  Stop/SessionEnd (the claude dialect all hook hosts share).
- src/state-io.cts: createStateIO({io}, {backend?}) -> filesystem (today's
  behavior — straight fs) / sandboxed-storage / session-log-append (fail-closed
  seams until a host backend is bound).

Proactive CI gates: ADR-457 ignores + INVENTORY-MANIFEST entries for both new
.cjs; injection-scan 'act as' substring audit; unused-import check. All clean
locally (11 tests + security 15/15 + inventory + eslint 0 problems).

Phase 3 (#1680) seam layer now complete. Concrete host binding -> Phase 5
(#1682, D15/D18).

* chore(changeset): add Changed fragment for hook-bus + stateIO seams (#1680)
This commit is contained in:
Tom Boucher
2026-06-28 11:44:36 -04:00
committed by GitHub
parent b152f7e64c
commit abd21b2968
7 changed files with 298 additions and 0 deletions

57
tests/hook-bus.test.cjs Normal file
View File

@@ -0,0 +1,57 @@
'use strict';
/**
* Tests for the hook-bus seam (ADR-1239 Phase C-1, AC4 / #1680).
* Pins the three ownership modes + portable floor + fail-closed.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { createHookBus, PORTABLE_EVENT_FLOOR } = require('../gsd-core/bin/lib/hook-bus.cjs');
test('PORTABLE_EVENT_FLOOR: the 5 portable events (the claude dialect all hook hosts share)', () => {
assert.deepStrictEqual([...PORTABLE_EVENT_FLOOR], ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd']);
});
test('engine bus: in-process pub/sub (subscribe + emit reaches handlers)', () => {
const bus = createHookBus({ bus: 'engine' });
assert.strictEqual(bus.bus, 'engine');
const received = [];
bus.subscribe('PreToolUse', (p) => received.push(['PreToolUse', p]));
bus.subscribe('Stop', () => received.push(['Stop']));
bus.emit('PreToolUse', { tool: 'Edit' });
bus.emit('SessionStart');
bus.emit('Stop');
assert.deepStrictEqual(received, [['PreToolUse', { tool: 'Edit' }], ['Stop']]);
});
test('engine bus: a throwing handler is isolated (does not break the bus or other handlers)', () => {
const bus = createHookBus({ bus: 'engine' });
const seen = [];
bus.subscribe('PostToolUse', () => { throw new Error('boom'); });
bus.subscribe('PostToolUse', (p) => seen.push(p));
assert.doesNotThrow(() => bus.emit('PostToolUse', { ok: true }));
assert.deepStrictEqual(seen, [{ ok: true }]);
});
test('none bus: subscribe + emit are silent no-ops (degrade to rule-text)', () => {
const bus = createHookBus({ bus: 'none' });
assert.strictEqual(bus.bus, 'none');
assert.doesNotThrow(() => bus.subscribe('SessionStart', () => { throw new Error('must not be called'); }));
assert.doesNotThrow(() => bus.emit('SessionStart', {}));
});
test('host bus: emit delegates to the bound host emitter; fail-closed when unbound', () => {
const emitted = [];
const bound = createHookBus({ bus: 'host' }, { hostEmit: (e, p) => emitted.push([e, p]) });
assert.strictEqual(bound.bus, 'host');
bound.emit('Stop', { reason: 'done' });
assert.deepStrictEqual(emitted, [['Stop', { reason: 'done' }]]);
const unbound = createHookBus({ bus: 'host' });
assert.throws(() => unbound.emit('Stop'), /no host emitter bound/, 'unbound host emit must fail closed');
});
test('createHookBus: invalid mode throws (fail-closed construction)', () => {
for (const bad of ['cloud', '', null, undefined, 1]) {
assert.throws(() => createHookBus({ bus: bad }), TypeError, `bus=${JSON.stringify(bad)} must throw`);
}
});

59
tests/state-io.test.cjs Normal file
View File

@@ -0,0 +1,59 @@
'use strict';
/**
* Tests for the state IO seam (ADR-1239 Phase C-1, AC4 / #1680).
* Pins: filesystem (today's behavior) + fail-closed non-filesystem seams +
* host-backend binding + construction gating.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createStateIO } = require('../gsd-core/bin/lib/state-io.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
test('filesystem stateIO: read/write delegate to fs (today behavior)', () => {
const io = createStateIO({ io: 'filesystem' });
assert.strictEqual(io.io, 'filesystem');
const dir = createTempDir();
try {
const file = path.join(dir, 'STATE.md');
io.write(file, '# State\n');
assert.strictEqual(io.read(file), '# State\n');
assert.strictEqual(fs.readFileSync(file, 'utf-8'), '# State\n', 'must write through to real fs');
} finally {
cleanup(dir);
}
});
test('sandboxed-storage stateIO: fail-closed until a host backend is bound', () => {
const io = createStateIO({ io: 'sandboxed-storage' });
assert.strictEqual(io.io, 'sandboxed-storage');
assert.throws(() => io.read('/x'), /no host backend bound/, 'read must fail closed when unbound');
assert.throws(() => io.write('/x', 'y'), /no host backend bound/, 'write must fail closed when unbound');
});
test('session-log-append stateIO: fail-closed until a host backend is bound', () => {
const io = createStateIO({ io: 'session-log-append' });
assert.throws(() => io.read('/x'), /no host backend bound/);
});
test('non-filesystem stateIO: host backend is used when bound', () => {
const calls = [];
const io = createStateIO(
{ io: 'sandboxed-storage' },
{ backend: {
read: (p) => { calls.push(['read', p]); return 'BACKEND:' + p; },
write: (p, c) => { calls.push(['write', p, c]); },
} },
);
assert.strictEqual(io.read('/state/log'), 'BACKEND:/state/log');
io.write('/state/log', 'entry');
assert.deepStrictEqual(calls, [['read', '/state/log'], ['write', '/state/log', 'entry']]);
});
test('createStateIO: invalid io throws (fail-closed construction)', () => {
for (const bad of ['memory', '', null, undefined, 2]) {
assert.throws(() => createStateIO({ io: bad }), TypeError, `io=${JSON.stringify(bad)} must throw`);
}
});