@onlooker-community/ecosystem 0.43.2 → 0.43.3
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 +7 -0
- package/CLAUDE.md +18 -3
- package/biome.json +7 -1
- package/docs/architecture.md +41 -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/lib/onlooker-event.mjs +38 -1
- 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/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,255 @@
|
|
|
1
|
+
# Repo-Wide Schema Emission Harness — Design
|
|
2
|
+
|
|
3
|
+
**Status:** Approved, not started.
|
|
4
|
+
**Tracked by:** `ecosystem-u0t`.
|
|
5
|
+
**Single repository.** Everything lands under `scripts/lib/`, `test/`, and
|
|
6
|
+
`package.json`.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## What this is
|
|
11
|
+
|
|
12
|
+
Three bugs now share one root cause. A hook built a payload that diverged from
|
|
13
|
+
what the published schema admits; the emitter rejected it; the hook swallowed
|
|
14
|
+
the rejection and exited 0; the on-disk artifacts kept working. Nothing looked
|
|
15
|
+
broken.
|
|
16
|
+
|
|
17
|
+
- `ecosystem-q4d` (P1, closed) — both cartographer payloads off-contract.
|
|
18
|
+
- `ecosystem-1p1` (P2, closed) — librarian `scan.complete` emitted
|
|
19
|
+
`outcome:budget_exceeded`, which the enum rejects.
|
|
20
|
+
- `ecosystem-ci0` (P3, open) — cartographer `finding_type:"unknown"`, admitted
|
|
21
|
+
by no schema.
|
|
22
|
+
|
|
23
|
+
Each fix added its own bats test driving a real payload through
|
|
24
|
+
`scripts/lib/onlooker-event.mjs`. That guarantee stops at the plugin being
|
|
25
|
+
repaired. Nothing generalizes it, so the next divergence ships the same way:
|
|
26
|
+
silently, with green tests and working artifacts. Every instance so far was
|
|
27
|
+
found by hand, long after it landed, and each cost a two-repo release cycle.
|
|
28
|
+
|
|
29
|
+
This builds one harness that makes the class detectable.
|
|
30
|
+
|
|
31
|
+
## Why the obvious seam is the wrong one
|
|
32
|
+
|
|
33
|
+
`ecosystem-u0t` proposes enumerating payload builders per plugin, noting that
|
|
34
|
+
the `q4d` fix extracted cartographer's builders out of `run_emit` "precisely so
|
|
35
|
+
tests and production share one construction — that pattern is the likely seam,
|
|
36
|
+
but not every plugin has been refactored that way yet."
|
|
37
|
+
|
|
38
|
+
The seam does not exist. Counting named `*_payload()` builders across every
|
|
39
|
+
`plugins/*/scripts/lib/*-events.sh`:
|
|
40
|
+
|
|
41
|
+
| Plugin | Builders |
|
|
42
|
+
|--------|----------|
|
|
43
|
+
| cartographer | 3 |
|
|
44
|
+
| every other events lib (12) | 0 |
|
|
45
|
+
|
|
46
|
+
Three plugins — curator, historian, librarian — have no events lib at all.
|
|
47
|
+
Everywhere except cartographer, the payload is assembled inline at the call
|
|
48
|
+
site and handed to `<plugin>_emit_event` as a finished JSON string.
|
|
49
|
+
|
|
50
|
+
Choosing that seam means refactoring fifteen plugins before writing a single
|
|
51
|
+
assertion, and the refactor would be driven by a test rather than by need.
|
|
52
|
+
Rejected.
|
|
53
|
+
|
|
54
|
+
## The failure signature
|
|
55
|
+
|
|
56
|
+
The rejection is not a bad line on the bus. It is the **absence** of a line.
|
|
57
|
+
|
|
58
|
+
Probing the emitter directly with a payload that violates
|
|
59
|
+
`governor.gate.checked`:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
EMITTER EXIT=1
|
|
63
|
+
stderr: [ { "path": "/decision",
|
|
64
|
+
"message": "must be equal to one of the allowed values" }, ... ]
|
|
65
|
+
bus: NO LOG FILE — event was dropped
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Per [ADR-005](../../adr/005-runtime-emitter-fails-open.md), validation is
|
|
69
|
+
attempted through a lazy `await import('@onlooker-community/schema')`. Where
|
|
70
|
+
the package resolves — dev, CI, tests — the emitter validates and **rejects**.
|
|
71
|
+
Where it does not, it emits anyway. So in CI an invalid payload never reaches
|
|
72
|
+
the log, and sweeping the log for invalid lines would find nothing, ever.
|
|
73
|
+
|
|
74
|
+
Hooks fail soft and exit 0 by design, which discards the emitter's exit 1 and
|
|
75
|
+
its stderr. That is the whole bug: the only two signals the emitter produces
|
|
76
|
+
are both destroyed before anyone can see them. `ecosystem-1p1` established the
|
|
77
|
+
usable property in the other direction — presence on the bus proves the payload
|
|
78
|
+
validated.
|
|
79
|
+
|
|
80
|
+
The harness therefore has to capture the rejection at the point it happens, in
|
|
81
|
+
a place that outlives the hook.
|
|
82
|
+
|
|
83
|
+
## Architecture
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
107 bats files -> real hooks -> real branches
|
|
87
|
+
|
|
|
88
|
+
scripts/lib/onlooker-event.mjs
|
|
89
|
+
|
|
|
90
|
+
tryValidate(event)
|
|
91
|
+
+---------------+---------------+
|
|
92
|
+
valid rejected
|
|
93
|
+
| |
|
|
94
|
+
bus (per-test, dropped, exit 1,
|
|
95
|
+
ephemeral) stderr swallowed
|
|
96
|
+
+---------------+---------------+
|
|
97
|
+
|
|
|
98
|
+
$ONLOOKER_TEST_REPORT_DIR/emissions.jsonl <- durable
|
|
99
|
+
{ event_type, validated, valid, errors? }
|
|
100
|
+
|
|
|
101
|
+
Gate A Gate B
|
|
102
|
+
no valid:false line manifest accounts for 125
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Why a durable report directory
|
|
106
|
+
|
|
107
|
+
`test/helpers/setup.bash:15` points `ONLOOKER_DIR` at
|
|
108
|
+
`${BATS_TEST_TMPDIR}/home/.onlooker`. Bats deletes that tree after **each
|
|
109
|
+
test**, so a sidecar written under `$ONLOOKER_DIR` dies with the test that
|
|
110
|
+
produced it and neither gate could read it afterward.
|
|
111
|
+
|
|
112
|
+
A shared bats `teardown` was considered and rejected. Teardown is per-file: a
|
|
113
|
+
definition in the shared helper would clobber the one bats file that already
|
|
114
|
+
defines its own, and any future file defining a teardown would silently opt out
|
|
115
|
+
of the gate — the same silent-opt-out failure mode that produced `u0t`.
|
|
116
|
+
|
|
117
|
+
Instead the suite exports `ONLOOKER_TEST_REPORT_DIR` once, outside
|
|
118
|
+
`BATS_TEST_TMPDIR`. One mechanism then feeds both gates, with no per-test
|
|
119
|
+
opt-in to forget.
|
|
120
|
+
|
|
121
|
+
## The emitter change
|
|
122
|
+
|
|
123
|
+
In `scripts/lib/onlooker-event.mjs`, after `tryValidate`: when
|
|
124
|
+
`ONLOOKER_TEST_REPORT_DIR` is set, append one JSON line recording the event
|
|
125
|
+
type, whether validation actually ran, whether it passed, and any ajv errors.
|
|
126
|
+
|
|
127
|
+
Constraints:
|
|
128
|
+
|
|
129
|
+
- Guarded entirely on the environment variable. Production never sets it, so
|
|
130
|
+
production behavior, exit codes, and the fail-open contract are untouched.
|
|
131
|
+
- One `appendFileSync` of one line, so the record is atomic under POSIX
|
|
132
|
+
`O_APPEND` for the line sizes involved.
|
|
133
|
+
- Records **`validated`** separately from **`valid`**. This is not redundant;
|
|
134
|
+
see below.
|
|
135
|
+
|
|
136
|
+
Roughly twelve lines. It is the only production file this design touches.
|
|
137
|
+
|
|
138
|
+
## Gate A — no rejected emission
|
|
139
|
+
|
|
140
|
+
Fails if any line in `emissions.jsonl` has `valid:false`, reporting the event
|
|
141
|
+
type and the ajv errors that caused it.
|
|
142
|
+
|
|
143
|
+
It additionally asserts that **at least one line recorded `validated:true`**.
|
|
144
|
+
Without that assertion the gate is a trap. If `node_modules` is ever absent,
|
|
145
|
+
`tryValidate` fails open, nothing validates, no line is ever `valid:false`, and
|
|
146
|
+
Gate A passes green while checking nothing at all — reproducing the exact bug it
|
|
147
|
+
exists to catch, one level up. Recording whether validation ran is what makes
|
|
148
|
+
that state detectable rather than indistinguishable from success.
|
|
149
|
+
|
|
150
|
+
## Gate B — every registered type accounted for
|
|
151
|
+
|
|
152
|
+
A committed manifest, `test/bus-coverage.json`:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"expected": ["session.start", "governor.gate.checked", "..."],
|
|
157
|
+
"excluded": {
|
|
158
|
+
"meridian.hint.generated": "meridian plugin lives in another repo",
|
|
159
|
+
"curator.finding.contradiction": "LLM contradiction sweep deferred",
|
|
160
|
+
"compass.check.overridden": "compass has no implementation yet"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Gate B asserts three things:
|
|
166
|
+
|
|
167
|
+
1. Every type in `expected` appears in `emissions.jsonl` with `valid:true`.
|
|
168
|
+
2. `expected` and `excluded` together equal `ALL_EVENT_TYPES` exactly — no
|
|
169
|
+
gaps, no extras.
|
|
170
|
+
3. Every exclusion carries a non-empty reason.
|
|
171
|
+
|
|
172
|
+
Assertion 2 is the anti-drift property. A newly registered schema type belongs
|
|
173
|
+
to neither list, so CI fails until someone triages it deliberately. This is the
|
|
174
|
+
same shape as the `test:shellcheck` fix in #190: derive coverage from the
|
|
175
|
+
authoritative set rather than from a hand-maintained list that rots.
|
|
176
|
+
|
|
177
|
+
All three assertions are hard from the first commit: the manifest is authored
|
|
178
|
+
during implementation precisely to make them true. What ratchets is the
|
|
179
|
+
manifest itself, as types move from `excluded` to `expected` when the features
|
|
180
|
+
that emit them land.
|
|
181
|
+
|
|
182
|
+
## Where the gates run
|
|
183
|
+
|
|
184
|
+
The gates become their own npm script, `test:bus`, sequenced after `test:bats`
|
|
185
|
+
in `test:ci`. It **fails when the report is missing**.
|
|
186
|
+
|
|
187
|
+
They deliberately do not live inside `test:schema`. A node test that skips when
|
|
188
|
+
the report is absent would pass vacuously for anyone running `test:schema`
|
|
189
|
+
standalone — silent skipping being, again, the failure mode under repair.
|
|
190
|
+
|
|
191
|
+
`test:bats` clears and exports the report directory; the path is gitignored.
|
|
192
|
+
|
|
193
|
+
## Bootstrapping the manifest
|
|
194
|
+
|
|
195
|
+
Of 125 registered types, 75 are referenced in this repo's source and 50 are
|
|
196
|
+
not. The 50 split three ways, and each group takes a different disposition:
|
|
197
|
+
|
|
198
|
+
| Group | Count | Disposition |
|
|
199
|
+
|-------|-------|-------------|
|
|
200
|
+
| Foreign plugins — meridian 6, sentinel 3, oracle 2, relay 2 | 13 | `excluded`, "plugin lives in another repo" |
|
|
201
|
+
| In-repo but unemitted — curator 11, tribunal 6, archivist 5, governor 4, historian 3, librarian 2, compass 2, and three singles | 36 | `excluded`, with the specific reason |
|
|
202
|
+
| `onlooker.session.summary` | 1 | `excluded`, emitted by the agent, not this repo |
|
|
203
|
+
|
|
204
|
+
Curator's eleven are deferred by design; compass has no implementation at all
|
|
205
|
+
and is still in its design phase. Those move to `expected` as the features land,
|
|
206
|
+
which is the ratchet working as intended.
|
|
207
|
+
|
|
208
|
+
Note that source reference is not emission. The initial `expected` list is
|
|
209
|
+
seeded from what the first instrumented run actually observes, not from the 75
|
|
210
|
+
grep hits — a type named only in a comment or a doc would otherwise be demanded
|
|
211
|
+
to have an example it can never produce.
|
|
212
|
+
|
|
213
|
+
## Testing the harness itself
|
|
214
|
+
|
|
215
|
+
Three checks, each broken on purpose before being trusted:
|
|
216
|
+
|
|
217
|
+
1. Feed a known-bad payload through the emitter with the report directory set;
|
|
218
|
+
confirm a `valid:false` line appears and Gate A goes red.
|
|
219
|
+
2. Remove a type from `expected` that the suite does emit; confirm assertion 2
|
|
220
|
+
fails on the resulting gap.
|
|
221
|
+
3. Simulate the schema package being unresolvable; confirm Gate A fails on the
|
|
222
|
+
absence of any `validated:true` line rather than passing green.
|
|
223
|
+
|
|
224
|
+
Check 3 is the one that matters most, because it is the only one that tests the
|
|
225
|
+
harness against its own failure mode.
|
|
226
|
+
|
|
227
|
+
## Out of scope
|
|
228
|
+
|
|
229
|
+
- **Refactoring fifteen plugins to extract payload builders.** The integration
|
|
230
|
+
seam makes it unnecessary.
|
|
231
|
+
- **Forcing coverage of the 36 unemitted in-repo types now.** They are
|
|
232
|
+
excluded with reasons and promoted as features land.
|
|
233
|
+
- **Parallel bats.** The suite runs serially. Running `bats -j` would need
|
|
234
|
+
append-atomicity revisited; not addressed here.
|
|
235
|
+
- **The `-S error` shellcheck severity question.** Unrelated, separately filed.
|
|
236
|
+
|
|
237
|
+
## Acceptance
|
|
238
|
+
|
|
239
|
+
- `npm run test:ci` runs `test:bus` after `test:bats` and fails when the report
|
|
240
|
+
is missing.
|
|
241
|
+
- Gate A fails on a rejected emission, naming the type and the ajv errors.
|
|
242
|
+
- Gate A fails when validation never ran, rather than passing vacuously.
|
|
243
|
+
- Gate B fails when a registered type appears in neither manifest list.
|
|
244
|
+
- `ecosystem-ci0`'s `finding_type:"unknown"` payload is caught by Gate A once
|
|
245
|
+
a test exercises that branch, without a bespoke test being written for it.
|
|
246
|
+
- Production emission behavior is unchanged: no new dependency, no exit-code
|
|
247
|
+
change, and nothing written unless `ONLOOKER_TEST_REPORT_DIR` is set.
|
|
248
|
+
|
|
249
|
+
## Documentation to update
|
|
250
|
+
|
|
251
|
+
- `docs/architecture.md` — how the bus gates fit the event pipeline.
|
|
252
|
+
- `CLAUDE.md` — the "Adding a new plugin" checklist gains a step: triage any
|
|
253
|
+
new event type into `test/bus-coverage.json`.
|
|
254
|
+
- `.claude/skills/writing-tests/SKILL.md` — note that emissions are gated
|
|
255
|
+
suite-wide, so new plugins need no bespoke schema test.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlooker-community/ecosystem",
|
|
3
|
-
"version": "0.43.
|
|
3
|
+
"version": "0.43.3",
|
|
4
4
|
"description": "Agents, skills, hooks, commands, rules, and MCP configurations that power [Onlooker](https://onlooker.dev)",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -20,16 +20,18 @@
|
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "npm run test:bats && npm run test:schema",
|
|
23
|
-
"test:bats": "bats test/bats",
|
|
24
|
-
"test:schema": "node --test test/node/*.test.mjs",
|
|
23
|
+
"test:bats": "rm -rf \"$PWD/test/tmp-emission-report\" && ONLOOKER_TEST_REPORT_DIR=\"$PWD/test/tmp-emission-report\" bats test/bats",
|
|
24
|
+
"test:schema": "ONLOOKER_TEST_REPORT_DIR=\"$PWD/test/tmp-emission-report\" node --test test/node/*.test.mjs",
|
|
25
|
+
"test:bus": "node scripts/lint/check-bus-coverage.mjs",
|
|
25
26
|
"test:shellcheck": "git ls-files -z '*.sh' | xargs -0 shellcheck -S error -x",
|
|
26
27
|
"lint:references": "node scripts/lint/check-references.mjs",
|
|
27
28
|
"lint:manifests": "node scripts/lint/check-manifests.mjs",
|
|
28
29
|
"lint:lesson-schema": "node scripts/lint/check-lesson-schema-drift.mjs",
|
|
30
|
+
"lint:managed-blocks": "node scripts/lint/check-managed-blocks.mjs",
|
|
29
31
|
"coverage:node": "node scripts/coverage/run-coverage.mjs",
|
|
30
32
|
"coverage:bash": "node scripts/coverage/bash-coverage.mjs",
|
|
31
33
|
"coverage": "npm run coverage:node && npm run coverage:bash",
|
|
32
|
-
"test:ci": "npm run test:shellcheck && npm run test:bats && npm run test:schema && npm run lint:check && npm run lint:manifests && npm run lint:references && npm run lint:lesson-schema",
|
|
34
|
+
"test:ci": "npm run test:shellcheck && npm run test:bats && npm run test:schema && npm run test:bus && npm run lint:managed-blocks && npm run lint:check && npm run lint:manifests && npm run lint:references && npm run lint:lesson-schema",
|
|
33
35
|
"lint:check": "biome check . && markdownlint '**/*.md'",
|
|
34
36
|
"lint": "biome lint --write && markdownlint --fix '**/*.md'",
|
|
35
37
|
"format": "biome format --write && markdownlint --fix '**/*.md'",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Uses @onlooker-community/schema for envelope shape and validation.
|
|
5
5
|
*/
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
|
|
10
10
|
// Canonical event-type constants, inlined rather than imported from
|
|
@@ -105,6 +105,41 @@ async function tryValidate(event) {
|
|
|
105
105
|
return { available: true, ...schema.validate(event) };
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Test-only emission report.
|
|
110
|
+
*
|
|
111
|
+
* When ONLOOKER_TEST_REPORT_DIR is set, append one line per emission recording
|
|
112
|
+
* the event type, whether validation actually ran, and whether it passed.
|
|
113
|
+
*
|
|
114
|
+
* This exists because both signals the emitter produces on rejection — a
|
|
115
|
+
* non-zero exit and stderr — are destroyed by the hook's fail-soft exit 0,
|
|
116
|
+
* which leaves a dropped event indistinguishable from one that never fired.
|
|
117
|
+
* The report lives outside the per-test BATS_TEST_TMPDIR so the suite can gate
|
|
118
|
+
* on it after the fact.
|
|
119
|
+
*
|
|
120
|
+
* Production never sets the variable, so nothing is written there and the
|
|
121
|
+
* fail-open contract in ADR-005 is untouched. `validated` is recorded
|
|
122
|
+
* separately from `valid` so a run where the schema package never resolved is
|
|
123
|
+
* distinguishable from a run where everything passed — without it, a missing
|
|
124
|
+
* node_modules would make the gate pass while checking nothing.
|
|
125
|
+
*/
|
|
126
|
+
function recordEmission(event, check) {
|
|
127
|
+
const dir = process.env.ONLOOKER_TEST_REPORT_DIR;
|
|
128
|
+
if (!dir) return;
|
|
129
|
+
const record = {
|
|
130
|
+
event_type: event?.event_type ?? null,
|
|
131
|
+
validated: check.available === true,
|
|
132
|
+
valid: check.available === true ? check.valid === true : null,
|
|
133
|
+
};
|
|
134
|
+
if (check.available && !check.valid) record.errors = check.errors;
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dir, { recursive: true });
|
|
137
|
+
appendFileSync(join(dir, 'emissions.jsonl'), `${JSON.stringify(record)}\n`);
|
|
138
|
+
} catch {
|
|
139
|
+
// A broken report must never break an emission.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
108
143
|
function summarizeText(value, maxLen = 1000) {
|
|
109
144
|
if (value == null) return undefined;
|
|
110
145
|
const text = String(value).replace(/\s+/g, ' ').trim();
|
|
@@ -510,6 +545,7 @@ async function main() {
|
|
|
510
545
|
// Best-effort: reject when a validator is present (dev/CI), fail open in
|
|
511
546
|
// installed plugins so the event is still emitted.
|
|
512
547
|
const check = await tryValidate(mapped.event);
|
|
548
|
+
recordEmission(mapped.event, check);
|
|
513
549
|
if (check.available && !check.valid) {
|
|
514
550
|
console.error(JSON.stringify(check.errors, null, 2));
|
|
515
551
|
process.exit(1);
|
|
@@ -538,6 +574,7 @@ async function main() {
|
|
|
538
574
|
// Best-effort: reject when a validator is present (dev/CI), fail open in
|
|
539
575
|
// installed plugins so the event is still emitted.
|
|
540
576
|
const check = await tryValidate(event);
|
|
577
|
+
recordEmission(event, check);
|
|
541
578
|
if (check.available && !check.valid) {
|
|
542
579
|
console.error(JSON.stringify(check.errors, null, 2));
|
|
543
580
|
process.exit(1);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bus coverage gates.
|
|
3
|
+
//
|
|
4
|
+
// Gate A: no emission recorded during the suite was rejected by the schema.
|
|
5
|
+
//
|
|
6
|
+
// Reads the report the emitter writes when ONLOOKER_TEST_REPORT_DIR is set —
|
|
7
|
+
// see recordEmission in scripts/lib/onlooker-event.mjs. A rejected emission is
|
|
8
|
+
// invisible any other way: the emitter exits 1 and prints ajv errors, and the
|
|
9
|
+
// hook's fail-soft exit 0 destroys both.
|
|
10
|
+
//
|
|
11
|
+
// Gate B: every registered event type is accounted for — either it produced
|
|
12
|
+
// a validated emission during the suite, or the manifest excuses it with a
|
|
13
|
+
// stated reason. See test/bus-coverage.json.
|
|
14
|
+
//
|
|
15
|
+
// Exit codes:
|
|
16
|
+
// 0 ok
|
|
17
|
+
// 1 gate A or gate B failure
|
|
18
|
+
// 2 unknown argument
|
|
19
|
+
//
|
|
20
|
+
// Usage: check-bus-coverage.mjs [--report <dir>] [--manifest <path>]
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, join, resolve } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const REPO_ROOT = resolve(HERE, '..', '..');
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const out = {
|
|
30
|
+
report: join(REPO_ROOT, 'test', 'tmp-emission-report'),
|
|
31
|
+
manifest: join(REPO_ROOT, 'test', 'bus-coverage.json'),
|
|
32
|
+
};
|
|
33
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
34
|
+
const a = argv[i];
|
|
35
|
+
if (a === '--report') out.report = argv[++i];
|
|
36
|
+
else if (a === '--manifest') out.manifest = argv[++i];
|
|
37
|
+
else if (a === '--help') {
|
|
38
|
+
process.stderr.write('Usage: check-bus-coverage.mjs [--report <dir>] [--manifest <path>]\n');
|
|
39
|
+
process.exit(0);
|
|
40
|
+
} else {
|
|
41
|
+
process.stderr.write(`check-bus-coverage: unknown argument: ${a}\n`);
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadReport(dir) {
|
|
49
|
+
const p = join(dir, 'emissions.jsonl');
|
|
50
|
+
if (!existsSync(p)) return [];
|
|
51
|
+
return readFileSync(p, 'utf8')
|
|
52
|
+
.trim()
|
|
53
|
+
.split('\n')
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((l) => JSON.parse(l));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function gateA(lines) {
|
|
59
|
+
const failures = [];
|
|
60
|
+
if (lines.length === 0) {
|
|
61
|
+
failures.push('no emissions recorded — run `npm run test:bats` with ONLOOKER_TEST_REPORT_DIR set');
|
|
62
|
+
return failures;
|
|
63
|
+
}
|
|
64
|
+
if (!lines.some((l) => l.validated === true)) {
|
|
65
|
+
failures.push(
|
|
66
|
+
'no emission was validated: @onlooker-community/schema did not resolve, so this gate ' +
|
|
67
|
+
'checked nothing. Run `npm ci` and try again.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
for (const l of lines.filter((x) => x.valid === false)) {
|
|
71
|
+
failures.push(`rejected emission: ${l.event_type} — ${JSON.stringify(l.errors)}`);
|
|
72
|
+
}
|
|
73
|
+
return failures;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Gate B: every registered event type is accounted for.
|
|
78
|
+
*
|
|
79
|
+
* `expected` types must have produced a validated emission during the suite.
|
|
80
|
+
* `excluded` types must carry a reason and must NOT have been emitted —
|
|
81
|
+
* otherwise coverage is silently under-claimed and the manifest can drift
|
|
82
|
+
* downward without CI noticing. Together `expected` and `excluded` must
|
|
83
|
+
* equal ALL_EVENT_TYPES exactly, so a newly registered type belongs to
|
|
84
|
+
* neither and fails here until someone triages it deliberately.
|
|
85
|
+
*/
|
|
86
|
+
async function gateB(lines, manifestPath) {
|
|
87
|
+
const failures = [];
|
|
88
|
+
let schema;
|
|
89
|
+
try {
|
|
90
|
+
schema = await import('@onlooker-community/schema');
|
|
91
|
+
} catch {
|
|
92
|
+
return ['@onlooker-community/schema is not installed; run `npm ci`'];
|
|
93
|
+
}
|
|
94
|
+
const registered = new Set(schema.ALL_EVENT_TYPES);
|
|
95
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
96
|
+
const expected = manifest.expected ?? [];
|
|
97
|
+
const excluded = manifest.excluded ?? {};
|
|
98
|
+
|
|
99
|
+
const emitted = new Set(lines.filter((l) => l.valid === true).map((l) => l.event_type));
|
|
100
|
+
for (const t of expected) {
|
|
101
|
+
if (!emitted.has(t)) failures.push(`expected type never emitted during the suite: ${t}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const accounted = new Set([...expected, ...Object.keys(excluded)]);
|
|
105
|
+
for (const t of registered) {
|
|
106
|
+
if (!accounted.has(t)) {
|
|
107
|
+
failures.push(`registered type is in neither list — triage it in the manifest: ${t}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const t of accounted) {
|
|
111
|
+
if (!registered.has(t)) {
|
|
112
|
+
failures.push(`manifest names a type the schema does not register: ${t}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const [t, reason] of Object.entries(excluded)) {
|
|
116
|
+
if (!reason || !String(reason).trim()) {
|
|
117
|
+
failures.push(`excluded type needs a reason: ${t}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// An excluded type that is actually emitted and valid is coverage silently
|
|
121
|
+
// under-claimed: the manifest says "nothing tests this" while the suite
|
|
122
|
+
// does. Catch it before the manifest can drift downward unnoticed.
|
|
123
|
+
for (const t of Object.keys(excluded)) {
|
|
124
|
+
if (emitted.has(t)) {
|
|
125
|
+
failures.push(`excluded type is actually emitted — move it to expected: ${t}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return failures;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function main() {
|
|
132
|
+
const args = parseArgs(process.argv);
|
|
133
|
+
const lines = loadReport(args.report);
|
|
134
|
+
const failures = gateA(lines);
|
|
135
|
+
// Skip Gate B unless something was genuinely validated. A merely
|
|
136
|
+
// non-empty report where nothing validated (schema package never
|
|
137
|
+
// resolved) would otherwise bury the one real failure under a spurious
|
|
138
|
+
// "expected type never emitted" line for every expected type.
|
|
139
|
+
if (lines.some((l) => l.validated === true)) failures.push(...(await gateB(lines, args.manifest)));
|
|
140
|
+
if (failures.length) {
|
|
141
|
+
for (const f of failures) process.stderr.write(`check-bus-coverage: ${f}\n`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write(`check-bus-coverage: ok (${lines.length} emission(s))\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const isMain = process.argv[1]?.endsWith('check-bus-coverage.mjs') ?? false;
|
|
148
|
+
if (isMain) {
|
|
149
|
+
main().catch((err) => {
|
|
150
|
+
process.stderr.write(`check-bus-coverage: ${err.message}\n`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
});
|
|
153
|
+
}
|