@onlooker-community/ecosystem 0.43.2 → 0.43.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +1 -1
- package/.claude/skills/writing-tests/SKILL.md +18 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +19 -6
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +18 -3
- package/biome.json +7 -1
- package/docs/architecture.md +43 -1
- package/docs/superpowers/plans/2026-08-21-schema-emission-harness.md +1115 -0
- package/docs/superpowers/specs/2026-08-21-schema-emission-harness-design.md +255 -0
- package/package.json +6 -4
- package/scripts/hooks/prompt-rule-injector.sh +10 -2
- package/scripts/lib/onlooker-event.mjs +38 -1
- package/scripts/lib/prompt-rules.sh +43 -12
- package/scripts/lint/check-bus-coverage.mjs +153 -0
- package/scripts/lint/check-managed-blocks.mjs +206 -0
- package/test/bats/assayer-events.bats +1 -1
- package/test/bats/bursar-events.bats +1 -1
- package/test/bats/cartographer-events.bats +2 -2
- package/test/bats/echo-events.bats +1 -1
- package/test/bats/emission-report-optout.bats +41 -0
- package/test/bats/governor-events.bats +1 -1
- package/test/bats/inspector-events.bats +1 -1
- package/test/bats/librarian-session-end.bats +1 -1
- package/test/bats/lineage-events.bats +2 -2
- package/test/bats/prompt-rules.bats +61 -24
- package/test/bats/tribunal-events.bats +1 -1
- package/test/bats/warden-events.bats +1 -1
- package/test/bus-coverage.json +131 -0
- package/test/helpers/setup.bash +22 -0
- package/test/node/check-bus-coverage.test.mjs +160 -0
- package/test/node/check-managed-blocks.test.mjs +179 -0
- package/test/node/emission-report.test.mjs +97 -0
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
# Schema Emission Harness Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make a payload that drifts from the schema fail CI, instead of being
|
|
6
|
+
silently dropped by an emitter whose rejection the hook swallows.
|
|
7
|
+
|
|
8
|
+
**Architecture:** The emitter appends one line per emission to a suite-durable
|
|
9
|
+
report directory when `ONLOOKER_TEST_REPORT_DIR` is set. Two gates then read
|
|
10
|
+
that report after the suite: Gate A fails on any rejected emission, Gate B
|
|
11
|
+
fails when a registered event type appears in neither list of a committed
|
|
12
|
+
manifest. Nothing changes in production, which never sets the variable.
|
|
13
|
+
|
|
14
|
+
**Tech Stack:** Node 22 ESM, `node:test`, bats, `@onlooker-community/schema`
|
|
15
|
+
(devDependency), npm scripts.
|
|
16
|
+
|
|
17
|
+
**Spec:** `docs/superpowers/specs/2026-08-21-schema-emission-harness-design.md`
|
|
18
|
+
|
|
19
|
+
## Global Constraints
|
|
20
|
+
|
|
21
|
+
- The emitter has **zero runtime dependencies** and **fails open** (ADR-005).
|
|
22
|
+
Nothing in this plan may add an import that runs on the emit path, change an
|
|
23
|
+
exit code, or make emission depend on the report succeeding.
|
|
24
|
+
- Report writes happen **only** when `ONLOOKER_TEST_REPORT_DIR` is set.
|
|
25
|
+
- The `validate` subcommand must **not** write a report line. Only `emit` and
|
|
26
|
+
`emit-from-hook` do. A test that validates a fixture is not an emission, and
|
|
27
|
+
counting it would give Gate B false coverage.
|
|
28
|
+
- Runtime artifacts go under `${ONLOOKER_DIR:-$HOME/.onlooker}`; the report
|
|
29
|
+
directory is a **test** artifact and lives under `test/`, gitignored.
|
|
30
|
+
- American English in all comments, docs, and commit messages.
|
|
31
|
+
- Every commit goes through `/commit`. Never push to `main`; open a PR.
|
|
32
|
+
- Event types are `<plugin>.<noun>.<verb>`; ULIDs, not UUIDs, for IDs.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## File Structure
|
|
37
|
+
|
|
38
|
+
| File | Responsibility |
|
|
39
|
+
|------|----------------|
|
|
40
|
+
| `scripts/lib/onlooker-event.mjs` (modify) | Add `recordEmission()`; call it from `emit` and `emit-from-hook` only |
|
|
41
|
+
| `test/node/emission-report.test.mjs` (create) | Proves the emitter records correctly and stays silent when unset |
|
|
42
|
+
| `scripts/lint/check-bus-coverage.mjs` (create) | The gate runner — Gate A in Task 2, Gate B added in Task 4 |
|
|
43
|
+
| `test/node/check-bus-coverage.test.mjs` (create) | Fixture-driven tests for the gate runner itself |
|
|
44
|
+
| `test/helpers/setup.bash` (modify) | Add `expect_emission_rejected`, the opt-out for deliberate-rejection tests |
|
|
45
|
+
| `test/bats/emission-report-optout.bats` (create) | Proves the opt-out suppresses a rejection and restores the report dir |
|
|
46
|
+
| `test/bats/*-events.bats` (modify) | Convert negative emission call sites to the opt-out helper |
|
|
47
|
+
| `test/bus-coverage.json` (create) | The committed manifest: `expected` list + `excluded` map |
|
|
48
|
+
| `package.json` (modify) | Export the report dir; add `test:bus`; wire into `test:ci` |
|
|
49
|
+
| `.gitignore` (modify) | Ignore `test/tmp-emission-report/` |
|
|
50
|
+
| `docs/architecture.md`, `CLAUDE.md`, `.claude/skills/writing-tests/SKILL.md` (modify) | Document the gates |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Task 1: Emitter records every emission when the report dir is set
|
|
55
|
+
|
|
56
|
+
**Files:**
|
|
57
|
+
|
|
58
|
+
- Modify: `scripts/lib/onlooker-event.mjs:7` (import), after `tryValidate`
|
|
59
|
+
(~line 107), and both emit call sites (~line 512, ~line 540)
|
|
60
|
+
- Test: `test/node/emission-report.test.mjs`
|
|
61
|
+
|
|
62
|
+
**Interfaces:**
|
|
63
|
+
|
|
64
|
+
- Consumes: nothing from earlier tasks.
|
|
65
|
+
- Produces: a report file at `$ONLOOKER_TEST_REPORT_DIR/emissions.jsonl`. Each
|
|
66
|
+
line is `{ event_type: string|null, validated: boolean, valid: boolean|null,
|
|
67
|
+
errors?: object[] }`. `validated` is whether the schema package resolved;
|
|
68
|
+
`valid` is `null` when it did not. Task 2 and Task 5 read this shape.
|
|
69
|
+
|
|
70
|
+
- [ ] **Step 1: Write the failing test**
|
|
71
|
+
|
|
72
|
+
Create `test/node/emission-report.test.mjs`:
|
|
73
|
+
|
|
74
|
+
```javascript
|
|
75
|
+
import assert from 'node:assert/strict';
|
|
76
|
+
import { spawnSync } from 'node:child_process';
|
|
77
|
+
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
|
78
|
+
import { tmpdir } from 'node:os';
|
|
79
|
+
import { dirname, join, resolve } from 'node:path';
|
|
80
|
+
import { describe, it } from 'node:test';
|
|
81
|
+
import { fileURLToPath } from 'node:url';
|
|
82
|
+
|
|
83
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
84
|
+
const REPO_ROOT = resolve(HERE, '..', '..');
|
|
85
|
+
const EMITTER = join(REPO_ROOT, 'scripts', 'lib', 'onlooker-event.mjs');
|
|
86
|
+
|
|
87
|
+
// session.start requires only working_directory and forbids extra properties.
|
|
88
|
+
// Verified against node_modules/@onlooker-community/schema/schemas/payload/session.json
|
|
89
|
+
const VALID = {
|
|
90
|
+
plugin: 'onlooker',
|
|
91
|
+
session_id: '01JZZZZZZZZZZZZZZZZZZZZZZZ',
|
|
92
|
+
event_type: 'session.start',
|
|
93
|
+
payload: { working_directory: '/tmp/x' },
|
|
94
|
+
};
|
|
95
|
+
const INVALID = { ...VALID, payload: { working_directory: 42 } };
|
|
96
|
+
|
|
97
|
+
function emit(params, { reportDir } = {}) {
|
|
98
|
+
const env = {
|
|
99
|
+
...process.env,
|
|
100
|
+
ONLOOKER_DIR: mkdtempSync(join(tmpdir(), 'emit-onlooker-')),
|
|
101
|
+
};
|
|
102
|
+
if (reportDir) env.ONLOOKER_TEST_REPORT_DIR = reportDir;
|
|
103
|
+
else delete env.ONLOOKER_TEST_REPORT_DIR;
|
|
104
|
+
return spawnSync('node', [EMITTER, 'emit'], {
|
|
105
|
+
input: JSON.stringify(params),
|
|
106
|
+
encoding: 'utf8',
|
|
107
|
+
env,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readReport(dir) {
|
|
112
|
+
const p = join(dir, 'emissions.jsonl');
|
|
113
|
+
if (!existsSync(p)) return null;
|
|
114
|
+
return readFileSync(p, 'utf8')
|
|
115
|
+
.trim()
|
|
116
|
+
.split('\n')
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.map((l) => JSON.parse(l));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('emission report', () => {
|
|
122
|
+
it('records a valid emission as validated and valid', () => {
|
|
123
|
+
const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
|
|
124
|
+
const r = emit(VALID, { reportDir: dir });
|
|
125
|
+
assert.equal(r.status, 0, r.stderr);
|
|
126
|
+
const lines = readReport(dir);
|
|
127
|
+
assert.equal(lines.length, 1);
|
|
128
|
+
assert.equal(lines[0].event_type, 'session.start');
|
|
129
|
+
assert.equal(lines[0].validated, true);
|
|
130
|
+
assert.equal(lines[0].valid, true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('records a rejected emission as invalid, with its errors', () => {
|
|
134
|
+
const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
|
|
135
|
+
const r = emit(INVALID, { reportDir: dir });
|
|
136
|
+
assert.equal(r.status, 1);
|
|
137
|
+
const lines = readReport(dir);
|
|
138
|
+
assert.equal(lines.length, 1);
|
|
139
|
+
assert.equal(lines[0].validated, true);
|
|
140
|
+
assert.equal(lines[0].valid, false);
|
|
141
|
+
assert.ok(Array.isArray(lines[0].errors) && lines[0].errors.length > 0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('writes nothing when the report dir is unset', () => {
|
|
145
|
+
const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
|
|
146
|
+
emit(VALID, { reportDir: dir });
|
|
147
|
+
assert.equal(readReport(dir).length, 1);
|
|
148
|
+
// Same directory, but this emission is never told about it. The count must
|
|
149
|
+
// not move. Asserting on an untouched temp dir would pass either way.
|
|
150
|
+
const r = emit(VALID);
|
|
151
|
+
assert.equal(r.status, 0, r.stderr);
|
|
152
|
+
assert.equal(readReport(dir).length, 1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('appends across emissions rather than truncating', () => {
|
|
156
|
+
const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
|
|
157
|
+
emit(VALID, { reportDir: dir });
|
|
158
|
+
emit(VALID, { reportDir: dir });
|
|
159
|
+
assert.equal(readReport(dir).length, 2);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not record for the validate subcommand', () => {
|
|
163
|
+
const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
|
|
164
|
+
spawnSync('node', [EMITTER, 'validate'], {
|
|
165
|
+
input: JSON.stringify({ nonsense: true }),
|
|
166
|
+
encoding: 'utf8',
|
|
167
|
+
env: { ...process.env, ONLOOKER_TEST_REPORT_DIR: dir },
|
|
168
|
+
});
|
|
169
|
+
assert.equal(readReport(dir), null);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- [ ] **Step 2: Run the test to verify it fails**
|
|
175
|
+
|
|
176
|
+
Run: `node --test test/node/emission-report.test.mjs`
|
|
177
|
+
Expected: FAIL — every test that reads the report fails, because no report
|
|
178
|
+
file is written yet. `readReport` returns `null` and the `.length` accesses
|
|
179
|
+
throw. That is the correct failure: each test now depends on the report
|
|
180
|
+
actually existing.
|
|
181
|
+
|
|
182
|
+
- [ ] **Step 3: Add `appendFileSync` to the fs import**
|
|
183
|
+
|
|
184
|
+
In `scripts/lib/onlooker-event.mjs`, replace line 7:
|
|
185
|
+
|
|
186
|
+
```javascript
|
|
187
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
with:
|
|
191
|
+
|
|
192
|
+
```javascript
|
|
193
|
+
import {
|
|
194
|
+
appendFileSync,
|
|
195
|
+
existsSync,
|
|
196
|
+
mkdirSync,
|
|
197
|
+
readFileSync,
|
|
198
|
+
statSync,
|
|
199
|
+
writeFileSync,
|
|
200
|
+
} from 'node:fs';
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- [ ] **Step 4: Add `recordEmission` immediately after `tryValidate`**
|
|
204
|
+
|
|
205
|
+
Insert after the closing brace of `tryValidate` (~line 107):
|
|
206
|
+
|
|
207
|
+
```javascript
|
|
208
|
+
/**
|
|
209
|
+
* Test-only emission report.
|
|
210
|
+
*
|
|
211
|
+
* When ONLOOKER_TEST_REPORT_DIR is set, append one line per emission recording
|
|
212
|
+
* the event type, whether validation actually ran, and whether it passed.
|
|
213
|
+
*
|
|
214
|
+
* This exists because both signals the emitter produces on rejection — a
|
|
215
|
+
* non-zero exit and stderr — are destroyed by the hook's fail-soft exit 0,
|
|
216
|
+
* which leaves a dropped event indistinguishable from one that never fired.
|
|
217
|
+
* The report lives outside the per-test BATS_TEST_TMPDIR so the suite can gate
|
|
218
|
+
* on it after the fact.
|
|
219
|
+
*
|
|
220
|
+
* Production never sets the variable, so nothing is written there and the
|
|
221
|
+
* fail-open contract in ADR-005 is untouched. `validated` is recorded
|
|
222
|
+
* separately from `valid` so a run where the schema package never resolved is
|
|
223
|
+
* distinguishable from a run where everything passed — without it, a missing
|
|
224
|
+
* node_modules would make the gate pass while checking nothing.
|
|
225
|
+
*/
|
|
226
|
+
function recordEmission(event, check) {
|
|
227
|
+
const dir = process.env.ONLOOKER_TEST_REPORT_DIR;
|
|
228
|
+
if (!dir) return;
|
|
229
|
+
const record = {
|
|
230
|
+
event_type: event?.event_type ?? null,
|
|
231
|
+
validated: check.available === true,
|
|
232
|
+
valid: check.available === true ? check.valid === true : null,
|
|
233
|
+
};
|
|
234
|
+
if (check.available && !check.valid) record.errors = check.errors;
|
|
235
|
+
try {
|
|
236
|
+
mkdirSync(dir, { recursive: true });
|
|
237
|
+
appendFileSync(join(dir, 'emissions.jsonl'), `${JSON.stringify(record)}\n`);
|
|
238
|
+
} catch {
|
|
239
|
+
// A broken report must never break an emission.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
- [ ] **Step 5: Call it from both emit paths — and only those**
|
|
245
|
+
|
|
246
|
+
In the `emit-from-hook` block, after `const check = await tryValidate(mapped.event);`:
|
|
247
|
+
|
|
248
|
+
```javascript
|
|
249
|
+
const check = await tryValidate(mapped.event);
|
|
250
|
+
recordEmission(mapped.event, check);
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
In the `emit` block, after `const check = await tryValidate(event);`:
|
|
254
|
+
|
|
255
|
+
```javascript
|
|
256
|
+
const check = await tryValidate(event);
|
|
257
|
+
recordEmission(event, check);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Do **not** add a call in the `validate` block.
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 6: Run the test to verify it passes**
|
|
263
|
+
|
|
264
|
+
Run: `node --test test/node/emission-report.test.mjs`
|
|
265
|
+
Expected: PASS, 5 tests.
|
|
266
|
+
|
|
267
|
+
- [ ] **Step 7: Confirm production behavior is unchanged**
|
|
268
|
+
|
|
269
|
+
Run: `npm run test:schema && npm run test:bats`
|
|
270
|
+
Expected: PASS. No test sets `ONLOOKER_TEST_REPORT_DIR` yet, so this proves the
|
|
271
|
+
change is inert when the variable is absent.
|
|
272
|
+
|
|
273
|
+
- [ ] **Step 8: Format and commit**
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
./node_modules/.bin/biome check --write scripts/lib/onlooker-event.mjs test/node/emission-report.test.mjs
|
|
277
|
+
git add scripts/lib/onlooker-event.mjs test/node/emission-report.test.mjs
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Then run `/commit` with: emitter records each emission to a test-only report so
|
|
281
|
+
a rejection outlives the hook's fail-soft exit.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Task 2: Gate A — fail on a rejected emission, or on validation never running
|
|
286
|
+
|
|
287
|
+
**Files:**
|
|
288
|
+
|
|
289
|
+
- Create: `scripts/lint/check-bus-coverage.mjs`
|
|
290
|
+
- Test: `test/node/check-bus-coverage.test.mjs`
|
|
291
|
+
|
|
292
|
+
**Interfaces:**
|
|
293
|
+
|
|
294
|
+
- Consumes: the report line shape from Task 1.
|
|
295
|
+
- Produces: a CLI `check-bus-coverage.mjs [--report <dir>]` exiting 0 on pass,
|
|
296
|
+
1 on gate failure, 2 on bad arguments. Task 5 extends it with `--manifest`.
|
|
297
|
+
|
|
298
|
+
- [ ] **Step 1: Write the failing test**
|
|
299
|
+
|
|
300
|
+
Create `test/node/check-bus-coverage.test.mjs`:
|
|
301
|
+
|
|
302
|
+
```javascript
|
|
303
|
+
import assert from 'node:assert/strict';
|
|
304
|
+
import { spawnSync } from 'node:child_process';
|
|
305
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
306
|
+
import { tmpdir } from 'node:os';
|
|
307
|
+
import { dirname, join, resolve } from 'node:path';
|
|
308
|
+
import { describe, it } from 'node:test';
|
|
309
|
+
import { fileURLToPath } from 'node:url';
|
|
310
|
+
|
|
311
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
312
|
+
const REPO_ROOT = resolve(HERE, '..', '..');
|
|
313
|
+
const GATE = join(REPO_ROOT, 'scripts', 'lint', 'check-bus-coverage.mjs');
|
|
314
|
+
|
|
315
|
+
function reportDir(lines) {
|
|
316
|
+
const dir = mkdtempSync(join(tmpdir(), 'bus-report-'));
|
|
317
|
+
mkdirSync(dir, { recursive: true });
|
|
318
|
+
writeFileSync(
|
|
319
|
+
join(dir, 'emissions.jsonl'),
|
|
320
|
+
lines.map((l) => JSON.stringify(l)).join('\n') + (lines.length ? '\n' : ''),
|
|
321
|
+
);
|
|
322
|
+
return dir;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function run(dir) {
|
|
326
|
+
const r = spawnSync('node', [GATE, '--report', dir], { encoding: 'utf8' });
|
|
327
|
+
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const OK = { event_type: 'session.start', validated: true, valid: true };
|
|
331
|
+
|
|
332
|
+
describe('check-bus-coverage gate A', () => {
|
|
333
|
+
it('passes when every emission validated', () => {
|
|
334
|
+
const r = run(reportDir([OK, OK]));
|
|
335
|
+
assert.equal(r.code, 0, r.stderr);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('fails on a rejected emission and names the type', () => {
|
|
339
|
+
const bad = {
|
|
340
|
+
event_type: 'librarian.scan.complete',
|
|
341
|
+
validated: true,
|
|
342
|
+
valid: false,
|
|
343
|
+
errors: [{ path: '/outcome', message: 'must be equal to one of the allowed values' }],
|
|
344
|
+
};
|
|
345
|
+
const r = run(reportDir([OK, bad]));
|
|
346
|
+
assert.equal(r.code, 1);
|
|
347
|
+
assert.match(r.stderr, /librarian\.scan\.complete/);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('fails when validation never ran, rather than passing vacuously', () => {
|
|
351
|
+
const unvalidated = { event_type: 'session.start', validated: false, valid: null };
|
|
352
|
+
const r = run(reportDir([unvalidated, unvalidated]));
|
|
353
|
+
assert.equal(r.code, 1);
|
|
354
|
+
assert.match(r.stderr, /did not resolve|never validated|no emission was validated/i);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('fails when the report is missing entirely', () => {
|
|
358
|
+
const r = run(mkdtempSync(join(tmpdir(), 'bus-empty-')));
|
|
359
|
+
assert.equal(r.code, 1);
|
|
360
|
+
assert.match(r.stderr, /no emissions recorded/i);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
- [ ] **Step 2: Run the test to verify it fails**
|
|
366
|
+
|
|
367
|
+
Run: `node --test test/node/check-bus-coverage.test.mjs`
|
|
368
|
+
Expected: FAIL — `Cannot find module .../check-bus-coverage.mjs`.
|
|
369
|
+
|
|
370
|
+
- [ ] **Step 3: Write the gate runner**
|
|
371
|
+
|
|
372
|
+
Create `scripts/lint/check-bus-coverage.mjs`:
|
|
373
|
+
|
|
374
|
+
```javascript
|
|
375
|
+
#!/usr/bin/env node
|
|
376
|
+
/**
|
|
377
|
+
* Bus coverage gates.
|
|
378
|
+
*
|
|
379
|
+
* Gate A: no emission recorded during the suite was rejected by the schema.
|
|
380
|
+
*
|
|
381
|
+
* Reads the report the emitter writes when ONLOOKER_TEST_REPORT_DIR is set —
|
|
382
|
+
* see recordEmission in scripts/lib/onlooker-event.mjs. A rejected emission is
|
|
383
|
+
* invisible any other way: the emitter exits 1 and prints ajv errors, and the
|
|
384
|
+
* hook's fail-soft exit 0 destroys both.
|
|
385
|
+
*
|
|
386
|
+
* Usage: check-bus-coverage.mjs [--report <dir>]
|
|
387
|
+
*/
|
|
388
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
389
|
+
import { dirname, join, resolve } from 'node:path';
|
|
390
|
+
import { fileURLToPath } from 'node:url';
|
|
391
|
+
|
|
392
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
393
|
+
const REPO_ROOT = resolve(HERE, '..', '..');
|
|
394
|
+
|
|
395
|
+
function parseArgs(argv) {
|
|
396
|
+
const out = { report: join(REPO_ROOT, 'test', 'tmp-emission-report') };
|
|
397
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
398
|
+
const a = argv[i];
|
|
399
|
+
if (a === '--report') out.report = argv[++i];
|
|
400
|
+
else if (a === '--help') {
|
|
401
|
+
process.stderr.write('Usage: check-bus-coverage.mjs [--report <dir>]\n');
|
|
402
|
+
process.exit(0);
|
|
403
|
+
} else {
|
|
404
|
+
process.stderr.write(`check-bus-coverage: unknown argument: ${a}\n`);
|
|
405
|
+
process.exit(2);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function loadReport(dir) {
|
|
412
|
+
const p = join(dir, 'emissions.jsonl');
|
|
413
|
+
if (!existsSync(p)) return [];
|
|
414
|
+
return readFileSync(p, 'utf8')
|
|
415
|
+
.trim()
|
|
416
|
+
.split('\n')
|
|
417
|
+
.filter(Boolean)
|
|
418
|
+
.map((l) => JSON.parse(l));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function gateA(lines) {
|
|
422
|
+
const failures = [];
|
|
423
|
+
if (lines.length === 0) {
|
|
424
|
+
failures.push(
|
|
425
|
+
'no emissions recorded — run `npm run test:bats` with ONLOOKER_TEST_REPORT_DIR set',
|
|
426
|
+
);
|
|
427
|
+
return failures;
|
|
428
|
+
}
|
|
429
|
+
if (!lines.some((l) => l.validated === true)) {
|
|
430
|
+
failures.push(
|
|
431
|
+
'no emission was validated: @onlooker-community/schema did not resolve, so this gate ' +
|
|
432
|
+
'checked nothing. Run `npm ci` and try again.',
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
for (const l of lines.filter((x) => x.valid === false)) {
|
|
436
|
+
failures.push(`rejected emission: ${l.event_type} — ${JSON.stringify(l.errors)}`);
|
|
437
|
+
}
|
|
438
|
+
return failures;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function main() {
|
|
442
|
+
const args = parseArgs(process.argv);
|
|
443
|
+
const lines = loadReport(args.report);
|
|
444
|
+
const failures = gateA(lines);
|
|
445
|
+
if (failures.length) {
|
|
446
|
+
for (const f of failures) process.stderr.write(`check-bus-coverage: ${f}\n`);
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
process.stdout.write(`check-bus-coverage: ok (${lines.length} emission(s))\n`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const isMain = process.argv[1]?.endsWith('check-bus-coverage.mjs') ?? false;
|
|
453
|
+
if (isMain) main();
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
- [ ] **Step 4: Run the test to verify it passes**
|
|
457
|
+
|
|
458
|
+
Run: `node --test test/node/check-bus-coverage.test.mjs`
|
|
459
|
+
Expected: PASS, 4 tests.
|
|
460
|
+
|
|
461
|
+
- [ ] **Step 5: Format and commit**
|
|
462
|
+
|
|
463
|
+
```bash
|
|
464
|
+
./node_modules/.bin/biome check --write scripts/lint/check-bus-coverage.mjs test/node/check-bus-coverage.test.mjs
|
|
465
|
+
git add scripts/lint/check-bus-coverage.mjs test/node/check-bus-coverage.test.mjs
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Then run `/commit` with: add Gate A, which fails on a rejected emission and on a
|
|
469
|
+
run where validation never happened at all.
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
## Task 3: Keep deliberate rejections out of the report
|
|
474
|
+
|
|
475
|
+
**Files:**
|
|
476
|
+
|
|
477
|
+
- Modify: `test/helpers/setup.bash` (add `expect_emission_rejected`)
|
|
478
|
+
- Create: `test/bats/emission-report-optout.bats`
|
|
479
|
+
- Modify: every bats file with a negative emission assertion — discovered
|
|
480
|
+
empirically in Step 3, not from a fixed list
|
|
481
|
+
|
|
482
|
+
**Interfaces:**
|
|
483
|
+
|
|
484
|
+
- Consumes: the report shape from Task 1; the gate CLI from Task 2.
|
|
485
|
+
- Produces: `expect_emission_rejected <command> [args...]`, a bats helper that
|
|
486
|
+
runs a command expected to fail validation with `ONLOOKER_TEST_REPORT_DIR`
|
|
487
|
+
unset, then restores it. Sets `$status` and `$output` exactly as `run` does.
|
|
488
|
+
Task 4 depends on this existing, or the first wired CI run is red.
|
|
489
|
+
|
|
490
|
+
**Why this task exists:** ADR-005 names the negative tests — "emission fails
|
|
491
|
+
loudly on a bogus event_type" — and the repo has roughly ten. Each deliberately
|
|
492
|
+
emits an invalid event and asserts the emitter rejects it. Without an opt-out
|
|
493
|
+
every one writes a `valid:false` line and Gate A is permanently red from
|
|
494
|
+
intentional tests.
|
|
495
|
+
|
|
496
|
+
Two flavors, and the second is why filtering by registered type does not work:
|
|
497
|
+
|
|
498
|
+
- **Unregistered types**, e.g. `warden.bogus.event`, `bursar.no_such_event`.
|
|
499
|
+
- **Registered types with deliberately bad payloads** —
|
|
500
|
+
`cartographer-events.bats` "the retired pre-implementation vocabulary no
|
|
501
|
+
longer validates" and "a typeless finding puts nothing on the bus" (both
|
|
502
|
+
`cartographer.issue.found`), and `lineage-events.bats` "an invalid tool enum
|
|
503
|
+
is rejected by the schema" (`lineage.change.recorded`). These are
|
|
504
|
+
indistinguishable from real drift, which is exactly what Gate A must catch.
|
|
505
|
+
|
|
506
|
+
Note that "returns 1 when payload is empty" tests need no conversion: the bash
|
|
507
|
+
wrapper returns 1 before reaching the emitter, so nothing is ever recorded.
|
|
508
|
+
|
|
509
|
+
- [ ] **Step 1: Write the failing test**
|
|
510
|
+
|
|
511
|
+
Create `test/bats/emission-report-optout.bats`:
|
|
512
|
+
|
|
513
|
+
```bash
|
|
514
|
+
#!/usr/bin/env bats
|
|
515
|
+
|
|
516
|
+
setup() {
|
|
517
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
518
|
+
setup_test_env
|
|
519
|
+
|
|
520
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/warden"
|
|
521
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
522
|
+
export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
|
|
523
|
+
source "${PLUGIN_ROOT}/scripts/lib/warden-events.sh"
|
|
524
|
+
|
|
525
|
+
export ONLOOKER_TEST_REPORT_DIR="${BATS_TEST_TMPDIR}/report"
|
|
526
|
+
mkdir -p "$ONLOOKER_TEST_REPORT_DIR"
|
|
527
|
+
REPORT="${ONLOOKER_TEST_REPORT_DIR}/emissions.jsonl"
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
_valid_payload() {
|
|
531
|
+
jq -cn '{source_type:"web_fetch", threat_type:"prompt_injection", confidence:0.5}'
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
# Positive control. Without this, the opt-out test below could pass because
|
|
535
|
+
# nothing writes a report at all, rather than because the helper suppressed it.
|
|
536
|
+
@test "a normal emission does write a report line" {
|
|
537
|
+
run warden_emit_event "warden.threat.detected" "$(_valid_payload)"
|
|
538
|
+
[ "$status" -eq 0 ] || return 1
|
|
539
|
+
[ -s "$REPORT" ]
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
@test "expect_emission_rejected keeps a deliberate rejection out of the report" {
|
|
543
|
+
expect_emission_rejected warden_emit_event "warden.bogus.event" "$(_valid_payload)"
|
|
544
|
+
[ "$status" -ne 0 ] || return 1
|
|
545
|
+
[ ! -f "$REPORT" ]
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
@test "expect_emission_rejected restores the report dir afterward" {
|
|
549
|
+
expect_emission_rejected warden_emit_event "warden.bogus.event" "$(_valid_payload)"
|
|
550
|
+
[ "$ONLOOKER_TEST_REPORT_DIR" = "${BATS_TEST_TMPDIR}/report" ] || return 1
|
|
551
|
+
run warden_emit_event "warden.threat.detected" "$(_valid_payload)"
|
|
552
|
+
[ "$status" -eq 0 ] || return 1
|
|
553
|
+
[ -s "$REPORT" ]
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
- [ ] **Step 2: Run it to verify it fails**
|
|
558
|
+
|
|
559
|
+
Run: `bats test/bats/emission-report-optout.bats`
|
|
560
|
+
Expected: FAIL — `expect_emission_rejected: command not found`. The positive
|
|
561
|
+
control should already pass; if it does not, Task 1 is broken and you should
|
|
562
|
+
stop and report that rather than continuing.
|
|
563
|
+
|
|
564
|
+
- [ ] **Step 3: Add the helper to `test/helpers/setup.bash`**
|
|
565
|
+
|
|
566
|
+
Append after `load_validate_path`:
|
|
567
|
+
|
|
568
|
+
```bash
|
|
569
|
+
# Run a command that is expected to fail schema validation, without recording
|
|
570
|
+
# the deliberate rejection in the suite-wide emission report.
|
|
571
|
+
#
|
|
572
|
+
# The report exists so a payload that drifts from the schema turns CI red. A
|
|
573
|
+
# test that deliberately emits an invalid payload would otherwise write a
|
|
574
|
+
# valid:false line indistinguishable from real drift, making the gate
|
|
575
|
+
# permanently red from intentional tests. Unsetting the report directory for
|
|
576
|
+
# the duration keeps the negative test honest — it still asserts the emitter
|
|
577
|
+
# rejects — without polluting the gate.
|
|
578
|
+
#
|
|
579
|
+
# Sets $status and $output exactly as bats' `run` does.
|
|
580
|
+
#
|
|
581
|
+
# Usage: expect_emission_rejected <command> [args...]
|
|
582
|
+
expect_emission_rejected() {
|
|
583
|
+
local saved="${ONLOOKER_TEST_REPORT_DIR:-}"
|
|
584
|
+
unset ONLOOKER_TEST_REPORT_DIR
|
|
585
|
+
run "$@"
|
|
586
|
+
if [ -n "$saved" ]; then
|
|
587
|
+
export ONLOOKER_TEST_REPORT_DIR="$saved"
|
|
588
|
+
fi
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
- [ ] **Step 4: Run the test to verify it passes**
|
|
593
|
+
|
|
594
|
+
Run: `bats test/bats/emission-report-optout.bats`
|
|
595
|
+
Expected: PASS, 3 tests.
|
|
596
|
+
|
|
597
|
+
- [ ] **Step 5: Discover every call site that needs converting**
|
|
598
|
+
|
|
599
|
+
Do not work from a hand-written list — find them empirically, so the set is
|
|
600
|
+
complete by construction:
|
|
601
|
+
|
|
602
|
+
```bash
|
|
603
|
+
rm -rf /tmp/optout-report
|
|
604
|
+
ONLOOKER_TEST_REPORT_DIR=/tmp/optout-report bats test/bats
|
|
605
|
+
node -e '
|
|
606
|
+
const { readFileSync } = require("node:fs");
|
|
607
|
+
const lines = readFileSync("/tmp/optout-report/emissions.jsonl", "utf8")
|
|
608
|
+
.trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
609
|
+
const bad = lines.filter((l) => l.valid === false);
|
|
610
|
+
console.log("rejected emissions recorded:", bad.length);
|
|
611
|
+
for (const t of [...new Set(bad.map((l) => l.event_type))].sort()) console.log(" ", t);
|
|
612
|
+
'
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Every type printed corresponds to at least one negative test. Locate each with
|
|
616
|
+
`grep -rn '<type>' test/bats/`.
|
|
617
|
+
|
|
618
|
+
- [ ] **Step 6: Convert each negative call site**
|
|
619
|
+
|
|
620
|
+
For each, replace the `run` with `expect_emission_rejected` and leave the
|
|
621
|
+
assertions untouched. For example, in `test/bats/warden-events.bats`:
|
|
622
|
+
|
|
623
|
+
```bash
|
|
624
|
+
run warden_emit_event "warden.bogus.event" "$p"
|
|
625
|
+
[ "$status" -ne 0 ]
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
becomes:
|
|
629
|
+
|
|
630
|
+
```bash
|
|
631
|
+
expect_emission_rejected warden_emit_event "warden.bogus.event" "$p"
|
|
632
|
+
[ "$status" -ne 0 ]
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
Multi-line invocations keep their continuations — only the leading `run` word
|
|
636
|
+
changes. Do **not** convert the "returns 1 when payload is empty" tests; they
|
|
637
|
+
never reach the emitter.
|
|
638
|
+
|
|
639
|
+
- [ ] **Step 7: Verify no rejection is recorded any more**
|
|
640
|
+
|
|
641
|
+
Re-run the discovery command from Step 5.
|
|
642
|
+
Expected: `rejected emissions recorded: 0`.
|
|
643
|
+
|
|
644
|
+
If any remain, convert those call sites too and repeat until the count is zero.
|
|
645
|
+
|
|
646
|
+
- [ ] **Step 8: Confirm the negative tests still fail for the right reason**
|
|
647
|
+
|
|
648
|
+
The conversion must not have neutered them. Pick
|
|
649
|
+
`test/bats/cartographer-events.bats` "a typeless finding puts nothing on the
|
|
650
|
+
bus", temporarily change its expected status from `-ne 0` to `-eq 0`, and
|
|
651
|
+
confirm the test now FAILS. Restore it afterward.
|
|
652
|
+
|
|
653
|
+
This is the check that matters: a helper that silently swallowed the failure
|
|
654
|
+
would leave the test passing either way, which is worse than no test.
|
|
655
|
+
|
|
656
|
+
- [ ] **Step 9: Run the full suite**
|
|
657
|
+
|
|
658
|
+
Run: `npm run test:bats && npm run test:schema`
|
|
659
|
+
Expected: PASS, same test count as before your changes plus the 3 new ones.
|
|
660
|
+
|
|
661
|
+
- [ ] **Step 10: Commit**
|
|
662
|
+
|
|
663
|
+
```bash
|
|
664
|
+
git add test/helpers/setup.bash test/bats/emission-report-optout.bats test/bats/
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
Then run `/commit` with: keep deliberate rejections out of the emission report
|
|
668
|
+
so the gate stays red only for real drift.
|
|
669
|
+
|
|
670
|
+
---
|
|
671
|
+
|
|
672
|
+
## Task 4: Wire the report and the gate into the test scripts
|
|
673
|
+
|
|
674
|
+
**Files:**
|
|
675
|
+
|
|
676
|
+
- Modify: `package.json` (`test:bats`, `test:schema`, new `test:bus`, `test:ci`)
|
|
677
|
+
- Modify: `.gitignore`
|
|
678
|
+
|
|
679
|
+
**Interfaces:**
|
|
680
|
+
|
|
681
|
+
- Consumes: the gate CLI from Task 2, and `expect_emission_rejected` from
|
|
682
|
+
Task 3 — without it this task's first green run is impossible, because the
|
|
683
|
+
negative tests would fill the report with deliberate rejections.
|
|
684
|
+
- Produces: `test/tmp-emission-report/emissions.jsonl` populated by a real suite
|
|
685
|
+
run. Task 5 reads it to bootstrap the manifest.
|
|
686
|
+
|
|
687
|
+
- [ ] **Step 1: Ignore the report directory**
|
|
688
|
+
|
|
689
|
+
Add to `.gitignore` beneath the existing `test/tmp-*` entries:
|
|
690
|
+
|
|
691
|
+
```gitignore
|
|
692
|
+
test/tmp-emission-report/
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
- [ ] **Step 2: Update the test scripts**
|
|
696
|
+
|
|
697
|
+
In `package.json`, replace `test:bats` and `test:schema` and add `test:bus`:
|
|
698
|
+
|
|
699
|
+
```json
|
|
700
|
+
"test:bats": "rm -rf \"$PWD/test/tmp-emission-report\" && ONLOOKER_TEST_REPORT_DIR=\"$PWD/test/tmp-emission-report\" bats test/bats",
|
|
701
|
+
"test:schema": "ONLOOKER_TEST_REPORT_DIR=\"$PWD/test/tmp-emission-report\" node --test test/node/*.test.mjs",
|
|
702
|
+
"test:bus": "node scripts/lint/check-bus-coverage.mjs",
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
`$PWD` is absolute; a relative path would break because hooks run the emitter
|
|
706
|
+
from arbitrary working directories. `test:bats` clears the report so a stale
|
|
707
|
+
run cannot mask a regression; `test:schema` appends to it.
|
|
708
|
+
|
|
709
|
+
- [ ] **Step 3: Sequence the gate after both suites in `test:ci`**
|
|
710
|
+
|
|
711
|
+
Replace `test:ci` with:
|
|
712
|
+
|
|
713
|
+
```json
|
|
714
|
+
"test:ci": "npm run test:shellcheck && npm run test:bats && npm run test:schema && npm run test:bus && npm run lint:check && npm run lint:manifests && npm run lint:references && npm run lint:lesson-schema",
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
`test:bus` runs after both suites so emissions from each are counted. It is
|
|
718
|
+
deliberately not folded into `test:schema`: a node test that skipped when the
|
|
719
|
+
report was absent would pass vacuously for anyone running `test:schema` alone,
|
|
720
|
+
which is the exact failure mode under repair.
|
|
721
|
+
|
|
722
|
+
- [ ] **Step 4: Verify the report is actually produced**
|
|
723
|
+
|
|
724
|
+
Run: `npm run test:bats && npm run test:schema`
|
|
725
|
+
Then: `wc -l test/tmp-emission-report/emissions.jsonl`
|
|
726
|
+
Expected: a non-zero line count. If the file is missing, the environment
|
|
727
|
+
variable is not reaching the emitter — check that `$PWD` expanded.
|
|
728
|
+
|
|
729
|
+
- [ ] **Step 5: Verify Gate A passes on the real suite**
|
|
730
|
+
|
|
731
|
+
Run: `npm run test:bus`
|
|
732
|
+
Expected: `check-bus-coverage: ok (N emission(s))`, exit 0.
|
|
733
|
+
|
|
734
|
+
If it reports a rejected emission, that is a real pre-existing bug — likely
|
|
735
|
+
`ecosystem-ci0`. Stop and report it rather than working around the gate.
|
|
736
|
+
|
|
737
|
+
- [ ] **Step 6: Break it on purpose**
|
|
738
|
+
|
|
739
|
+
Temporarily append a rejected line and confirm the gate goes red:
|
|
740
|
+
|
|
741
|
+
```bash
|
|
742
|
+
echo '{"event_type":"fake.bad.event","validated":true,"valid":false,"errors":[{"path":"/x","message":"nope"}]}' \
|
|
743
|
+
>> test/tmp-emission-report/emissions.jsonl
|
|
744
|
+
npm run test:bus; echo "EXIT=$?"
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
Expected: EXIT=1, stderr naming `fake.bad.event`. Then re-run
|
|
748
|
+
`npm run test:bats && npm run test:schema` to restore a clean report.
|
|
749
|
+
|
|
750
|
+
- [ ] **Step 7: Commit**
|
|
751
|
+
|
|
752
|
+
```bash
|
|
753
|
+
git add package.json .gitignore
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
Then run `/commit` with: produce the emission report during the suite and gate
|
|
757
|
+
on it in CI.
|
|
758
|
+
|
|
759
|
+
---
|
|
760
|
+
|
|
761
|
+
## Task 5: The manifest and Gate B
|
|
762
|
+
|
|
763
|
+
**Files:**
|
|
764
|
+
|
|
765
|
+
- Create: `test/bus-coverage.json`
|
|
766
|
+
- Modify: `scripts/lint/check-bus-coverage.mjs`
|
|
767
|
+
- Modify: `test/node/check-bus-coverage.test.mjs`
|
|
768
|
+
|
|
769
|
+
**Interfaces:**
|
|
770
|
+
|
|
771
|
+
- Consumes: the populated report from Task 4; `ALL_EVENT_TYPES` from
|
|
772
|
+
`@onlooker-community/schema` (125 entries at time of writing).
|
|
773
|
+
- Produces: `test/bus-coverage.json` with `{ expected: string[], excluded:
|
|
774
|
+
Record<string, string> }`, and a `--manifest <path>` flag on the gate CLI.
|
|
775
|
+
|
|
776
|
+
- [ ] **Step 1: Generate the manifest skeleton from a real run**
|
|
777
|
+
|
|
778
|
+
With a clean report present from Task 4, run:
|
|
779
|
+
|
|
780
|
+
```bash
|
|
781
|
+
node -e "
|
|
782
|
+
import('@onlooker-community/schema').then(async (m) => {
|
|
783
|
+
const { readFileSync } = await import('node:fs');
|
|
784
|
+
const lines = readFileSync('test/tmp-emission-report/emissions.jsonl', 'utf8')
|
|
785
|
+
.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
786
|
+
const seen = new Set(lines.filter((l) => l.valid === true).map((l) => l.event_type));
|
|
787
|
+
const expected = m.ALL_EVENT_TYPES.filter((t) => seen.has(t)).sort();
|
|
788
|
+
const excluded = {};
|
|
789
|
+
for (const t of m.ALL_EVENT_TYPES.filter((t) => !seen.has(t)).sort()) excluded[t] = 'FILL IN';
|
|
790
|
+
console.log(JSON.stringify({ expected, excluded }, null, 2));
|
|
791
|
+
});" > test/bus-coverage.json
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
- [ ] **Step 2: Replace every `FILL IN` with a real reason**
|
|
795
|
+
|
|
796
|
+
Open `test/bus-coverage.json` and write a specific reason for each exclusion.
|
|
797
|
+
Use these exact reasons for the known groups — they were established during
|
|
798
|
+
design:
|
|
799
|
+
|
|
800
|
+
| Prefix | Reason string |
|
|
801
|
+
|--------|---------------|
|
|
802
|
+
| `meridian.*`, `sentinel.*`, `oracle.*`, `relay.*` | `plugin lives in another repo` |
|
|
803
|
+
| `onlooker.session.summary` | `emitted by the agent, not this repo` |
|
|
804
|
+
| `curator.*` | `curator check deferred; see plugins/curator/README.md` |
|
|
805
|
+
| `compass.*` | `compass has no implementation yet; design phase` |
|
|
806
|
+
| anything else | one line saying which branch would emit it and why no test reaches that branch yet |
|
|
807
|
+
|
|
808
|
+
No reason may remain `FILL IN`; Step 5's test enforces that.
|
|
809
|
+
|
|
810
|
+
- [ ] **Step 3: Write the failing Gate B tests**
|
|
811
|
+
|
|
812
|
+
Append to `test/node/check-bus-coverage.test.mjs`:
|
|
813
|
+
|
|
814
|
+
```javascript
|
|
815
|
+
import { ALL_EVENT_TYPES } from '@onlooker-community/schema';
|
|
816
|
+
|
|
817
|
+
function manifestFile(manifest) {
|
|
818
|
+
const dir = mkdtempSync(join(tmpdir(), 'bus-manifest-'));
|
|
819
|
+
const p = join(dir, 'bus-coverage.json');
|
|
820
|
+
writeFileSync(p, JSON.stringify(manifest, null, 2));
|
|
821
|
+
return p;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function runWith(dir, manifestPath) {
|
|
825
|
+
const r = spawnSync('node', [GATE, '--report', dir, '--manifest', manifestPath], {
|
|
826
|
+
encoding: 'utf8',
|
|
827
|
+
});
|
|
828
|
+
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// A manifest that accounts for all 125 types, expecting only session.start.
|
|
832
|
+
function fullManifest(expected = ['session.start']) {
|
|
833
|
+
const excluded = {};
|
|
834
|
+
for (const t of ALL_EVENT_TYPES) {
|
|
835
|
+
if (!expected.includes(t)) excluded[t] = 'not emitted in tests';
|
|
836
|
+
}
|
|
837
|
+
return { expected, excluded };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
describe('check-bus-coverage gate B', () => {
|
|
841
|
+
it('passes when every expected type has a validated emission', () => {
|
|
842
|
+
const r = runWith(reportDir([OK]), manifestFile(fullManifest()));
|
|
843
|
+
assert.equal(r.code, 0, r.stderr);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('fails when an expected type never appeared', () => {
|
|
847
|
+
const m = fullManifest(['session.start', 'session.end']);
|
|
848
|
+
const r = runWith(reportDir([OK]), manifestFile(m));
|
|
849
|
+
assert.equal(r.code, 1);
|
|
850
|
+
assert.match(r.stderr, /session\.end/);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('fails when a registered type is in neither list', () => {
|
|
854
|
+
const m = fullManifest();
|
|
855
|
+
delete m.excluded[ALL_EVENT_TYPES.find((t) => t !== 'session.start')];
|
|
856
|
+
const r = runWith(reportDir([OK]), manifestFile(m));
|
|
857
|
+
assert.equal(r.code, 1);
|
|
858
|
+
assert.match(r.stderr, /accounted for|neither/i);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('fails when the manifest names a type the schema does not register', () => {
|
|
862
|
+
const m = fullManifest();
|
|
863
|
+
m.excluded['not.a.real.type'] = 'bogus';
|
|
864
|
+
const r = runWith(reportDir([OK]), manifestFile(m));
|
|
865
|
+
assert.equal(r.code, 1);
|
|
866
|
+
assert.match(r.stderr, /not\.a\.real\.type/);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('fails when an exclusion has an empty reason', () => {
|
|
870
|
+
const m = fullManifest();
|
|
871
|
+
m.excluded[Object.keys(m.excluded)[0]] = '';
|
|
872
|
+
const r = runWith(reportDir([OK]), manifestFile(m));
|
|
873
|
+
assert.equal(r.code, 1);
|
|
874
|
+
assert.match(r.stderr, /reason/i);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('the committed manifest accounts for every registered type', () => {
|
|
878
|
+
const committed = JSON.parse(
|
|
879
|
+
readFileSync(join(REPO_ROOT, 'test', 'bus-coverage.json'), 'utf8'),
|
|
880
|
+
);
|
|
881
|
+
const accounted = new Set([...committed.expected, ...Object.keys(committed.excluded)]);
|
|
882
|
+
const missing = ALL_EVENT_TYPES.filter((t) => !accounted.has(t));
|
|
883
|
+
assert.deepEqual(missing, [], `unaccounted event types: ${missing.join(', ')}`);
|
|
884
|
+
for (const [type, reason] of Object.entries(committed.excluded)) {
|
|
885
|
+
assert.ok(reason && reason.trim() && reason !== 'FILL IN', `${type} needs a real reason`);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
Add `readFileSync` to the `node:fs` import at the top of the file.
|
|
892
|
+
|
|
893
|
+
- [ ] **Step 4: Run the tests to verify they fail**
|
|
894
|
+
|
|
895
|
+
Run: `node --test test/node/check-bus-coverage.test.mjs`
|
|
896
|
+
Expected: FAIL — the gate ignores `--manifest` and exits 2 on an unknown
|
|
897
|
+
argument.
|
|
898
|
+
|
|
899
|
+
- [ ] **Step 5: Implement Gate B**
|
|
900
|
+
|
|
901
|
+
In `scripts/lint/check-bus-coverage.mjs`, add `--manifest` to `parseArgs`:
|
|
902
|
+
|
|
903
|
+
```javascript
|
|
904
|
+
const out = {
|
|
905
|
+
report: join(REPO_ROOT, 'test', 'tmp-emission-report'),
|
|
906
|
+
manifest: join(REPO_ROOT, 'test', 'bus-coverage.json'),
|
|
907
|
+
};
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
and inside the loop, before the `--help` branch:
|
|
911
|
+
|
|
912
|
+
```javascript
|
|
913
|
+
else if (a === '--manifest') out.manifest = argv[++i];
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
Add the gate itself:
|
|
917
|
+
|
|
918
|
+
```javascript
|
|
919
|
+
/**
|
|
920
|
+
* Gate B: every registered event type is accounted for.
|
|
921
|
+
*
|
|
922
|
+
* `expected` types must have produced a validated emission during the suite.
|
|
923
|
+
* `excluded` types must carry a reason. Together the two lists must equal
|
|
924
|
+
* ALL_EVENT_TYPES exactly, so a newly registered type belongs to neither and
|
|
925
|
+
* fails here until someone triages it deliberately.
|
|
926
|
+
*/
|
|
927
|
+
async function gateB(lines, manifestPath) {
|
|
928
|
+
const failures = [];
|
|
929
|
+
let schema;
|
|
930
|
+
try {
|
|
931
|
+
schema = await import('@onlooker-community/schema');
|
|
932
|
+
} catch {
|
|
933
|
+
return ['@onlooker-community/schema is not installed; run `npm ci`'];
|
|
934
|
+
}
|
|
935
|
+
const registered = new Set(schema.ALL_EVENT_TYPES);
|
|
936
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
937
|
+
const expected = manifest.expected ?? [];
|
|
938
|
+
const excluded = manifest.excluded ?? {};
|
|
939
|
+
|
|
940
|
+
const emitted = new Set(lines.filter((l) => l.valid === true).map((l) => l.event_type));
|
|
941
|
+
for (const t of expected) {
|
|
942
|
+
if (!emitted.has(t)) failures.push(`expected type never emitted during the suite: ${t}`);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const accounted = new Set([...expected, ...Object.keys(excluded)]);
|
|
946
|
+
for (const t of registered) {
|
|
947
|
+
if (!accounted.has(t)) {
|
|
948
|
+
failures.push(`registered type is in neither list — triage it in the manifest: ${t}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
for (const t of accounted) {
|
|
952
|
+
if (!registered.has(t)) {
|
|
953
|
+
failures.push(`manifest names a type the schema does not register: ${t}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
for (const [t, reason] of Object.entries(excluded)) {
|
|
957
|
+
if (!reason || !String(reason).trim()) {
|
|
958
|
+
failures.push(`excluded type needs a reason: ${t}`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return failures;
|
|
962
|
+
}
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
Replace the existing `main` function and the `isMain` block at the bottom of
|
|
966
|
+
the file — do not append a second copy:
|
|
967
|
+
|
|
968
|
+
```javascript
|
|
969
|
+
async function main() {
|
|
970
|
+
const args = parseArgs(process.argv);
|
|
971
|
+
const lines = loadReport(args.report);
|
|
972
|
+
const failures = gateA(lines);
|
|
973
|
+
// Skip Gate B when nothing was recorded. Every expected type would report
|
|
974
|
+
// as missing, burying the single failure that actually matters.
|
|
975
|
+
if (lines.length > 0) failures.push(...(await gateB(lines, args.manifest)));
|
|
976
|
+
if (failures.length) {
|
|
977
|
+
for (const f of failures) process.stderr.write(`check-bus-coverage: ${f}\n`);
|
|
978
|
+
process.exit(1);
|
|
979
|
+
}
|
|
980
|
+
process.stdout.write(`check-bus-coverage: ok (${lines.length} emission(s))\n`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const isMain = process.argv[1]?.endsWith('check-bus-coverage.mjs') ?? false;
|
|
984
|
+
if (isMain) {
|
|
985
|
+
main().catch((err) => {
|
|
986
|
+
process.stderr.write(`check-bus-coverage: ${err.message}\n`);
|
|
987
|
+
process.exit(1);
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
- [ ] **Step 6: Run the tests to verify they pass**
|
|
993
|
+
|
|
994
|
+
Run: `node --test test/node/check-bus-coverage.test.mjs`
|
|
995
|
+
Expected: PASS, 10 tests.
|
|
996
|
+
|
|
997
|
+
- [ ] **Step 7: Run the whole gate against the real suite**
|
|
998
|
+
|
|
999
|
+
Run: `npm run test:ci`
|
|
1000
|
+
Expected: PASS end to end, including `test:bus`.
|
|
1001
|
+
|
|
1002
|
+
- [ ] **Step 8: Break it on purpose**
|
|
1003
|
+
|
|
1004
|
+
Move one type from `expected` into `excluded` in `test/bus-coverage.json`, run
|
|
1005
|
+
`npm run test:bus`, and confirm it fails naming that type — the manifest must
|
|
1006
|
+
not be able to silently under-claim. Restore the file afterward.
|
|
1007
|
+
|
|
1008
|
+
- [ ] **Step 9: Format and commit**
|
|
1009
|
+
|
|
1010
|
+
```bash
|
|
1011
|
+
./node_modules/.bin/biome check --write scripts/lint/check-bus-coverage.mjs test/node/check-bus-coverage.test.mjs test/bus-coverage.json
|
|
1012
|
+
git add test/bus-coverage.json scripts/lint/check-bus-coverage.mjs test/node/check-bus-coverage.test.mjs
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
Then run `/commit` with: require every registered event type to be either
|
|
1016
|
+
covered by a test or excluded with a stated reason.
|
|
1017
|
+
|
|
1018
|
+
---
|
|
1019
|
+
|
|
1020
|
+
## Task 6: Documentation
|
|
1021
|
+
|
|
1022
|
+
**Files:**
|
|
1023
|
+
|
|
1024
|
+
- Modify: `docs/architecture.md`
|
|
1025
|
+
- Modify: `CLAUDE.md`
|
|
1026
|
+
- Modify: `.claude/skills/writing-tests/SKILL.md`
|
|
1027
|
+
|
|
1028
|
+
**Interfaces:**
|
|
1029
|
+
|
|
1030
|
+
- Consumes: the finished harness from Tasks 1–5. Produces no code.
|
|
1031
|
+
|
|
1032
|
+
- [ ] **Step 1: Document the gates in `docs/architecture.md`**
|
|
1033
|
+
|
|
1034
|
+
Add a subsection to the event-bus discussion:
|
|
1035
|
+
|
|
1036
|
+
```markdown
|
|
1037
|
+
### Emission gates
|
|
1038
|
+
|
|
1039
|
+
Payload drift used to be invisible. The emitter validates against
|
|
1040
|
+
`@onlooker-community/schema` wherever it resolves and rejects a bad event with
|
|
1041
|
+
a non-zero exit, but hooks fail soft and exit 0, so the rejection was destroyed
|
|
1042
|
+
and the event simply never appeared.
|
|
1043
|
+
|
|
1044
|
+
Two CI gates close that hole. During the test suite `ONLOOKER_TEST_REPORT_DIR`
|
|
1045
|
+
is set, and the emitter appends one line per emission to
|
|
1046
|
+
`emissions.jsonl` recording whether validation ran and whether it passed.
|
|
1047
|
+
`npm run test:bus` then fails if any emission was rejected, if validation never
|
|
1048
|
+
ran at all, or if a registered event type appears in neither list of
|
|
1049
|
+
`test/bus-coverage.json`.
|
|
1050
|
+
|
|
1051
|
+
Adding an event type therefore requires triaging it into that manifest — as
|
|
1052
|
+
`expected`, meaning a test exercises the branch that emits it, or as
|
|
1053
|
+
`excluded` with a stated reason.
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
- [ ] **Step 2: Add the step to the plugin checklist in `CLAUDE.md`**
|
|
1057
|
+
|
|
1058
|
+
In the "Adding a new plugin" numbered list, after the step about registering
|
|
1059
|
+
event types in `@onlooker-community/schema`, add:
|
|
1060
|
+
|
|
1061
|
+
```markdown
|
|
1062
|
+
6. Triage every new event type into `test/bus-coverage.json` — `expected` when
|
|
1063
|
+
a test drives the branch that emits it, `excluded` with a reason when not.
|
|
1064
|
+
`npm run test:bus` fails on any registered type that appears in neither list.
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
Renumber the steps that follow.
|
|
1068
|
+
|
|
1069
|
+
- [ ] **Step 3: Note the suite-wide guarantee in the testing skill**
|
|
1070
|
+
|
|
1071
|
+
In `.claude/skills/writing-tests/SKILL.md`, under "Assert against the event
|
|
1072
|
+
log", add:
|
|
1073
|
+
|
|
1074
|
+
```markdown
|
|
1075
|
+
You no longer need a bespoke per-plugin test proving a payload validates. The
|
|
1076
|
+
suite gates every emission at once: `ONLOOKER_TEST_REPORT_DIR` is set during
|
|
1077
|
+
`test:bats` and `test:schema`, and `npm run test:bus` fails on any rejected
|
|
1078
|
+
emission. Write the test that drives the branch; the gate does the validating.
|
|
1079
|
+
|
|
1080
|
+
What still matters is exercising the *rare* branches. A payload is only checked
|
|
1081
|
+
when some test makes the code emit it, so an enum bug on an error path stays
|
|
1082
|
+
invisible until a test reaches that path.
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
- [ ] **Step 4: Lint the docs**
|
|
1086
|
+
|
|
1087
|
+
Run: `npm run lint:check`
|
|
1088
|
+
Expected: PASS.
|
|
1089
|
+
|
|
1090
|
+
- [ ] **Step 5: Commit**
|
|
1091
|
+
|
|
1092
|
+
```bash
|
|
1093
|
+
git add docs/architecture.md CLAUDE.md .claude/skills/writing-tests/SKILL.md
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
Then run `/commit` with: document the emission gates and the manifest step for
|
|
1097
|
+
new plugins.
|
|
1098
|
+
|
|
1099
|
+
---
|
|
1100
|
+
|
|
1101
|
+
## Acceptance
|
|
1102
|
+
|
|
1103
|
+
Verified by running `npm run test:ci` from a clean tree:
|
|
1104
|
+
|
|
1105
|
+
- [ ] `test:bus` runs after `test:bats` and `test:schema`, and fails when the
|
|
1106
|
+
report is missing.
|
|
1107
|
+
- [ ] Gate A fails on a rejected emission, naming the type and its ajv errors.
|
|
1108
|
+
- [ ] Gate A fails when no emission was validated, rather than passing green.
|
|
1109
|
+
- [ ] Gate B fails when a registered type is in neither manifest list.
|
|
1110
|
+
- [ ] Gate B fails when an exclusion carries an empty reason.
|
|
1111
|
+
- [ ] Emitting with `ONLOOKER_TEST_REPORT_DIR` unset writes no report and
|
|
1112
|
+
leaves exit codes unchanged.
|
|
1113
|
+
- [ ] `plugins/cartographer`'s `finding_type:"unknown"` payload
|
|
1114
|
+
(`ecosystem-ci0`) is caught by Gate A once a test exercises that branch,
|
|
1115
|
+
with no bespoke test written for it.
|