@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.
- package/dist/bin.js +35 -2
- package/dist/bin.js.map +1 -1
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +169 -17
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/claude/claude-config.d.ts.map +1 -1
- package/dist/commands/claude/claude-config.js +6 -0
- package/dist/commands/claude/claude-config.js.map +1 -1
- package/dist/commands/claude/org/helpers.d.ts +59 -1
- package/dist/commands/claude/org/helpers.d.ts.map +1 -1
- package/dist/commands/claude/org/helpers.js +58 -7
- package/dist/commands/claude/org/helpers.js.map +1 -1
- package/dist/commands/claude/org/index.d.ts.map +1 -1
- package/dist/commands/claude/org/index.js +24 -16
- package/dist/commands/claude/org/index.js.map +1 -1
- package/dist/commands/claude/org/skills.d.ts +428 -0
- package/dist/commands/claude/org/skills.d.ts.map +1 -1
- package/dist/commands/claude/org/skills.js +1189 -91
- package/dist/commands/claude/org/skills.js.map +1 -1
- package/dist/commands/claude/org/users.d.ts.map +1 -1
- package/dist/commands/claude/org/users.js +3 -1
- package/dist/commands/claude/org/users.js.map +1 -1
- package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
- package/dist/commands/claude/org/workspaces.js +5 -2
- package/dist/commands/claude/org/workspaces.js.map +1 -1
- package/dist/commands/claude/plugin/install.js +0 -1
- package/dist/commands/claude/plugin/install.js.map +1 -1
- package/dist/commands/corpus/runner.d.ts.map +1 -1
- package/dist/commands/corpus/runner.js +1 -19
- package/dist/commands/corpus/runner.js.map +1 -1
- package/dist/commands/inventory.js +1 -1
- package/dist/commands/inventory.js.map +1 -1
- package/dist/commands/resources/check-progress.d.ts +145 -0
- package/dist/commands/resources/check-progress.d.ts.map +1 -0
- package/dist/commands/resources/check-progress.js +205 -0
- package/dist/commands/resources/check-progress.js.map +1 -0
- package/dist/commands/resources/check-supervisor.d.ts +465 -0
- package/dist/commands/resources/check-supervisor.d.ts.map +1 -0
- package/dist/commands/resources/check-supervisor.js +566 -0
- package/dist/commands/resources/check-supervisor.js.map +1 -0
- package/dist/commands/resources/check.d.ts +454 -0
- package/dist/commands/resources/check.d.ts.map +1 -0
- package/dist/commands/resources/check.js +1237 -0
- package/dist/commands/resources/check.js.map +1 -0
- package/dist/commands/resources/index.d.ts.map +1 -1
- package/dist/commands/resources/index.js +277 -6
- package/dist/commands/resources/index.js.map +1 -1
- package/dist/commands/resources/query.d.ts +89 -0
- package/dist/commands/resources/query.d.ts.map +1 -0
- package/dist/commands/resources/query.js +146 -0
- package/dist/commands/resources/query.js.map +1 -0
- package/dist/commands/resources/scan.js +1 -1
- package/dist/commands/resources/scan.js.map +1 -1
- package/dist/commands/skill/review-checklist.d.ts.map +1 -1
- package/dist/commands/skill/review-checklist.js +35 -0
- package/dist/commands/skill/review-checklist.js.map +1 -1
- package/dist/commands/skill/test/configure.d.ts +37 -0
- package/dist/commands/skill/test/configure.d.ts.map +1 -1
- package/dist/commands/skill/test/configure.js +57 -12
- package/dist/commands/skill/test/configure.js.map +1 -1
- package/dist/commands/skills/install.js +0 -1
- package/dist/commands/skills/install.js.map +1 -1
- package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
- package/dist/commands/skills/skill-discovery.js +4 -2
- package/dist/commands/skills/skill-discovery.js.map +1 -1
- package/dist/utils/agent-runner.d.ts.map +1 -1
- package/dist/utils/agent-runner.js +7 -3
- package/dist/utils/agent-runner.js.map +1 -1
- package/dist/utils/command-error.d.ts +56 -1
- package/dist/utils/command-error.d.ts.map +1 -1
- package/dist/utils/command-error.js +66 -3
- package/dist/utils/command-error.js.map +1 -1
- package/dist/utils/config-loader.d.ts.map +1 -1
- package/dist/utils/config-loader.js +30 -7
- package/dist/utils/config-loader.js.map +1 -1
- package/dist/utils/issue-severity.d.ts +12 -5
- package/dist/utils/issue-severity.d.ts.map +1 -1
- package/dist/utils/issue-severity.js +1 -1
- package/dist/utils/issue-severity.js.map +1 -1
- package/dist/utils/projection-query.d.ts +263 -0
- package/dist/utils/projection-query.d.ts.map +1 -0
- package/dist/utils/projection-query.js +262 -0
- package/dist/utils/projection-query.js.map +1 -0
- package/dist/utils/projection-store.d.ts +104 -2
- package/dist/utils/projection-store.d.ts.map +1 -1
- package/dist/utils/projection-store.js +126 -4
- package/dist/utils/projection-store.js.map +1 -1
- package/dist/utils/vat-bin-path.d.ts +36 -0
- package/dist/utils/vat-bin-path.d.ts.map +1 -0
- package/dist/utils/vat-bin-path.js +49 -0
- package/dist/utils/vat-bin-path.js.map +1 -0
- 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
|