@vibe-agent-toolkit/cli 0.2.0-rc.4 → 0.2.0-rc.6

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.
Files changed (92) hide show
  1. package/dist/bin.js +35 -2
  2. package/dist/bin.js.map +1 -1
  3. package/dist/commands/audit.d.ts.map +1 -1
  4. package/dist/commands/audit.js +169 -17
  5. package/dist/commands/audit.js.map +1 -1
  6. package/dist/commands/claude/claude-config.d.ts.map +1 -1
  7. package/dist/commands/claude/claude-config.js +6 -0
  8. package/dist/commands/claude/claude-config.js.map +1 -1
  9. package/dist/commands/claude/org/helpers.d.ts +59 -1
  10. package/dist/commands/claude/org/helpers.d.ts.map +1 -1
  11. package/dist/commands/claude/org/helpers.js +58 -7
  12. package/dist/commands/claude/org/helpers.js.map +1 -1
  13. package/dist/commands/claude/org/index.d.ts.map +1 -1
  14. package/dist/commands/claude/org/index.js +24 -16
  15. package/dist/commands/claude/org/index.js.map +1 -1
  16. package/dist/commands/claude/org/skills.d.ts +428 -0
  17. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  18. package/dist/commands/claude/org/skills.js +1189 -91
  19. package/dist/commands/claude/org/skills.js.map +1 -1
  20. package/dist/commands/claude/org/users.d.ts.map +1 -1
  21. package/dist/commands/claude/org/users.js +3 -1
  22. package/dist/commands/claude/org/users.js.map +1 -1
  23. package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
  24. package/dist/commands/claude/org/workspaces.js +5 -2
  25. package/dist/commands/claude/org/workspaces.js.map +1 -1
  26. package/dist/commands/claude/plugin/install.js +0 -1
  27. package/dist/commands/claude/plugin/install.js.map +1 -1
  28. package/dist/commands/corpus/runner.d.ts.map +1 -1
  29. package/dist/commands/corpus/runner.js +1 -19
  30. package/dist/commands/corpus/runner.js.map +1 -1
  31. package/dist/commands/inventory.js +1 -1
  32. package/dist/commands/inventory.js.map +1 -1
  33. package/dist/commands/resources/check-progress.d.ts +145 -0
  34. package/dist/commands/resources/check-progress.d.ts.map +1 -0
  35. package/dist/commands/resources/check-progress.js +205 -0
  36. package/dist/commands/resources/check-progress.js.map +1 -0
  37. package/dist/commands/resources/check-supervisor.d.ts +465 -0
  38. package/dist/commands/resources/check-supervisor.d.ts.map +1 -0
  39. package/dist/commands/resources/check-supervisor.js +566 -0
  40. package/dist/commands/resources/check-supervisor.js.map +1 -0
  41. package/dist/commands/resources/check.d.ts +454 -0
  42. package/dist/commands/resources/check.d.ts.map +1 -0
  43. package/dist/commands/resources/check.js +1237 -0
  44. package/dist/commands/resources/check.js.map +1 -0
  45. package/dist/commands/resources/index.d.ts.map +1 -1
  46. package/dist/commands/resources/index.js +277 -6
  47. package/dist/commands/resources/index.js.map +1 -1
  48. package/dist/commands/resources/query.d.ts +89 -0
  49. package/dist/commands/resources/query.d.ts.map +1 -0
  50. package/dist/commands/resources/query.js +146 -0
  51. package/dist/commands/resources/query.js.map +1 -0
  52. package/dist/commands/resources/scan.js +1 -1
  53. package/dist/commands/resources/scan.js.map +1 -1
  54. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  55. package/dist/commands/skill/review-checklist.js +35 -0
  56. package/dist/commands/skill/review-checklist.js.map +1 -1
  57. package/dist/commands/skill/test/configure.d.ts +37 -0
  58. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  59. package/dist/commands/skill/test/configure.js +57 -12
  60. package/dist/commands/skill/test/configure.js.map +1 -1
  61. package/dist/commands/skills/install.js +0 -1
  62. package/dist/commands/skills/install.js.map +1 -1
  63. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  64. package/dist/commands/skills/skill-discovery.js +4 -2
  65. package/dist/commands/skills/skill-discovery.js.map +1 -1
  66. package/dist/utils/agent-runner.d.ts.map +1 -1
  67. package/dist/utils/agent-runner.js +7 -3
  68. package/dist/utils/agent-runner.js.map +1 -1
  69. package/dist/utils/command-error.d.ts +56 -1
  70. package/dist/utils/command-error.d.ts.map +1 -1
  71. package/dist/utils/command-error.js +66 -3
  72. package/dist/utils/command-error.js.map +1 -1
  73. package/dist/utils/config-loader.d.ts.map +1 -1
  74. package/dist/utils/config-loader.js +30 -7
  75. package/dist/utils/config-loader.js.map +1 -1
  76. package/dist/utils/issue-severity.d.ts +12 -5
  77. package/dist/utils/issue-severity.d.ts.map +1 -1
  78. package/dist/utils/issue-severity.js +1 -1
  79. package/dist/utils/issue-severity.js.map +1 -1
  80. package/dist/utils/projection-query.d.ts +263 -0
  81. package/dist/utils/projection-query.d.ts.map +1 -0
  82. package/dist/utils/projection-query.js +262 -0
  83. package/dist/utils/projection-query.js.map +1 -0
  84. package/dist/utils/projection-store.d.ts +104 -2
  85. package/dist/utils/projection-store.d.ts.map +1 -1
  86. package/dist/utils/projection-store.js +126 -4
  87. package/dist/utils/projection-store.js.map +1 -1
  88. package/dist/utils/vat-bin-path.d.ts +36 -0
  89. package/dist/utils/vat-bin-path.d.ts.map +1 -0
  90. package/dist/utils/vat-bin-path.js +49 -0
  91. package/dist/utils/vat-bin-path.js.map +1 -0
  92. package/package.json +25 -14
@@ -0,0 +1,1237 @@
1
+ /**
2
+ * `vat resources check` — run the project's own SQL assertions over its
3
+ * resource projection.
4
+ *
5
+ * ## Why this verb exists when `vat resources query` already runs SQL
6
+ *
7
+ * A query answers a question once. A question worth asking twice is a rule, and
8
+ * a rule that lives in someone's shell history is not enforced — it decays into
9
+ * a comment. This verb runs the statements a project wrote into
10
+ * `resources.checks`, turns each violating row into an ordinary validation
11
+ * finding, and exits non-zero when any of them is an error. That is the whole
12
+ * difference, and it is the difference between a tool and a gate.
13
+ *
14
+ * ## 🔑 A broken check is an ERROR, never a skip
15
+ *
16
+ * VAT ships no schema version, so a renamed column simply breaks a check's SQL.
17
+ * The tempting handling — log it and carry on — is the one thing this must never
18
+ * do: a check that stopped running looks exactly like a check that passed, and
19
+ * the project keeps reporting green over an assertion nobody is making any more.
20
+ * So a statement that will not run is reported as a finding at **error**
21
+ * severity, with the columns the projection actually has, and it fails the run.
22
+ *
23
+ * That report carries its OWN code (`RESOURCE_CHECK_BROKEN`), which no
24
+ * `validation.severity` entry can reach. A check's severity override is about how
25
+ * bad a VIOLATION is; while the two shared the code `CUSTOM:<name>`, the
26
+ * documented way to stand down an inherited check also silenced the news that it
27
+ * had stopped checking. See the `catch` in `runChecks`.
28
+ *
29
+ * ## 🔑 An empty CORPUS is a refusal too, not a pass
30
+ *
31
+ * The same class one level further out, and the one that shipped. `checksRun` is
32
+ * the denominator of RULES; nothing counted the ROWS, so a repository whose
33
+ * enumeration came back empty — a broad `.gitignore`, a shallow or sparse CI
34
+ * checkout, a root that resolved somewhere else — ran every declared check over
35
+ * nothing and reported `status: success` on exit 0 with empty stderr. Population
36
+ * declines ignored members rather than flagging them, so the tables held no
37
+ * trace of it either.
38
+ *
39
+ * So the population's size is published (`membersEnumerated`), and a run whose
40
+ * checks executed over zero members is an ERROR under the same non-overridable
41
+ * code as a broken statement — see {@link emptyCorpusFinding}. A gate that
42
+ * asserts nothing over nothing has not passed.
43
+ *
44
+ * ## 🔑 An unknown `--check` name is a refusal, not an empty pass
45
+ *
46
+ * The same failure class one flag over: a filter that matched nothing ran nothing
47
+ * and exited 0. `requireDeclaredCheck` refuses it before the crawl, naming the
48
+ * declared set. A gate that cannot fail is worse than no gate.
49
+ *
50
+ * ## 🔑 SQL is the surface a user OPTED IN to, so no BUILT-IN check may be SQL
51
+ *
52
+ * Every check this verb runs is adopter-declared: {@link checkCommand} reads
53
+ * `config?.resources?.checks` and nothing else, and warns loudly when a project
54
+ * declares none. There are no built-ins, and the one way to end that quietly is
55
+ * to author the first one as SQL. A default-on rule written as a statement makes
56
+ * the engine, the projection and the population mandatory for everyone who
57
+ * inherits it, without anybody deciding that. So: **built-ins are TypeScript
58
+ * predicates over the projection's row model.** SQL stays the two places a user
59
+ * asked for it — this verb's declared checks, and `vat resources query`.
60
+ *
61
+ * ⚠️ The engine today is `node:sqlite`, built into Node, so the thing at stake is
62
+ * not an install — it is the POPULATION. On an ~11,700-member adopter tree
63
+ * building the projection is **>99.9%** of a check run (16.5 s cold, ~1.3 s from
64
+ * the store) against **0.0008–0.004 s** for the statements themselves; see
65
+ * `vat-knowledge-resources.md`, which carries that measurement. That is exactly
66
+ * the cost "What this does NOT do" below refuses to put on every adopter's
67
+ * pre-commit path, and a default-on SQL check would put it there without the
68
+ * decision. The INSTALL cost is deferred rather than gone: the open question
69
+ * about a columnar engine is a 108 MB platform binding on a published toolkit,
70
+ * and this invariant is what keeps that question still answerable either way.
71
+ *
72
+ * ## 🔑 The DEFAULT check set belongs to the pipeline, not to the config file
73
+ *
74
+ * A directory with no `vibe-agent-toolkit.config.yaml` must run exactly the same
75
+ * default-on checks as one with a config; config only ADDS to that set or
76
+ * OVERRIDES a severity in it. This is load-bearing rather than a convenience:
77
+ * *"'default-on error' is meaningless as a category if being default-on requires
78
+ * a config file to say so."*
79
+ *
80
+ * It is therefore a constraint on WHERE the defaults live in code, not only on
81
+ * what they are. They cannot be defaulted into the parsed config object, because
82
+ * an absent config is `undefined` and every default would vanish with it — for
83
+ * the project that never wrote one, which is the population the category exists
84
+ * for. The rest of VAT already behaves this way and is the shape to match:
85
+ * `vat audit` completes its entire scan first and reads config only to apply
86
+ * severity overrides, warning and CONTINUING when the file cannot be read; and
87
+ * `loadResourcesWithConfig` hands `vat resources validate` a registry whose
88
+ * built-in validators run whether `loadConfig` returned a config or nothing.
89
+ * This verb has no defaults yet, so the invariant is a rule for the first one —
90
+ * and the day it is written is the easiest day to break it.
91
+ *
92
+ * ## Keeping a TypeScript check and its SQL twin: two good reasons, one trap
93
+ *
94
+ * The proposal was "write the predicate twice, in TypeScript and in SQL, and run
95
+ * whichever is faster". Two thirds of it are worth having.
96
+ *
97
+ * ✅ **As documentation.** An SQL twin is executable documentation of the row
98
+ * model, and strictly better than prose about it: prose can describe a column
99
+ * that no longer exists and never say so, while a statement stops compiling —
100
+ * which this verb reports as `RESOURCE_CHECK_BROKEN` rather than as a skip.
101
+ *
102
+ * ✅ **As a differential oracle.** Run both over the frozen corpus and assert
103
+ * IDENTICAL findings. That is the only thing which makes a dual implementation
104
+ * better than a single one rather than worse: two implementations with no
105
+ * mandatory differential test are simply two places for one rule to be wrong.
106
+ * Apply it selectively, to genuinely relational checks — done by default, every
107
+ * new rule costs three artifacts instead of one.
108
+ *
109
+ * ⛔ **As a runtime optimizer — rejected, on two INDEPENDENT grounds.** It would
110
+ * essentially never choose SQL: `buildResourceProjection` hands the rows back in
111
+ * memory before the ephemeral database exists at all, so the TypeScript arm is a
112
+ * for-loop over data already in hand, and the SQL arm's whole measured share of a
113
+ * run is the 0.0008–0.004 s above. A scheduler would be choosing between two
114
+ * arms of the 0.1%. And it reintroduces the dependency through the back door: an
115
+ * optimizer that MAY choose SQL means SQL MAY be needed at check time, which for
116
+ * anyone shipping the thing is the same as needed. **Optionality is not a
117
+ * property you can keep while also letting a scheduler reach for the optional
118
+ * thing.**
119
+ *
120
+ * ## 🔑 Adopter SQL runs UNSANDBOXED on purpose — and contributed SQL is not adopter SQL
121
+ *
122
+ * What a statement may BE is gated hard, and documented where it is enforced:
123
+ * `assertIsQuery` and `detachForeignSchemas` in
124
+ * `packages/projection-sqlite/src/store.ts` refuse `ATTACH` and `PRAGMA`, with
125
+ * the measured cross-repository leak that motivated both, and CHANGELOG.md's
126
+ * `--budget` entry carries why nothing inside this process can interrupt a
127
+ * runaway statement. Read those; do not restate them here.
128
+ *
129
+ * What is written down nowhere else is that everything PAST those gates is
130
+ * unsandboxed deliberately. A `.sql` file the adopter points their own config at
131
+ * is adopter code, at the same trust boundary as their eslint config: they wrote
132
+ * it or they chose to inherit it, and VAT is not the thing standing between a
133
+ * project and its own repository.
134
+ *
135
+ * 🚨 That reasoning covers exactly ONE trust domain, and the rule scoping it is
136
+ * the other half: **contributed SQL is a reviewed EXAMPLE, never an
137
+ * auto-executed check.** A contribution channel is third-party text, and a
138
+ * statement arriving through one has none of the "they chose it" the paragraph
139
+ * above rests on. Ship it as documentation an adopter copies into their own
140
+ * `resources.checks` deliberately. One paragraph must not be stretched to cover
141
+ * both domains.
142
+ *
143
+ * ## What this does NOT do
144
+ *
145
+ * It does not fold into `vat resources validate`. That command is on every
146
+ * adopter's pre-commit path, and adding a store-backed population to it is a
147
+ * cost and a risk that deserves its own decision with its own evidence. Wiring
148
+ * this into CI is one line in a workflow; making it unavoidable is not this
149
+ * change's call to make.
150
+ */
151
+ import { performance } from 'node:perf_hooks';
152
+ import { issuesFromCheckRows } from '@vibe-agent-toolkit/resources';
153
+ import { calculateValidationStatus, countBySeverity, CUSTOM_CHECK_CODE_PREFIX, isCustomCheckCode, } from '@vibe-agent-toolkit/schema';
154
+ import { safePath } from '@vibe-agent-toolkit/utils';
155
+ import { handleCommandError } from '../../utils/command-error.js';
156
+ import { loadConfigCached } from '../../utils/config-loader.js';
157
+ import { formatDurationSecs } from '../../utils/duration.js';
158
+ import { resolveIssueSeverity } from '../../utils/issue-severity.js';
159
+ import { createLogger } from '../../utils/logger.js';
160
+ import { writeJsonOutput, writeStdoutSync, writeYamlOutput } from '../../utils/output.js';
161
+ import { projectRootOrLoudCwd, projectRootOrNull } from '../../utils/project-root-policy.js';
162
+ import { withQueriedProjection, } from '../../utils/projection-query.js';
163
+ import { createProgressWriter, parseProgressLog, unitInFlight, } from './check-progress.js';
164
+ import { parseBudgetSeconds, requireSupervisableFlags, runsInThisProcess, superviseCheck, withProgressLog, } from './check-supervisor.js';
165
+ /**
166
+ * Build the check report.
167
+ *
168
+ * Pure: no file system, no clock, no `process.exit` — the same contract every
169
+ * other payload builder here follows, so the document's shape stays under unit
170
+ * test rather than only under a CLI spawn.
171
+ *
172
+ * `checksRun` is published beside the counts and is not decoration: zero
173
+ * findings from four checks and zero findings from **no** checks are the same
174
+ * document without it, and they mean opposite things. A project whose config key
175
+ * is misspelled would otherwise read as passing. It is DERIVED from the `checks`
176
+ * list rather than carried alongside it, so the denominator and the entries that
177
+ * account for it cannot disagree.
178
+ *
179
+ * 🚨 `populationSecs` and the per-check `durationSecs` do not overlap and do not
180
+ * sum to `durationSecs`. The population is paid once and shared by every check;
181
+ * see {@link CheckCost} for why it is charged to none of them.
182
+ *
183
+ * @param input - The findings and the provenance of the run behind them
184
+ * @returns The document to serialize
185
+ */
186
+ export function buildCheckOutputData(input) {
187
+ const issues = [...input.issues];
188
+ return {
189
+ status: calculateValidationStatus(issues),
190
+ root: input.root,
191
+ population: input.population,
192
+ // Beside the origin, because a `population: store` a reader cannot price is
193
+ // a label taken on faith. Charged to no check — see {@link CheckCost}.
194
+ populationSecs: formatDurationSecs(input.populationMs),
195
+ // The denominator. See above: without it an empty findings list is
196
+ // ambiguous. Derived from `checks`, never carried beside it.
197
+ checksRun: input.costs.length,
198
+ // The OTHER denominator, and the one whose absence shipped a green gate over
199
+ // an empty repository. `checksRun` counts rules; this counts what they ran
200
+ // against. Both are needed, because zero findings is the pass condition and
201
+ // either number at zero makes that pass vacuous.
202
+ membersEnumerated: input.membersEnumerated,
203
+ issueCounts: countBySeverity(issues),
204
+ durationSecs: formatDurationSecs(input.durationMs),
205
+ // What each rule cost, directly under the total it is a breakdown of. A SQL
206
+ // surface is an unbounded cost — a project can declare a statement that
207
+ // scans every row of every table — and a single total attributes nothing:
208
+ // ten seconds is one expensive rule, a slow population, or forty cheap
209
+ // rules, and only this list tells them apart.
210
+ //
211
+ // 🪤 `rows` and `broken` are spread conditionally rather than defaulted.
212
+ // `rows: 0` on a statement that never returned would read as a clean pass,
213
+ // which is the exact confusion `RESOURCE_CHECK_BROKEN` exists to prevent —
214
+ // so a check that threw publishes no row count at all.
215
+ checks: input.costs.map((cost) => ({
216
+ name: cost.name,
217
+ // Three significant figures, so a 0.4 ms rule serializes as 0.0004 rather
218
+ // than rounding to a zero that reads as "not measured".
219
+ durationSecs: formatDurationSecs(cost.durationMs),
220
+ ...(cost.rows === undefined ? {} : { rows: cost.rows }),
221
+ ...(cost.broken === undefined ? {} : { broken: cost.broken }),
222
+ })),
223
+ // 🪤 NOT run through `relativizePathEntries`. Every other payload builder
224
+ // re-bases here because its producer keeps absolute paths internally; these
225
+ // arrive project-relative instead, and re-basing an already-relative path
226
+ // would resolve it against the wrong base and silently corrupt it.
227
+ //
228
+ // 🪤 The guarantee is `locationOf` in `sql-checks.ts` — the one place a
229
+ // projection column becomes a finding's `location`, and which refuses an
230
+ // absolute or backslashed value there. It is NOT the type: nothing on this
231
+ // path parses issues through `ValidationIssueSchema` (this module imports the
232
+ // `ValidationIssue` TYPE only, and the schema's sole consumers are in
233
+ // `packages/schema/test/`), so the refinement is documentation here rather
234
+ // than enforcement. Do not "fix" that by parsing at this boundary: it would
235
+ // convert a slip in `locationOf` from "this finding loses its anchor" into
236
+ // "the whole run throws", which is strictly worse for a gate.
237
+ //
238
+ // A finding may also carry no path at all (an aggregate check has no file to
239
+ // name), which `PathEntry` cannot represent — one more reason this list is
240
+ // not that shape.
241
+ issues: issues.map((issue) => ({
242
+ code: issue.code,
243
+ severity: issue.severity,
244
+ message: issue.message,
245
+ ...(issue.location === undefined ? {} : { path: issue.location }),
246
+ })),
247
+ };
248
+ }
249
+ /**
250
+ * Run a thunk between two readings of the clock, and report both.
251
+ *
252
+ * ## 🚨 Why this is a helper and not a stopwatch inline in the loop
253
+ *
254
+ * The loop used to read the clock into a `startedAt`, call `ask`, and subtract
255
+ * two lines later — and the claim below that "the span is `ask(check.sql)` and
256
+ * nothing else" had NO guard behind it. A reviewer moved the subtraction below
257
+ * the row-to-finding mapping, widening the measured span to cover work that is
258
+ * not the statement, and **the entire unit suite stayed green (36/36)**. That is
259
+ * measured, not assumed.
260
+ *
261
+ * No cleverer fake clock closes it. The injected clock advances per READING, so
262
+ * every mutation that keeps two readings per check yields the same number; a
263
+ * clock that advanced only inside `ask` could not tell the two positions apart
264
+ * either, because the real mapping never touches the clock — and making the
265
+ * mapping slow in real time would buy a flake, not a guard.
266
+ *
267
+ * So the span is a THUNK. What is timed is what is passed in, and widening it
268
+ * stops being a one-line move of a subtraction and becomes a visible rewrite of
269
+ * the call site.
270
+ *
271
+ * 🪤 The throw is CAUGHT rather than propagated, so the failing path has a
272
+ * duration too. Keep it that way: a `try` in the caller around a helper that
273
+ * only times the happy path re-opens the two-read stopwatch this closes.
274
+ *
275
+ * @param now - The clock, in milliseconds
276
+ * @param run - The work whose duration is wanted, and nothing else
277
+ * @returns What `run` produced or threw, and how long that took
278
+ */
279
+ function timed(now, run) {
280
+ const startedAt = now();
281
+ try {
282
+ return { ok: true, value: run(), durationMs: now() - startedAt };
283
+ }
284
+ catch (error) {
285
+ return { ok: false, error, durationMs: now() - startedAt };
286
+ }
287
+ }
288
+ /**
289
+ * The finding for a statement that would not run.
290
+ *
291
+ * 🔑 A finding, not a skip, at `error`, and under its OWN code.
292
+ *
293
+ * The check's declared severity describes how bad a VIOLATION is; it says
294
+ * nothing about how bad it is that the check cannot run, and a `warning` check
295
+ * whose SQL broke would otherwise fail nothing while asserting nothing. That
296
+ * much was always true of the DECLARATION.
297
+ *
298
+ * 🪤 It was never true of an adopter OVERRIDE, and this finding used to carry
299
+ * `customCheckCode(name)` — the same code as a violation of that very check. So
300
+ * `severity: { 'CUSTOM:foo': 'ignore' }`, the documented way to stand down a
301
+ * check you inherited, also silenced "foo could not run", and `'warning'`
302
+ * demoted it below the exit threshold. A renamed projection column then produced
303
+ * exit 0 from a gate. `RESOURCE_CHECK_BROKEN` is a non-overridable code
304
+ * precisely so that config line cannot reach here.
305
+ *
306
+ * 🪤 Only `ask` reaches this. Turning the selected rows INTO findings is not a
307
+ * statement failure, and reporting it as one told the operator a working rule
308
+ * was broken — see the append in {@link runChecks}.
309
+ *
310
+ * @param name - The check's key in `resources.checks`
311
+ * @param error - Whatever the statement threw
312
+ * @returns The run-integrity finding
313
+ */
314
+ function brokenCheckFinding(name, error) {
315
+ return {
316
+ code: 'RESOURCE_CHECK_BROKEN',
317
+ severity: 'error',
318
+ message: `The check "${name}" could not run, so it is asserting nothing: `
319
+ + (error instanceof Error ? error.message : String(error)),
320
+ };
321
+ }
322
+ /**
323
+ * Run every declared check, collect its findings, and price it.
324
+ *
325
+ * ## What the clock measures, and what it must not
326
+ *
327
+ * The span is `ask(check.sql)` and nothing else — structurally, because it is
328
+ * the thunk handed to {@link timed}, which exists for that reason and documents
329
+ * the mutation that proved an inline stopwatch unguardable. Everything before
330
+ * the loop — the git tracker, `buildResourceProjection`, loading the ephemeral
331
+ * database — is paid ONCE for all of them and reaches the document as
332
+ * `populationSecs`; see {@link CheckCost} for why charging it here would be a
333
+ * lie in N places.
334
+ *
335
+ * ⚠️ `performance.now()`, never `Date.now()`. A rule over a small projection is
336
+ * routinely sub-millisecond, and a millisecond-granularity clock reports every
337
+ * one of them as `0` — which reads as "not measured" and makes the whole
338
+ * attribution worthless exactly where it is cheapest to get right.
339
+ *
340
+ * 🪤 Exactly one cost record per executed check, on both paths, because
341
+ * `checksRun` is this list's length. The record is filed before the rows become
342
+ * findings, so nothing between the two can leave a check unpriced.
343
+ *
344
+ * ## 🚨 Why the loop announces itself before it can be stopped
345
+ *
346
+ * {@link ProgressSink} is told a check is about to run, and told again when it
347
+ * is priced. That ordering is not decoration: this verb runs adopter-authored
348
+ * SQL unattended, an accidental cross join or an unterminated `WITH RECURSIVE`
349
+ * runs forever, and NOTHING in-process can interrupt it — the query is
350
+ * synchronous, it holds the event loop, and `node:sqlite` exposes no interrupt.
351
+ * The supervisor's only lever is an external `SIGKILL`, which publishes nothing
352
+ * of the killed process's memory. So the name of the rule that hangs has to be
353
+ * on disk BEFORE it is entered, or it is not recoverable at all.
354
+ *
355
+ * @param checks - The project's `resources.checks`
356
+ * @param only - A single check key to run, or undefined for all of them
357
+ * @param ask - Runs one statement against the populated projection
358
+ * @param now - The clock, in milliseconds; injected so a test can assert an
359
+ * exact duration rather than a range that passes on a timer that never started
360
+ * @param onProgress - Told what is about to run and what it cost, or undefined
361
+ * when nothing is watching (the in-process lane, and `--budget 0`)
362
+ * @returns The findings, and one cost record per check that ran
363
+ */
364
+ function runChecks(checks, only, ask, now, onProgress) {
365
+ const issues = [];
366
+ const costs = [];
367
+ /** File the cost with the observer as well as with the caller — one place. */
368
+ const price = (cost) => {
369
+ costs.push(cost);
370
+ onProgress?.({ kind: 'check', ...cost });
371
+ };
372
+ for (const [name, check] of Object.entries(checks)) {
373
+ if (only !== undefined && name !== only)
374
+ continue;
375
+ // 🪤 BEFORE `timed`, and not one line later. `ask` is where a runaway
376
+ // statement never returns from, so an announcement below it is filed by
377
+ // every check except the one whose name the operator needs.
378
+ onProgress?.({ kind: 'start', name });
379
+ const outcome = timed(now, () => ask(check.sql));
380
+ if (!outcome.ok) {
381
+ // Priced up to the throw, and with NO row count: a statement that did not
382
+ // complete selected nothing, and `rows: 0` would say it selected nothing
383
+ // and passed.
384
+ price({ name, durationMs: outcome.durationMs, broken: true });
385
+ issues.push(brokenCheckFinding(name, outcome.error));
386
+ continue;
387
+ }
388
+ price({ name, durationMs: outcome.durationMs, rows: outcome.value.length });
389
+ // 🪤 Appended one at a time, NEVER `issues.push(...findings)`. A spread
390
+ // becomes an ARGUMENT LIST, which throws `RangeError: Maximum call stack
391
+ // size exceeded` past roughly 125,000 elements on the main thread — and a
392
+ // check's result set is unbounded by construction. VAT's own
393
+ // `blob_references` table already holds 29,645 rows, so `SELECT * FROM
394
+ // blob_references` over a repository a few times this size reached it, and
395
+ // the throw landed in the arm above: a rule that ran perfectly was reported
396
+ // as one that "could not run, so it is asserting nothing". A loop does not
397
+ // put the argument limit in play at all.
398
+ for (const issue of issuesFromCheckRows(name, check, outcome.value))
399
+ issues.push(issue);
400
+ }
401
+ return { issues, costs };
402
+ }
403
+ /**
404
+ * Refuse a `--check` name the project does not declare.
405
+ *
406
+ * 🔑 **An unknown name used to be a silent green.** The filter was a bare
407
+ * `if (only !== undefined && name !== only) continue;` and nothing compared the
408
+ * flag against the declared keys, so `--check nope` filtered every check away,
409
+ * ran none, and exited 0 with `checksRun: 0` and `issues: []` on empty stderr.
410
+ * The loud "no checks are declared" warning never fired, because it asks whether
411
+ * the `checks` MAP is empty, never whether the FILTER matched anything.
412
+ *
413
+ * That is not a hypothetical typo: `vat resources check --check orphan-skills`
414
+ * is the example in this command's own help text. Rename or delete that check
415
+ * and the CI step keeps passing forever while asserting nothing.
416
+ *
417
+ * Thrown rather than reported as a finding, because it is an OPERATOR error and
418
+ * not a content violation — the two have different exit codes in this command's
419
+ * contract (2 versus 1) and different audiences. Thrown before the projection is
420
+ * populated, so a mistyped flag costs no crawl.
421
+ *
422
+ * @param checks - The project's `resources.checks`
423
+ * @param only - The `--check` value, or undefined
424
+ * @throws When `only` names no declared check
425
+ */
426
+ export function requireDeclaredCheck(checks, only) {
427
+ // 🪤 `Object.hasOwn`, not `only in checks`. `in` walks the prototype chain, so
428
+ // `--check toString` would have looked declared, matched nothing, and restored
429
+ // the exact silent green this guard closes.
430
+ if (only === undefined || Object.hasOwn(checks, only))
431
+ return;
432
+ const declared = Object.keys(checks);
433
+ throw new Error(`No check named "${only}" is declared. `
434
+ + (declared.length === 0
435
+ ? 'This project declares none — add them under `resources.checks` in'
436
+ + ' vibe-agent-toolkit.config.yaml.'
437
+ : `Declared under \`resources.checks\`: ${declared.join(', ')}.`));
438
+ }
439
+ /**
440
+ * Name every `CUSTOM:` severity override that overrides nothing.
441
+ *
442
+ * 🔑 **The same silent-green shape as an unknown `--check` name, one surface
443
+ * over.** {@link requireDeclaredCheck} refuses `--check nope` because a filter
444
+ * that matched nothing ran nothing and exited 0. A `validation.severity` entry
445
+ * keyed `CUSTOM:<name>` for a check that is not declared is the mirror image: it
446
+ * parses (the schema validates the KEY's shape and cannot see
447
+ * `resources.checks`), applies to no finding, and says nothing. An adopter
448
+ * renames a check and leaves the override behind, or mistypes it while standing
449
+ * down an inherited one — and in the second direction the check they meant to
450
+ * silence keeps failing their build with no clue why.
451
+ *
452
+ * ⚠️ **A warning, not a refusal, and the asymmetry with `--check` is
453
+ * deliberate.** `--check` is a flag typed for THIS run, so a mistake is an
454
+ * operator error and exiting 2 costs nothing. A `severity` entry lives in a
455
+ * committed config that may legitimately be shared across repositories or
456
+ * outlive a check through a rename in flight; refusing it would be a new
457
+ * breaking config error, which is exactly the change this release already
458
+ * decided not to make twice (see `ResourcesConfigSchema`). Stderr, so the
459
+ * document on stdout stays parseable — the same channel and the same reasoning
460
+ * as the "no checks are declared" warning in {@link checkCommand}.
461
+ *
462
+ * 📌 The registry half of the key space needs no equivalent: a misspelled
463
+ * REGISTRY code is already refused by `SeverityOverrideCodeSchema`'s enum
464
+ * branch. Only the open `CUSTOM:` namespace can name something that does not
465
+ * exist.
466
+ *
467
+ * @param checks - The project's `resources.checks`
468
+ * @param validation - The project's `resources.validation`, or undefined
469
+ * @param logger - Where the warning goes (stderr)
470
+ */
471
+ export function warnUndeclaredOverrides(checks, validation, logger) {
472
+ const stale = Object.keys(validation?.severity ?? {})
473
+ .filter((code) => isCustomCheckCode(code))
474
+ // 🪤 `Object.hasOwn`, not `in` — same reason {@link requireDeclaredCheck}
475
+ // uses it: `in` walks the prototype chain, so an override keyed
476
+ // `CUSTOM:toString` would look declared and stay silent.
477
+ .filter((code) => !Object.hasOwn(checks, code.slice(CUSTOM_CHECK_CODE_PREFIX.length)));
478
+ if (stale.length === 0)
479
+ return;
480
+ const declared = Object.keys(checks);
481
+ logger.warn(`resources.validation.severity names ${stale.length} check(s) this project does not declare,`
482
+ + ` so ${stale.length === 1 ? 'it overrides' : 'they override'} nothing: ${stale.join(', ')}.`
483
+ + (declared.length === 0
484
+ ? ' No checks are declared at all.'
485
+ : ` Declared under \`resources.checks\`: ${declared.join(', ')}.`)
486
+ + ' Correct the spelling, or delete the entry — a severity override for a check that no longer'
487
+ + ' exists is not silencing anything, and a MISTYPED one leaves the check you meant to stand'
488
+ + ' down still failing the build.');
489
+ }
490
+ /**
491
+ * The finding for a run whose checks executed over an empty corpus.
492
+ *
493
+ * 🔑 **The defect this closes shipped.** A scratch repository with
494
+ * `.gitignore = *` and two declared checks reported `checksRun: 2`,
495
+ * `status: success`, exit 0 and empty stderr over a corpus of ZERO files. The
496
+ * rules reported nothing because there was nothing to report on, and the
497
+ * document could not say so: `checksRun` is the denominator of rules, not of
498
+ * rows, so a gate over eight thousand files and a gate over none serialized
499
+ * identically. A broad ignore pattern, a shallow or sparse CI checkout, a root
500
+ * that resolved to the wrong directory, or an extent source that enumerated
501
+ * nothing all produce it — and because population DECLINES ignored members
502
+ * rather than flagging them, the tables carry no trace of the difference.
503
+ *
504
+ * The precedent is `population-wiring.ts`, which makes `onBlobPopulation`
505
+ * mandatory because "a tree whose every document was declined as binary
506
+ * otherwise populates as empty and reports success". That guard covers blob
507
+ * refusals only; this one covers the extent.
508
+ *
509
+ * 🪤 **Only when checks actually RAN.** Declaring no checks is a legitimate
510
+ * state that {@link checkCommand} answers with a loud stderr warning and a
511
+ * deliberate exit 0. Firing here as well would turn that legitimate state into
512
+ * an error and hand the operator two reports about one situation.
513
+ *
514
+ * 🪤 **`RESOURCE_CHECK_BROKEN`, not a code of its own.** It is the same
515
+ * run-integrity claim as a statement that would not compile — *these assertions
516
+ * did not execute meaningfully, so the green means nothing* — and it needs the
517
+ * identical non-overridability, which `ValidationConfigSchema` grants by refusing
518
+ * that code as a `severity` key. A sibling code would buy a consumer nothing the
519
+ * message does not already say, while adding a second thing an adopter's CI has
520
+ * to know to look for.
521
+ *
522
+ * @param checksRun - How many checks EXECUTED. Under `--check` that is fewer
523
+ * than the project declares, which is why the message says "ran" and not
524
+ * "declared": the number is the size of the filtered run
525
+ * @param membersEnumerated - How many members the population enumerated
526
+ * @returns The finding, or nothing when the run had a corpus (or no checks)
527
+ */
528
+ function emptyCorpusFinding(checksRun, membersEnumerated) {
529
+ if (checksRun === 0 || membersEnumerated > 0)
530
+ return [];
531
+ return [{
532
+ code: 'RESOURCE_CHECK_BROKEN',
533
+ severity: 'error',
534
+ message: `The projection enumerated 0 members, so the ${checksRun} check(s) that ran`
535
+ + ' asserted nothing: there were no rows for any statement to select and'
536
+ + ' zero findings means only that the corpus was empty.'
537
+ + ' Look at `.gitignore` (one broad pattern declines every file), at whether'
538
+ + ' the checkout is complete rather than shallow or sparse, and at whether'
539
+ + ' `root` in this report is the directory you meant.'
540
+ + ' `vat resources scan` over the same path lists what an enumeration finds.',
541
+ }];
542
+ }
543
+ /**
544
+ * Where the run was when it stopped, as a phrase.
545
+ *
546
+ * @param inFlight - What the log's last state says
547
+ * @returns A clause naming the unit
548
+ */
549
+ function inFlightPhrase(inFlight) {
550
+ if (inFlight.kind === 'check')
551
+ return `while the check "${inFlight.name}" was running`;
552
+ if (inFlight.kind === 'reporting') {
553
+ return 'after the last check had finished, while its document was being assembled';
554
+ }
555
+ // 🪤 The HEDGE is load-bearing and was once deleted on the strength of
556
+ // `checks-complete`. It does not cover this window. `price()` files the last
557
+ // check's cost BEFORE `issuesFromCheckRows` turns its rows into issues, and
558
+ // `checks-complete` is written after that conversion — so a kill in between
559
+ // leaves a log of nothing but completed checks, which reads as `idle`. The
560
+ // unhedged sentence then told an operator whose run had finished every rule
561
+ // that it died before the first statement. The window is narrow (the
562
+ // conversion runs ~0.74x the statement it follows, so a budget that survived
563
+ // the statement survives the conversion) but a narrow window is not a closed
564
+ // one, and the sentence costs nothing to keep honest.
565
+ return 'while no check was running — either between the population and the first'
566
+ + ' statement, or after the last one had filed its cost';
567
+ }
568
+ /**
569
+ * What ended the child, as a phrase.
570
+ *
571
+ * @param death - The ending the supervisor resolved
572
+ * @returns A clause naming the mechanism
573
+ */
574
+ function deathPhrase(death) {
575
+ if (death.kind === 'signal')
576
+ return `the child process was terminated by ${death.signal}`;
577
+ if (death.kind === 'spawn-failed') {
578
+ return `the child process could not be started at all (${death.binary}: ${death.detail})`;
579
+ }
580
+ if (death.kind === 'kill-failed') {
581
+ return 'the budget was blown and the SIGKILL that should have ended the run was REFUSED'
582
+ + ` (${death.detail})`;
583
+ }
584
+ if (death.kind === 'no-output') {
585
+ return `the child process exited ${death.code} and published no report at all`;
586
+ }
587
+ return 'the child process ended with neither an exit code nor a signal';
588
+ }
589
+ /**
590
+ * What to do about a child that never started.
591
+ *
592
+ * 🚨 **"Check your PATH" is the wrong thing to tell a saturated runner.**
593
+ * `spawn` fails with `EAGAIN` when the machine has no free process slot and
594
+ * `ENOMEM` when it has no free memory — which is precisely the memory-pressured
595
+ * environment this whole feature exists for, and precisely where an operator
596
+ * least deserves to be sent hunting an installation problem that is not there.
597
+ *
598
+ * @param detail - What `spawn` reported
599
+ * @returns The remedy sentence
600
+ */
601
+ function spawnFailedRemedy(detail) {
602
+ if (/\b(?:EAGAIN|ENOMEM)\b/.test(detail)) {
603
+ return ' The child could not be started because the RUNNER had nothing left to start it'
604
+ + ' with — EAGAIN is "no free process slot" and ENOMEM is "no free memory" — so this is'
605
+ + ' about how loaded the machine is, not about the installation and not about the SQL.'
606
+ + ' Give the job a less contended runner, or reduce what else is running beside it.';
607
+ }
608
+ return ' Nothing ran at all, so this is an installation or PATH problem rather than'
609
+ + ' anything about the SQL.';
610
+ }
611
+ /**
612
+ * What to do about a kill the kernel refused.
613
+ *
614
+ * ⛔ This must never be composed from the spawn-failure sentence, however
615
+ * similar the plumbing is. The two are opposites: one child never existed, and
616
+ * this one is, as far as this process can tell, STILL RUNNING.
617
+ *
618
+ * @param death - The refused kill, and the pid it left behind
619
+ * @returns The remedy sentence
620
+ */
621
+ function killFailedRemedy(detail, pid) {
622
+ const handle = pid === undefined
623
+ ? ' The child\'s pid is not known to this process.'
624
+ : ` The child was process ${pid}.`;
625
+ return ' The run started, ran past its budget, and then REFUSED TO DIE: the watchdog sent'
626
+ + ` SIGKILL and the kernel would not deliver it (${detail}), which normally means EPERM.`
627
+ + handle
628
+ + ' It may still be running and still holding whatever the statement was holding, so'
629
+ + ' check for it and end it by hand before starting another run. Nothing about the SQL'
630
+ + ' or the installation is implicated by the refusal itself — but the bound could not be'
631
+ + ' enforced, so treat this run as unsupervised.'
632
+ // 🚨 Its OWN budget-role sentence, because the generic one below is false
633
+ // here: this is the single abnormal death the watchdog causes. Saying "the
634
+ // watchdog never fired" would repeat, in the report, the exact confusion
635
+ // that produced the defect.
636
+ + ' The watchdog DID fire — the budget is what decided this run should stop — but the'
637
+ + ' kill was refused, so raising the budget only moves when the refusal happens. It does'
638
+ + ' not make the run stoppable.';
639
+ }
640
+ /**
641
+ * What to do about a particular signal.
642
+ *
643
+ * 🚨 **The defect this closes: the fallback contradicted the sentence beside
644
+ * it.** Only `SIGABRT` and `SIGKILL` were partitioned on and everything else
645
+ * fell through to a sentence written for `no-status` — so a real `kill -TERM`
646
+ * produced "terminated by SIGTERM … The cause is outside anything this command
647
+ * can observe; the child left no exit code to report". It names the signal, and
648
+ * then says it can see nothing and was told nothing. SIGSEGV, SIGBUS, SIGHUP and
649
+ * SIGXCPU read the same way.
650
+ *
651
+ * ⚠️ **SIGTERM is not exotic; it is how CI kills things** — `timeout(1)`, a
652
+ * cancelled job, a container stopping. The old claim that "SIGABRT and SIGKILL
653
+ * are the two that actually happen" was wrong about the commonest one.
654
+ *
655
+ * @param signal - The signal `close` reported
656
+ * @returns The remedy sentence
657
+ */
658
+ function signalRemedy(signal) {
659
+ if (signal === 'SIGABRT') {
660
+ return ' SIGABRT here is Node aborting on its own heap limit: the run exhausted memory'
661
+ + ' MATERIALISING a result set, which is what an unbounded statement does as soon as it'
662
+ + ' selects rows rather than an aggregate. The remedy is a narrower statement or a'
663
+ + ' `LIMIT`, not a bigger machine.';
664
+ }
665
+ if (signal === 'SIGKILL') {
666
+ return ' Nothing inside this run sends SIGKILL except the budget watchdog, and the watchdog'
667
+ + ' did not fire — so something OUTSIDE killed the child: a kernel OOM killer on a'
668
+ + ' memory-capped runner (it picks the largest process, which is this child and never the'
669
+ + ' idle parent), a container memory limit, or an operator. Give the job more memory, or'
670
+ + ' narrow the statement so it needs less.';
671
+ }
672
+ if (signal === 'SIGTERM' || signal === 'SIGINT' || signal === 'SIGHUP') {
673
+ return ` Nothing inside this run sends ${signal}: something OUTSIDE asked the child to stop.`
674
+ + ' On CI that is almost always a step or job timeout (`timeout(1)`, a workflow'
675
+ + ' `timeout-minutes`), a cancelled pipeline, or a container being shut down. Look at the'
676
+ + ' job\'s own time limit and at whether the run was cancelled — not at the SQL, which was'
677
+ + ' still working when it was interrupted.';
678
+ }
679
+ if (signal === 'SIGSEGV' || signal === 'SIGBUS') {
680
+ return ` ${signal} is a CRASH inside native code, not anything an adopter's SQL can express:`
681
+ + ' the process was killed by the kernel for touching memory it should not have. That'
682
+ + ' points at a native module (`node:sqlite` is one) or at the platform, and it is worth'
683
+ + ' reporting with the Node version, the platform and the statement that was running.';
684
+ }
685
+ return ` ${signal} was not sent by anything inside this run, and this command has no specific`
686
+ + ' remedy for it: look at what else on this machine signals its child processes — a'
687
+ + ' supervisor, a resource limit, or an operator.';
688
+ }
689
+ /**
690
+ * The exit code Node uses for its own fatal abort, on EVERY platform.
691
+ *
692
+ * 🚨 **This is measured, and it is NOT the local platform's signal number.** On
693
+ * macOS and Linux 134 is the shell encoding `128 + SIGABRT`, where `SIGABRT` is
694
+ * 6. On Windows `os.constants.signals.SIGABRT` is **22** — MSVC numbers it
695
+ * differently — so deriving the code from that table gives 150 and matches
696
+ * nothing Node ever emits. A `windows-latest` run of the heap-abort case
697
+ * reported exactly **134**, the same as Unix: the number comes from Node's own
698
+ * abort path, not from the platform's signal table.
699
+ *
700
+ * 🪤 An earlier version of this file DID derive it from `os.constants.signals`,
701
+ * and its unit test derived the same way — so the test agreed with the code, the
702
+ * round-trip passed on every machine, and the pair was wrong together on the one
703
+ * platform the branch exists for. The value is therefore pinned as a measured
704
+ * fact, and a test asserts the number itself rather than re-deriving it.
705
+ *
706
+ * ⛔ Not a version constant: it decides nothing about whether stored data is
707
+ * still valid. It is an external protocol value, like an API header.
708
+ */
709
+ export const NODE_FATAL_ABORT_EXIT_CODE = 134;
710
+ /**
711
+ * What to do about a child that exited with a code and said nothing.
712
+ *
713
+ * 🚨 **This is the SAME event as a signal death, seen on a platform without
714
+ * signals.** Windows has none, so Node reports its own fatal abort — the heap
715
+ * limit among them — as an exit code with `signal === null`, where macOS and
716
+ * Linux report `SIGABRT` with no code at all. An operator hitting the heap limit
717
+ * on Windows CI deserves the identical remedy as one hitting it on Linux, so the
718
+ * abort is recognised by its code and {@link signalRemedy} answers both.
719
+ *
720
+ * ⚠️ Every OTHER code gets a deliberately non-committal sentence. This command
721
+ * cannot tell an external termination it has no name for from a bug of its own,
722
+ * and claiming either would send half its readers to the wrong place — the exact
723
+ * defect {@link signalRemedy}'s fall-through was written to fix.
724
+ *
725
+ * @param code - The exit code the child reported
726
+ * @returns The remedy sentence
727
+ */
728
+ function noOutputRemedy(code) {
729
+ if (code === NODE_FATAL_ABORT_EXIT_CODE) {
730
+ return ` Exit ${code} is Node's own fatal abort. A platform with no signal to report — Windows`
731
+ + ' — surfaces as an exit code what macOS and Linux report as SIGABRT itself, and it is the'
732
+ + ' same event.' + signalRemedy('SIGABRT');
733
+ }
734
+ return ` Exit ${code} is not Node's abort code, and the child published none of the document`
735
+ + ' every path through this command ends by writing — so this is either a termination from'
736
+ + ' outside that left an exit code instead of a signal, or a defect in vat. The child\'s stderr'
737
+ + ' was inherited, so whatever it managed to say is above this report; if there is nothing'
738
+ + ' there either, report this exit code with the platform and the statement that was running.';
739
+ }
740
+ /**
741
+ * What to DO about it — and the reason the death is carried this far as a union
742
+ * rather than as a string.
743
+ *
744
+ * 🔑 Every kind wants a DIFFERENT file looked at: SIGABRT is the statement,
745
+ * SIGKILL is the runner's memory, SIGTERM is the job's own timeout, a failed
746
+ * spawn is either the installation or the runner's load, and a refused kill is a
747
+ * process still running. A message that conflated any two of them would send
748
+ * half its readers to the wrong place.
749
+ *
750
+ * ⛔ **The last line is for `no-status` and nothing else.** It is the only
751
+ * ending about which this command genuinely knows nothing, and it used to be
752
+ * the fall-through for every unpartitioned signal — which produced messages
753
+ * that named a signal and then denied having any information about it.
754
+ *
755
+ * @param death - The ending the supervisor resolved
756
+ * @returns The remedy sentence
757
+ */
758
+ function deathRemedy(death) {
759
+ if (death.kind === 'kill-failed')
760
+ return killFailedRemedy(death.detail, death.pid);
761
+ if (death.kind === 'spawn-failed')
762
+ return spawnFailedRemedy(death.detail) + WATCHDOG_UNINVOLVED;
763
+ if (death.kind === 'signal')
764
+ return signalRemedy(death.signal) + WATCHDOG_UNINVOLVED;
765
+ if (death.kind === 'no-output')
766
+ return noOutputRemedy(death.code) + WATCHDOG_UNINVOLVED;
767
+ return ' The cause is outside anything this command can observe; the child left no exit code'
768
+ + ' to report.' + WATCHDOG_UNINVOLVED;
769
+ }
770
+ /**
771
+ * The sentence that keeps an abnormal death from being blamed on the bound.
772
+ *
773
+ * ⛔ Appended by every arm of {@link deathRemedy} EXCEPT the refused kill, which
774
+ * writes its own — because there the watchdog is exactly what fired, and this
775
+ * sentence would repeat in the report the confusion that produced the defect:
776
+ * an `error` event read as a spawn failure, so `killed` was never consulted.
777
+ */
778
+ const WATCHDOG_UNINVOLVED = ' The budget was not what ended this run — the watchdog never fired — so raising it'
779
+ + ' would not help.';
780
+ /**
781
+ * The sentence that says what this document does NOT contain.
782
+ *
783
+ * 🚨 Load-bearing, and shared by both endings. The progress log records COSTS,
784
+ * not findings: a check that completed contributes its `rows` (how many rows its
785
+ * statement selected) but not the violations those rows would have become,
786
+ * because the child never got to turn them into issues. A reader who took the
787
+ * issue list as the complete account would conclude the finished rules found
788
+ * nothing.
789
+ */
790
+ const INCOMPLETE_NOTICE = ' ⚠️ The checks listed under `checks` DID complete and `rows` is what each'
791
+ + ' statement selected, but their individual violations are NOT in `issues`: the'
792
+ + ' progress log records costs, not findings. Read that list as incomplete.';
793
+ /**
794
+ * The finding for a run that was interrupted, however it was interrupted.
795
+ *
796
+ * 🪤 **`RESOURCE_CHECK_BROKEN`, and for the reason {@link emptyCorpusFinding}
797
+ * gives.** It is the same run-integrity claim — *these assertions did not
798
+ * execute, so the green means nothing* — and it needs the identical
799
+ * non-overridability, which `ValidationConfigSchema` grants by refusing that
800
+ * code as a `severity` key. An interrupted run must never be silenceable by the
801
+ * config of the very project whose SQL hung.
802
+ *
803
+ * @param inFlight - What the run was doing when the log stopped growing
804
+ * @param ending - Which way the run ended, and what the operator can do
805
+ * @returns The run-integrity finding
806
+ */
807
+ function interruptedRunFinding(inFlight, ending) {
808
+ const where = inFlightPhrase(inFlight);
809
+ const message = ending.kind === 'budget'
810
+ ? budgetKillMessage(inFlight, ending.budgetSecs, where)
811
+ : `This run DIED before it finished: ${deathPhrase(ending.death)} ${where}, so the checks`
812
+ + ' after it never executed and this document is not a verdict.'
813
+ + deathRemedy(ending.death);
814
+ return { code: 'RESOURCE_CHECK_BROKEN', severity: 'error', message: message + INCOMPLETE_NOTICE };
815
+ }
816
+ /**
817
+ * What a WATCHDOG kill means, which depends entirely on what was in flight.
818
+ *
819
+ * 🚨 **The defect this closes: the `reporting` state got the statement-hang
820
+ * body.** Creating that state adapted only the *where* clause, so a run whose
821
+ * ten checks had ALL completed and whose 1,000,000-issue document blew the
822
+ * budget was told "the checks after it never executed" and "a statement that
823
+ * will not finish cannot be stopped from inside the process — the query is
824
+ * synchronous and holds the event loop". Measured on that run: `checksRun: 10`,
825
+ * all ten costs present, no statement running, nothing holding the loop for any
826
+ * SQL reason. The operator was sent to hunt a hanging rule that does not exist,
827
+ * and the message never mentioned the thing that actually killed the run.
828
+ *
829
+ * ⚠️ It is easy to reach on a real gate, not a curiosity: serialising 2,000,000
830
+ * issues takes ~17 s after `checks-complete` is filed, which is one budget
831
+ * window on its own.
832
+ *
833
+ * @param inFlight - What the log's last state says
834
+ * @param budgetSecs - The bound that was blown
835
+ * @param where - The clause naming the unit, from {@link inFlightPhrase}
836
+ * @returns The message body
837
+ */
838
+ function budgetKillMessage(inFlight, budgetSecs, where) {
839
+ if (inFlight.kind === 'reporting') {
840
+ return `This run made no progress for ${budgetSecs}s and was killed ${where}.`
841
+ + ' Every declared check had already finished and filed its cost, so no statement was'
842
+ + ' running and there was nothing left to execute: the cause is the SIZE of the document'
843
+ + ' being assembled, not a rule that would not return. Serialising a large result is'
844
+ + ' linear in how much there is — 2,000,000 issues take about 17 seconds — so look at'
845
+ + ' the rules under `checks` that produced the most rows and narrow them (a `LIMIT`, or'
846
+ + ' one row per violation rather than one per member), or raise the bound with'
847
+ + ' `--budget <seconds>` so the document has time to be written.';
848
+ }
849
+ return `This run made no progress for ${budgetSecs}s and was killed ${where}, so the`
850
+ + ' checks after it never executed and this document is not a verdict.'
851
+ + ' A statement that will not finish cannot be stopped from inside the process —'
852
+ + ' the query is synchronous and holds the event loop — so the bound is enforced by'
853
+ + ' killing the run. Raise it with `--budget <seconds>` if the work is legitimately'
854
+ + ' slow, or `--budget 0` to remove it (the run can then hang forever).';
855
+ }
856
+ /**
857
+ * The refusal when there is no population line to build a document from.
858
+ *
859
+ * Separate wording per ending for the same reason the finding is: telling an
860
+ * operator whose child was OOM-killed to raise `--budget` sends them to change
861
+ * the one thing that had nothing to do with it.
862
+ *
863
+ * @param ending - Which way the run ended
864
+ * @returns The operator-error message
865
+ */
866
+ function noPopulationMessage(ending) {
867
+ const tail = ' There is no projection to report on and no honest document to publish.';
868
+ if (ending.kind === 'abnormal') {
869
+ return `This run DIED before its population completed: ${deathPhrase(ending.death)}.`
870
+ + tail + deathRemedy(ending.death);
871
+ }
872
+ return `The run made no progress for ${ending.budgetSecs}s and was killed before its`
873
+ + ' population completed.' + tail
874
+ + ' Population is legitimately slow on a large tree, and it reports progress only when it'
875
+ + ' FINISHES, so the budget is a total bound for that one unit — raise it with'
876
+ + ' `--budget <seconds>` before assuming the crawl is stuck.'
877
+ + ' For scale, two MEASURED trees, neither of them yours: VAT\'s own repository is ~1.2s'
878
+ + ' warm and 33-35s with a cold parse cache; a 10,000-file adopter tree is ~5s warm and'
879
+ + ' 16.5s cold. Your tree is a third number.';
880
+ }
881
+ /**
882
+ * Rebuild the document from what an INTERRUPTED run left on disk.
883
+ *
884
+ * Both interruptions come here — the watchdog's kill and an abnormal death —
885
+ * because everything below this line is identical for them: the same recovered
886
+ * costs, the same non-overridable finding, the same refusal when there is no
887
+ * population. Only the sentences differ, and {@link CheckRunEnding} carries that.
888
+ *
889
+ * 🔑 It feeds {@link buildCheckOutputData}, the SAME builder a completed run
890
+ * uses. A second payload builder for this lane would be a second shape to
891
+ * keep in step with every future field — and the failure mode is silent, because
892
+ * nobody reads an interrupted run's document until the day they need it.
893
+ *
894
+ * 🚨 **A run interrupted during POPULATION gets no document at all.** There is no
895
+ * projection, so `population`, `populationSecs` and `membersEnumerated` have no
896
+ * honest value — and inventing `membersEnumerated: 0` would be worse than a
897
+ * blank, because that value already MEANS "this gate ran over an empty corpus",
898
+ * which is a different and wrong claim. The throw becomes an operator error
899
+ * (exit 2) through the same `handleCommandError` path everything else here uses.
900
+ *
901
+ * @param options - The wreckage
902
+ * @param options.entries - What `parseProgressLog` recovered
903
+ * @param options.root - The corpus root the parent resolved
904
+ * @param options.ending - Which way the run was interrupted
905
+ * @param options.durationMs - Wall time from spawn to ending
906
+ * @returns The input to {@link buildCheckOutputData}
907
+ * @throws When the child never reported a completed population
908
+ */
909
+ export function buildInterruptedCheckInput(options) {
910
+ const { entries, root, ending, durationMs } = options;
911
+ const population = entries.find((entry) => entry.kind === 'population');
912
+ if (population === undefined)
913
+ throw new Error(noPopulationMessage(ending));
914
+ return {
915
+ root,
916
+ durationMs,
917
+ population: population.population,
918
+ populationMs: population.populationMs,
919
+ membersEnumerated: population.membersEnumerated,
920
+ issues: [interruptedRunFinding(unitInFlight(entries), ending)],
921
+ // 🪤 Rebuilt field by field rather than passed through. The log's check
922
+ // entry carries a `kind` discriminator that `CheckCost` does not, and the
923
+ // conditional spreads keep `rows`/`broken` ABSENT rather than `undefined` —
924
+ // which is what makes `rows: 0` on a statement that never returned
925
+ // impossible, exactly as it is on the completed path.
926
+ costs: entries.filter((entry) => entry.kind === 'check').map((entry) => ({
927
+ name: entry.name,
928
+ durationMs: entry.durationMs,
929
+ ...(entry.rows === undefined ? {} : { rows: entry.rows }),
930
+ ...(entry.broken === undefined ? {} : { broken: entry.broken }),
931
+ })),
932
+ };
933
+ }
934
+ /**
935
+ * Run the declared checks and resolve their severities — the whole of this
936
+ * verb's logic, with the database taken out.
937
+ *
938
+ * Exported and pure so the loop is provable without a spawn. It was not: every
939
+ * spawned case declared ONE check (the `--check` case declares two and filters
940
+ * to one, which the loop cannot distinguish), so `checksRun` was never observed
941
+ * above 1 and a `break` at the end of the loop would have left the suite green.
942
+ *
943
+ * Pure in the same sense the payload builder is, with ONE seam: it reads a
944
+ * clock. That is why the clock is a parameter — the measurement is the point of
945
+ * the `costs` it returns, and a duration nothing can pin is a number nobody can
946
+ * trust.
947
+ *
948
+ * @param options - The run
949
+ * @param options.checks - The declared checks
950
+ * @param options.only - A single check key, or undefined for all
951
+ * @param options.ask - Runs one statement against the populated projection
952
+ * @param options.validation - The project's severity overrides, or undefined
953
+ * @param options.membersEnumerated - How many members the population enumerated.
954
+ * Told rather than asked, because no answer can reveal it: an `ask` over an
955
+ * empty projection and an `ask` over a clean one both return no rows
956
+ * @param options.now - The measurement clock in milliseconds, defaulting to
957
+ * `performance.now()`. Injected only so a unit test can pin an exact per-check
958
+ * duration; nothing in production passes it
959
+ * @param options.onProgress - Where each check's `start` and cost are announced
960
+ * so an outside supervisor can see them, or undefined when the run is not
961
+ * supervised. Optional rather than required because `--budget 0` and every
962
+ * unit case run with nobody watching
963
+ * @returns The resolved findings, and one cost record per check that ran
964
+ */
965
+ export function runDeclaredChecks(options) {
966
+ const now = options.now ?? (() => performance.now());
967
+ const { issues, costs } = runChecks(options.checks, options.only, options.ask, now, options.onProgress);
968
+ // 🚨 HERE, and this line moved to get here. `checks-complete` buys the phase
969
+ // after the last statement a fresh watchdog window, and three places
970
+ // documented that phase as "resolving severities and serialising". It was
971
+ // emitted a level up, by `runOutcome`, AFTER this function returned — so
972
+ // `resolveIssueSeverity` below ran inside the LAST CHECK's window and the
973
+ // documented claim was false for the half of the phase that is quadratic in
974
+ // the issue count. Emitting it before that call is what makes the claim true,
975
+ // which is the useful direction: the alternative was to weaken three docs.
976
+ //
977
+ // ⚠️ It does NOT cover the last check's row-to-issue conversion, which
978
+ // `runChecks` does after filing that check's cost — see {@link inFlightPhrase}
979
+ // for why the `idle` sentence still has to hedge for that window.
980
+ options.onProgress?.({ kind: 'checks-complete' });
981
+ // The run-integrity report leads. An aggregate check selects a row whatever
982
+ // the corpus is, so its findings can outnumber this one — and every one of
983
+ // them is noise until the operator knows the gate ran over nothing.
984
+ const reported = [...emptyCorpusFinding(costs.length, options.membersEnumerated), ...issues];
985
+ return {
986
+ // The adopter's own `resources.validation.severity` still applies, so a
987
+ // check inherited from a shared config can be downgraded or ignored without
988
+ // editing it. Applied AFTER the run, never before: a check that is going to
989
+ // be ignored must still be EXECUTED, or "ignored" would quietly become
990
+ // "never checked" and those are different claims.
991
+ //
992
+ // It reaches a check's VIOLATIONS only. `RESOURCE_CHECK_BROKEN` is not an
993
+ // overridable code, so the news that a check stopped checking survives every
994
+ // override an adopter can write about the check itself.
995
+ issues: resolveIssueSeverity(reported, options.validation),
996
+ costs,
997
+ };
998
+ }
999
+ /**
1000
+ * Serialize the document in the format the operator asked for.
1001
+ *
1002
+ * Extracted because BOTH endings publish one — a completed run and a killed one
1003
+ * — and two copies of a two-branch format switch is how one of them ends up
1004
+ * ignoring `--format json`.
1005
+ *
1006
+ * @param payload - The document
1007
+ * @param format - `json`, or anything else for YAML
1008
+ */
1009
+ function emitCheckDocument(payload, format) {
1010
+ if (format === 'json') {
1011
+ writeJsonOutput(payload);
1012
+ }
1013
+ else {
1014
+ writeYamlOutput(payload);
1015
+ }
1016
+ }
1017
+ /**
1018
+ * The child's argv — this very command, plus the hidden flag that stops it
1019
+ * spawning a child of its own.
1020
+ *
1021
+ * 🚨 `--cost-log` is ALWAYS appended, and that is the recursion guard. Its
1022
+ * presence is the sole discriminator between "supervise a child" and "do the
1023
+ * work"; a path that built these arguments without it would fork bomb.
1024
+ *
1025
+ * @param pathArg - The corpus root the operator named, or undefined
1026
+ * @param options - What the operator passed
1027
+ * @param logPath - The progress log both sides watch
1028
+ * @returns The child's arguments after the CLI entry point
1029
+ */
1030
+ function childArgs(pathArg, options, logPath) {
1031
+ return [
1032
+ 'resources',
1033
+ 'check',
1034
+ ...(pathArg === undefined ? [] : [pathArg]),
1035
+ ...(options.check === undefined ? [] : ['--check', options.check]),
1036
+ ...(options.format === undefined ? [] : ['--format', options.format]),
1037
+ ...(options.debug === true ? ['--debug'] : []),
1038
+ '--cost-log',
1039
+ logPath,
1040
+ ];
1041
+ }
1042
+ /**
1043
+ * Run the checks in a child bounded by `budgetSecs`, and report either what it
1044
+ * said or what killing it revealed.
1045
+ *
1046
+ * 🪤 The endings are RETURNED rather than exited on from inside
1047
+ * `withProgressLog`. `process.exit` skips a `finally`, so exiting in there would
1048
+ * leak the temp directory on every single run — and it is the successful runs,
1049
+ * which are all of them but one, that would accumulate.
1050
+ *
1051
+ * @param options - The run
1052
+ * @param options.pathArg - The corpus root the operator named, or undefined
1053
+ * @param options.options - What the operator passed
1054
+ * @param options.budgetSecs - The bound, in seconds
1055
+ * @returns What to publish, and with what exit code
1056
+ * @throws When the child was killed before its population completed
1057
+ */
1058
+ async function superviseCheckRun(options) {
1059
+ const startDir = options.pathArg ?? process.cwd();
1060
+ // 🪤 The SILENT resolution, deliberately. `projectRootOrLoudCwd` warns on
1061
+ // stderr when there is no project ancestor, and the CHILD runs that policy for
1062
+ // real — a parent that ran it too would print the same warning twice for one
1063
+ // run. The parent needs a root only to label a KILLED run's document.
1064
+ const root = projectRootOrNull(startDir) ?? safePath.resolve(startDir);
1065
+ return withProgressLog(async (logPath) => {
1066
+ const run = await superviseCheck({
1067
+ args: childArgs(options.pathArg, options.options, logPath),
1068
+ logPath,
1069
+ budgetMs: options.budgetSecs * 1000,
1070
+ });
1071
+ if (run.outcome === 'completed')
1072
+ return { forward: run.stdout, code: run.code };
1073
+ return {
1074
+ payload: buildCheckOutputData(buildInterruptedCheckInput({
1075
+ entries: parseProgressLog(run.log),
1076
+ root,
1077
+ // 🚨 The abnormal ending reaches the SAME fail-closed document the
1078
+ // watchdog's kill does. It used to reach `{ outcome: 'completed', code:
1079
+ // 0 }` instead, because the close handler never read `close`'s signal
1080
+ // argument — so a child that aborted on its heap limit printed nothing
1081
+ // and exited 0, on precisely the runaway shape the bound exists for.
1082
+ ending: run.outcome === 'killed'
1083
+ ? { kind: 'budget', budgetSecs: options.budgetSecs }
1084
+ : { kind: 'abnormal', death: run.death },
1085
+ durationMs: run.elapsedMs,
1086
+ })),
1087
+ };
1088
+ });
1089
+ }
1090
+ /**
1091
+ * Run the project's declared checks against its projection.
1092
+ *
1093
+ * ## 🚨 Why this verb spawns a child and `vat resources query` does not
1094
+ *
1095
+ * Both verbs run SQL an adopter wrote, over the same projection, through the
1096
+ * same `withQueriedProjection` — which does NOT diverge here, and must not. The
1097
+ * fork is this function and nothing below it.
1098
+ *
1099
+ * `query`'s author is at the keyboard, and Ctrl-C already works for them. It
1100
+ * works for one reason worth stating, because it is fragile: NO signal handler
1101
+ * is installed anywhere on this path. A process blocked in synchronous
1102
+ * `node:sqlite` dies instantly on SIGINT while that remains true, and SURVIVES
1103
+ * SIGINT and SIGTERM the moment a handler exists — the handler is a JS callback
1104
+ * and the event loop that would schedule it is the blocked resource. Adding one
1105
+ * would take Ctrl-C away from `query` without adding anything to `check`.
1106
+ *
1107
+ * `check` runs unattended in CI, where nobody is there to press it. So it gets a
1108
+ * bound enforced from outside itself, and a hang becomes a bounded failure
1109
+ * instead of a job that burns its runner minutes and reports nothing.
1110
+ *
1111
+ * @param pathArg - The corpus root, or omitted for the current directory
1112
+ * @param options - Parsed command-line options
1113
+ */
1114
+ export async function checkCommand(pathArg, options) {
1115
+ const logger = createLogger({ debug: options.debug ?? false });
1116
+ const startTime = Date.now();
1117
+ try {
1118
+ const budgetSecs = parseBudgetSeconds(options.budget);
1119
+ // Before anything is spawned: a budget the fork below would silently ignore
1120
+ // is an operator error, not a bound.
1121
+ requireSupervisableFlags({
1122
+ costLog: options.costLog,
1123
+ budgetRaw: options.budget,
1124
+ budgetSecs,
1125
+ });
1126
+ // The presence of `--cost-log` says "you ARE the child". `--budget 0` says
1127
+ // the operator declined the bound; both run the work here, and the second
1128
+ // can therefore hang forever, which its help text says.
1129
+ if (!runsInThisProcess({ costLog: options.costLog, budgetSecs })) {
1130
+ const ending = await superviseCheckRun({ pathArg, options, budgetSecs });
1131
+ if ('forward' in ending) {
1132
+ // Verbatim. The child already built the document the operator's
1133
+ // `--format` asked for, and re-serializing it here would be a second
1134
+ // place for that shape to live.
1135
+ //
1136
+ // ⚠️ `durationSecs` in it is therefore the CHILD's wall time and does
1137
+ // not include this process's own startup and spawn (~0.15 s measured).
1138
+ // Deliberate: the field is a breakdown that `populationSecs` and the
1139
+ // per-rule costs have to reconcile against, and folding in a supervisor
1140
+ // overhead none of them can account for would leave a remainder a
1141
+ // reader would attribute to whichever rule they were looking at — the
1142
+ // exact defect `CheckCost` documents.
1143
+ writeStdoutSync(ending.forward);
1144
+ process.exit(ending.code);
1145
+ }
1146
+ emitCheckDocument(ending.payload, options.format);
1147
+ // ⛔ Never 0. A run that did not finish — killed by the watchdog OR dead of
1148
+ // its own memory — must not look like a pass.
1149
+ process.exit(1);
1150
+ }
1151
+ const projectRoot = projectRootOrLoudCwd(pathArg ?? process.cwd(), logger);
1152
+ const config = loadConfigCached(projectRoot);
1153
+ const checks = config?.resources?.checks;
1154
+ if (checks === undefined || Object.keys(checks).length === 0) {
1155
+ // Loud, and exit 0. Declaring no checks is a legitimate state — most
1156
+ // projects have none — but a run that silently printed a passing report
1157
+ // would let a config typo read as a green gate forever.
1158
+ logger.warn('No checks are declared. Add them under `resources.checks` in'
1159
+ + ' vibe-agent-toolkit.config.yaml; each is a description plus one SQL'
1160
+ + ' statement selecting the rows that VIOLATE it.');
1161
+ }
1162
+ // Before the crawl: a mistyped flag is an operator error and pays nothing.
1163
+ requireDeclaredCheck(checks ?? {}, options.check);
1164
+ // Also before the crawl, and for the same reason: an override that overrides
1165
+ // nothing is knowable from the config alone, and the operator should read it
1166
+ // beside the other config news rather than after a full population.
1167
+ warnUndeclaredOverrides(checks ?? {}, config?.resources?.validation, logger);
1168
+ const outcome = await runOutcome({
1169
+ root: projectRoot,
1170
+ checks: checks ?? {},
1171
+ only: options.check,
1172
+ logger,
1173
+ validation: config?.resources?.validation,
1174
+ onProgress: options.costLog === undefined
1175
+ ? undefined
1176
+ : createProgressWriter(options.costLog),
1177
+ });
1178
+ const payload = buildCheckOutputData({
1179
+ ...outcome,
1180
+ root: projectRoot,
1181
+ durationMs: Date.now() - startTime,
1182
+ });
1183
+ emitCheckDocument(payload, options.format);
1184
+ process.exit(payload['status'] === 'error' ? 1 : 0);
1185
+ }
1186
+ catch (error) {
1187
+ handleCommandError(error, logger, startTime, 'Check', options.format);
1188
+ }
1189
+ }
1190
+ /**
1191
+ * Populate once, run the checks, resolve their severities.
1192
+ *
1193
+ * @param options - The run
1194
+ * @param options.root - Absolute corpus root
1195
+ * @param options.checks - The declared checks
1196
+ * @param options.only - A single check key, or undefined for all
1197
+ * @param options.logger - Where blob-stage refusals are reported
1198
+ * @param options.validation - The project's severity overrides, or undefined
1199
+ * @param options.onProgress - Where each unit is announced for a supervisor
1200
+ * outside this process, or undefined when nothing is watching
1201
+ * @returns The findings and the provenance of the population behind them
1202
+ */
1203
+ async function runOutcome(options) {
1204
+ const { root, checks, only, logger, validation, onProgress } = options;
1205
+ // 🪤 **No `preflight` here, deliberately.** `withQueriedProjection` can compile
1206
+ // statements against the empty schema before populating — `vat resources query`
1207
+ // passes its one statement and gets a typo back in milliseconds instead of
1208
+ // after a full population. This verb must NOT, because a check that cannot run
1209
+ // is not an operator error here: it is a `RESOURCE_CHECK_BROKEN` finding naming
1210
+ // the check, at `error`, in a document that also reports how big the corpus was
1211
+ // and what the population cost. Failing early would trade that document for a
1212
+ // bare throw, and the finding exists precisely so a renamed column cannot end a
1213
+ // gate quietly. The population is the price of an honest report, and it is
1214
+ // charged once for every check rather than per broken one.
1215
+ return withQueriedProjection({ root, logger }, (ask, provenance, extent) => {
1216
+ // 🔑 Emitted HERE, inside the callback, because this is the one place where
1217
+ // the provenance and the extent are both exactly in hand — and emitted the
1218
+ // INSTANT population completes, because its arrival is what tells a
1219
+ // supervisor that a kill has a projection to report on. It also resets the
1220
+ // watchdog, so the population is bounded by the budget without being charged
1221
+ // the first check's share of it.
1222
+ onProgress?.({
1223
+ kind: 'population',
1224
+ population: provenance.population,
1225
+ populationMs: provenance.populationMs,
1226
+ membersEnumerated: extent.membersEnumerated,
1227
+ });
1228
+ // 🪤 `checks-complete` is NOT emitted here. It used to be, and that put
1229
+ // `resolveIssueSeverity` — which runs inside `runDeclaredChecks` — on the
1230
+ // wrong side of the line, still charged to the last check's budget window
1231
+ // while three separate docs said otherwise. It is emitted by
1232
+ // `runDeclaredChecks` itself now, the instant the last statement is done.
1233
+ const ran = runDeclaredChecks({ checks, only, ask, validation, ...extent, onProgress });
1234
+ return { ...ran, ...provenance, ...extent };
1235
+ });
1236
+ }
1237
+ //# sourceMappingURL=check.js.map