@erclx/aitk 3.51.1 → 3.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/scripts/watch.sh +74 -3
- package/docs/agents/audits.md +1 -1
- package/docs/agents/commands.md +2 -0
- package/docs/agents/gate.md +84 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/markdown-audit.md +3 -3
- package/docs/agents/sessions.md +8 -0
- package/docs/agents/state-scoped-risk.md +1 -1
- package/package.json +3 -3
- package/scripts/core/list-seed-roots.sh +18 -0
- package/scripts/core/repair-bare-flag.sh +19 -0
- package/scripts/core/update.sh +3 -3
- package/src/audits/catalog.ts +2 -2
- package/src/audits/run.ts +2 -1
- package/src/cli.ts +4 -0
- package/src/commands/claude.ts +2 -2
- package/src/commands/context.ts +2 -2
- package/src/commands/gate.ts +189 -0
- package/src/commands/sessions.ts +27 -1
- package/src/gate/measures.ts +682 -0
- package/src/gate/sequencer.ts +386 -0
- package/src/gate/stages.ts +412 -0
- package/src/sessions/registry.ts +17 -0
- package/src/sessions/resolve.ts +44 -2
- package/scripts/core/verify.sh +0 -694
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export interface CommandResult {
|
|
6
|
+
readonly exitCode: number
|
|
7
|
+
readonly stdout: string
|
|
8
|
+
readonly stderr: string
|
|
9
|
+
/**
|
|
10
|
+
* The two streams interleaved, which is what `2>&1` handed the frame in the
|
|
11
|
+
* script this replaces. A stage borrowing a command's own output pipes this
|
|
12
|
+
* rather than either stream alone.
|
|
13
|
+
*/
|
|
14
|
+
readonly all: string
|
|
15
|
+
/**
|
|
16
|
+
* Set when the binary could not be started at all, which is a different state
|
|
17
|
+
* from a binary that ran and refused. An absent tool on a contributor's
|
|
18
|
+
* machine is somebody mid-setup, and an absent tool under CI is a broken
|
|
19
|
+
* workflow step, so the two have to stay distinguishable.
|
|
20
|
+
*/
|
|
21
|
+
readonly spawnError?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type RunCommand = (argv: readonly string[]) => Promise<CommandResult>
|
|
25
|
+
|
|
26
|
+
export interface MeasureContext {
|
|
27
|
+
readonly root: string
|
|
28
|
+
/**
|
|
29
|
+
* True where this run is the merge gate rather than a contributor's machine.
|
|
30
|
+
* It decides nothing about what a stage measures and everything about what an
|
|
31
|
+
* absent input means, which is why it reaches a measure at all.
|
|
32
|
+
*/
|
|
33
|
+
readonly ci: boolean
|
|
34
|
+
/** Any binary, run from the project root. */
|
|
35
|
+
readonly run: RunCommand
|
|
36
|
+
/**
|
|
37
|
+
* This checkout's own CLI rather than whatever `aitk` resolves to on PATH. A
|
|
38
|
+
* globally installed binary resolves to the main checkout no matter which
|
|
39
|
+
* worktree is running, so a gate reading through it would measure the wrong
|
|
40
|
+
* tree and report a pass over a branch it never opened.
|
|
41
|
+
*/
|
|
42
|
+
readonly cli: RunCommand
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Emission =
|
|
46
|
+
| { readonly kind: 'info'; readonly text: string }
|
|
47
|
+
| { readonly kind: 'warn'; readonly text: string }
|
|
48
|
+
/** Borrowed output, indented under the stage the way `pipe_output` did. */
|
|
49
|
+
| { readonly kind: 'output'; readonly text: string }
|
|
50
|
+
|
|
51
|
+
export interface MeasureReport {
|
|
52
|
+
readonly emissions: readonly Emission[]
|
|
53
|
+
/** The stage read its input and found a fact, so the run stops here. */
|
|
54
|
+
readonly failure?: string
|
|
55
|
+
/**
|
|
56
|
+
* The stage could not read its input at all, so it has no verdict to give.
|
|
57
|
+
*
|
|
58
|
+
* Distinct from a pass with nothing to report, which is what six places in
|
|
59
|
+
* the script this replaces used to print. A skip rendered as a passing line
|
|
60
|
+
* reports the pass the stage exists to withhold, so the sequencer renders
|
|
61
|
+
* this as a warning on a contributor's machine and refuses on it under CI.
|
|
62
|
+
*/
|
|
63
|
+
readonly unmeasured?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type Measure = (ctx: MeasureContext) => Promise<MeasureReport>
|
|
67
|
+
|
|
68
|
+
const info = (text: string): Emission => ({ kind: 'info', text })
|
|
69
|
+
const warn = (text: string): Emission => ({ kind: 'warn', text })
|
|
70
|
+
const output = (text: string): Emission => ({ kind: 'output', text })
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Rules no stack reaches, sorted the way `aitk gov list` emits them.
|
|
74
|
+
* `260-shadcn` and `320-tanstack-query` are opt-in libraries a project may not
|
|
75
|
+
* want. `505-at-references` used to sit here too, shipping with no stack on
|
|
76
|
+
* purpose since a rule under `claude/` would reach every base consumer through
|
|
77
|
+
* the folder-whole entry there. Its own install channel, `aitk snippets
|
|
78
|
+
* install`, retired with nothing left to deliver it, so `base` now carries
|
|
79
|
+
* `snippets` as a folder-whole entry of its own and the rule reaches every base
|
|
80
|
+
* consumer through that instead.
|
|
81
|
+
*
|
|
82
|
+
* Both are recorded here rather than in a config file: the list is what a
|
|
83
|
+
* reader compares a new arrival against, and a config file would absorb the
|
|
84
|
+
* arrival silently.
|
|
85
|
+
*/
|
|
86
|
+
export const GOV_EXPECTED_UNREFERENCED = ['260-shadcn', '320-tanstack-query']
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Scenarios declaring no expectation, taken from `aitk sandbox coverage`
|
|
90
|
+
* against a clean tree. Raising it is a deliberate edit that says which
|
|
91
|
+
* scenario shipped unarmed and why.
|
|
92
|
+
*/
|
|
93
|
+
export const SANDBOX_UNDECLARED_CEILING = 47
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The retained counts the audit stage compares each run against. Spelled here
|
|
97
|
+
* rather than derived, because this stage only ever names the file in a remedy
|
|
98
|
+
* a reader has to be able to open, and `aitk audits run` owns writing it.
|
|
99
|
+
*/
|
|
100
|
+
export const AUDITS_BASELINE = '.claude/audits/baseline.json'
|
|
101
|
+
|
|
102
|
+
export const HERO_STAMP_FAILURE =
|
|
103
|
+
'The hero set disagrees with the stamp written when the image was captured. Run aitk capture assets/hero.html and commit all three files together.'
|
|
104
|
+
|
|
105
|
+
function parseJson(payload: string): unknown {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(payload)
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A rule no stack names is a report rather than a gate. All three standing
|
|
115
|
+
* findings ship that way on purpose, so failing would fail every push over the
|
|
116
|
+
* deliberate case and teach a reader to route around the stage.
|
|
117
|
+
*
|
|
118
|
+
* The catalog is parsed here rather than piped through `bun --eval`, which the
|
|
119
|
+
* script this replaces had to do and had to guard: that interpreter exits 0
|
|
120
|
+
* when its script throws while stdin is a pipe, so a payload that is not JSON
|
|
121
|
+
* printed nothing and exited clean, and empty already means every rule is
|
|
122
|
+
* reached. A sentinel string carried success past that. Parsing in process
|
|
123
|
+
* leaves the two states distinguishable with nothing to carry.
|
|
124
|
+
*/
|
|
125
|
+
export const unreferencedRules: Measure = async (ctx) => {
|
|
126
|
+
const { exitCode, stdout } = await ctx.cli(['gov', 'list', '--json'])
|
|
127
|
+
if (exitCode !== 0 || stdout.trim() === '') {
|
|
128
|
+
return {
|
|
129
|
+
emissions: [],
|
|
130
|
+
unmeasured: 'The governance catalog did not report.',
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const record = parseJson(stdout) as { unreferenced?: unknown } | undefined
|
|
135
|
+
if (!Array.isArray(record?.unreferenced)) {
|
|
136
|
+
return {
|
|
137
|
+
emissions: [],
|
|
138
|
+
unmeasured:
|
|
139
|
+
'The governance catalog carried no readable unreferenced list.',
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const unreferenced = record.unreferenced.map(String)
|
|
144
|
+
if (unreferenced.length === 0) {
|
|
145
|
+
return { emissions: [info('Every rule is reached by a stack')] }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const listed = unreferenced.join(' ')
|
|
149
|
+
if (listed === GOV_EXPECTED_UNREFERENCED.join(' ')) {
|
|
150
|
+
return {
|
|
151
|
+
emissions: [
|
|
152
|
+
info(`Reached by no stack: ${listed} (each recorded above with why)`),
|
|
153
|
+
],
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
emissions: [
|
|
159
|
+
warn(`Reached by no stack: ${listed}`),
|
|
160
|
+
warn(
|
|
161
|
+
`Expected: ${GOV_EXPECTED_UNREFERENCED.join(' ')}. Name the new rule in a stack, or update GOV_EXPECTED_UNREFERENCED in src/gate/measures.ts and say why it reaches no stack.`,
|
|
162
|
+
),
|
|
163
|
+
],
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A banned character, word, or spelling is a fact rather than a threshold, so
|
|
169
|
+
* it fails the push while bullet, paragraph, and depth weight stay advisory.
|
|
170
|
+
*
|
|
171
|
+
* The whole corpus is measured rather than the changed files, because a
|
|
172
|
+
* `Do not use` bullet added to a standard bans a token retroactively and no
|
|
173
|
+
* file in the push that adds it was edited.
|
|
174
|
+
*
|
|
175
|
+
* `--json` sends the record to stdout and the frame to stderr, so a passing run
|
|
176
|
+
* stays silent and a failing one is re-run for its frame rather than parsed out
|
|
177
|
+
* of a stream this stage would have to strip.
|
|
178
|
+
*/
|
|
179
|
+
export const markdownBans: Measure = async (ctx) => {
|
|
180
|
+
const { exitCode } = await ctx.cli(['markdown', 'audit', '--json'])
|
|
181
|
+
|
|
182
|
+
if (exitCode === 0) {
|
|
183
|
+
return { emissions: [info('No banned character, word, or spelling')] }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (exitCode === 1) {
|
|
187
|
+
return {
|
|
188
|
+
emissions: [],
|
|
189
|
+
unmeasured: 'The markdown audit refused and measured nothing.',
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (exitCode === 3) {
|
|
194
|
+
return {
|
|
195
|
+
emissions: [],
|
|
196
|
+
failure:
|
|
197
|
+
'The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for. Check src/markdown/bans.ts.',
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (exitCode === 2) {
|
|
202
|
+
const frame = await ctx.cli(['markdown', 'audit'])
|
|
203
|
+
return {
|
|
204
|
+
emissions: [output(frame.all)],
|
|
205
|
+
failure:
|
|
206
|
+
'Markdown prose carries a banned character, word, or spelling. Rewrite the sentence, and reach for a code span only where the token is genuinely an identifier under discussion.',
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
emissions: [],
|
|
212
|
+
failure: `The markdown audit exited ${exitCode}, which is neither a pass nor a finding.`,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The markdown stage audits this repository. The seed tree ships into every
|
|
218
|
+
* scaffolded project, so a seed breaking the standard it seeds propagates
|
|
219
|
+
* instead of sitting still, and no rule path reaches the tree to report it.
|
|
220
|
+
*
|
|
221
|
+
* `--gate` fails on the two findings beside citations that are facts, a missing
|
|
222
|
+
* required section and index drift, and leaves the thresholds advisory for the
|
|
223
|
+
* reason the stage above leaves its own so.
|
|
224
|
+
*
|
|
225
|
+
* The roots are discovered rather than listed, through the one bash definition
|
|
226
|
+
* `check-seed-independence.sh` already reads, so a stack seeding `.claude/`
|
|
227
|
+
* later is covered with no edit here and the two stages cannot disagree about
|
|
228
|
+
* which roots exist.
|
|
229
|
+
*/
|
|
230
|
+
export const seedStandards: Measure = async (ctx) => {
|
|
231
|
+
const roots = await ctx.run([
|
|
232
|
+
'bash',
|
|
233
|
+
join(ctx.root, 'scripts/core/list-seed-roots.sh'),
|
|
234
|
+
])
|
|
235
|
+
|
|
236
|
+
if (roots.exitCode !== 0) {
|
|
237
|
+
return {
|
|
238
|
+
emissions: [],
|
|
239
|
+
unmeasured: 'The seed roots could not be listed, so no seed was read.',
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const seedRoots = roots.stdout.split('\n').filter((line) => line !== '')
|
|
244
|
+
if (seedRoots.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
emissions: [],
|
|
247
|
+
unmeasured: 'No seed root carries .claude/, so nothing was measured.',
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const emissions: Emission[] = []
|
|
252
|
+
let measured = 0
|
|
253
|
+
|
|
254
|
+
for (const seedRoot of seedRoots) {
|
|
255
|
+
const run = await ctx.cli([
|
|
256
|
+
'context',
|
|
257
|
+
'audit',
|
|
258
|
+
seedRoot,
|
|
259
|
+
'--gate',
|
|
260
|
+
'--json',
|
|
261
|
+
])
|
|
262
|
+
|
|
263
|
+
// The audit separates 1 from 2 and they mean opposite things. 2 is a seed
|
|
264
|
+
// breaking the standard it seeds. 1 is the audit refusing, which a seed
|
|
265
|
+
// root carrying a `.claude/` but no audited folder produces, and reporting
|
|
266
|
+
// that as a violation sends a reader hunting one that does not exist.
|
|
267
|
+
if (run.exitCode === 1) {
|
|
268
|
+
emissions.push(
|
|
269
|
+
warn(`${seedRoot}: no audited folder under .claude/, nothing measured`),
|
|
270
|
+
)
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (run.exitCode !== 0) {
|
|
275
|
+
const frame = await ctx.cli(['context', 'audit', seedRoot, '--gate'])
|
|
276
|
+
emissions.push(output(frame.all))
|
|
277
|
+
return {
|
|
278
|
+
emissions,
|
|
279
|
+
failure:
|
|
280
|
+
run.exitCode === 2
|
|
281
|
+
? `A seed breaks the standard governing the folder it seeds: ${seedRoot}`
|
|
282
|
+
: `The seed audit exited ${run.exitCode} against ${seedRoot}, which is neither a pass nor a finding.`,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const entries = seedEntryCount(run.stdout)
|
|
287
|
+
measured += entries
|
|
288
|
+
emissions.push(
|
|
289
|
+
entries === 0
|
|
290
|
+
? warn(
|
|
291
|
+
`${seedRoot}: no entry under an audited folder, nothing measured`,
|
|
292
|
+
)
|
|
293
|
+
: info(`${seedRoot}: ${entries} entries measured`),
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (measured === 0) {
|
|
298
|
+
return {
|
|
299
|
+
emissions,
|
|
300
|
+
unmeasured: 'No seed entry was measured, so the stage covered nothing.',
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { emissions }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Entries the audit actually measured, summed across the folders it resolved.
|
|
309
|
+
*
|
|
310
|
+
* A root can resolve a folder and measure nothing in it, which is a passing
|
|
311
|
+
* gate over an empty set. The caller states this per root rather than reporting
|
|
312
|
+
* one verdict for every root, or a tree nobody measured reads as a tree that
|
|
313
|
+
* passed.
|
|
314
|
+
*/
|
|
315
|
+
export function seedEntryCount(payload: string): number {
|
|
316
|
+
const record = parseJson(payload) as
|
|
317
|
+
| { folders?: { entries?: unknown }[] }
|
|
318
|
+
| undefined
|
|
319
|
+
const folders = Array.isArray(record?.folders) ? record.folders : []
|
|
320
|
+
return folders.reduce(
|
|
321
|
+
(total, folder) =>
|
|
322
|
+
total + (typeof folder.entries === 'number' ? folder.entries : 0),
|
|
323
|
+
0,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Scoped to arrival rather than the corpus, since `standards/standard.md`
|
|
329
|
+
* forbids writing a criterion into an existing standard outside the change that
|
|
330
|
+
* exercises it. Gating the known gaps would fail every push until someone
|
|
331
|
+
* closed them all, which is the sweep that rule exists to prevent.
|
|
332
|
+
*/
|
|
333
|
+
export const standardCriteria: Measure = async (ctx) => {
|
|
334
|
+
const run = await ctx.cli(['standards', 'audit', '--arrivals-only'])
|
|
335
|
+
|
|
336
|
+
if (run.exitCode === 0) {
|
|
337
|
+
return { emissions: [info('Arriving standards carry a success criterion')] }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
emissions: [output(run.all)],
|
|
342
|
+
failure:
|
|
343
|
+
run.exitCode === 2
|
|
344
|
+
? 'A standard new to this branch carries no ## Success criterion section. Run bun src/cli.ts standards audit.'
|
|
345
|
+
: 'aitk standards audit could not read which standards arrived on this branch. Run bun src/cli.ts standards audit --json to see why.',
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* `aitk sandbox coverage` moves only when a person runs it, so a scenario added
|
|
351
|
+
* with no expectation ships unnoticed.
|
|
352
|
+
*
|
|
353
|
+
* The gate is an absolute count of undeclared scenarios rather than a ratio or
|
|
354
|
+
* a floor under the declared count. A floor passes the case this exists to
|
|
355
|
+
* catch, since adding an unarmed scenario leaves that number where it was. A
|
|
356
|
+
* ratio moves when a scenario is legitimately deleted, and this ceiling does
|
|
357
|
+
* not: deleting an unarmed scenario lowers it and deleting an armed one leaves
|
|
358
|
+
* it alone.
|
|
359
|
+
*/
|
|
360
|
+
export const sandboxCoverage: Measure = async (ctx) => {
|
|
361
|
+
const run = await ctx.cli(['sandbox', 'coverage', '--json'])
|
|
362
|
+
|
|
363
|
+
if (run.exitCode !== 0) {
|
|
364
|
+
return {
|
|
365
|
+
emissions: [],
|
|
366
|
+
unmeasured: `The scenario tree did not report (exit ${run.exitCode}). It ships in the checkout, so a run that does not report is a broken command rather than an absent tree.`,
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const record = parseJson(run.stdout) as
|
|
371
|
+
| { totalScenarios?: unknown; armedScenarios?: unknown }
|
|
372
|
+
| undefined
|
|
373
|
+
const total = record?.totalScenarios
|
|
374
|
+
const armed = record?.armedScenarios
|
|
375
|
+
|
|
376
|
+
if (typeof total !== 'number' || typeof armed !== 'number') {
|
|
377
|
+
return {
|
|
378
|
+
emissions: [],
|
|
379
|
+
failure:
|
|
380
|
+
'The coverage report carried no scenario totals, so the stage measured nothing. Run bun src/cli.ts sandbox coverage --json.',
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const undeclared = total - armed
|
|
385
|
+
if (undeclared > SANDBOX_UNDECLARED_CEILING) {
|
|
386
|
+
return {
|
|
387
|
+
emissions: [],
|
|
388
|
+
failure: `${undeclared} of ${total} scenarios declare no expectation, over the ceiling of ${SANDBOX_UNDECLARED_CEILING}. Declare expectations on the new scenario, or raise SANDBOX_UNDECLARED_CEILING in src/gate/measures.ts and say which scenario shipped unarmed.`,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
emissions: [
|
|
394
|
+
info(
|
|
395
|
+
`${armed} of ${total} scenarios declare expectations, ${undeclared} undeclared against a ceiling of ${SANDBOX_UNDECLARED_CEILING}`,
|
|
396
|
+
),
|
|
397
|
+
],
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The flat scalars `aitk audits run --json` publishes for a caller to read. */
|
|
402
|
+
interface AuditSummary {
|
|
403
|
+
readonly grown?: number
|
|
404
|
+
readonly shrunk?: number
|
|
405
|
+
readonly facts?: number
|
|
406
|
+
readonly unmeasured?: number
|
|
407
|
+
readonly absent?: number
|
|
408
|
+
readonly unrecorded?: number
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The three stages gating on the three findings here that are facts sit above,
|
|
413
|
+
* and this stage reports the rest. It runs the whole set anyway rather than
|
|
414
|
+
* only what those stages skip, because the aggregate's own value is one verdict
|
|
415
|
+
* over every audit, and a stage measuring a subset would report a health this
|
|
416
|
+
* repository never took.
|
|
417
|
+
*
|
|
418
|
+
* This reports and never fails. Growth in a judgment count is the thing the
|
|
419
|
+
* baseline exists to make visible, and failing a push on one would teach a
|
|
420
|
+
* contributor to route around the stage. A fact still fails the push, at the
|
|
421
|
+
* specific stage above that names its own remedy.
|
|
422
|
+
*/
|
|
423
|
+
export const auditSet: Measure = async (ctx) => {
|
|
424
|
+
const run = await ctx.cli(['audits', 'run', '--json'])
|
|
425
|
+
|
|
426
|
+
if (run.stdout.trim() === '') {
|
|
427
|
+
return {
|
|
428
|
+
emissions: [],
|
|
429
|
+
unmeasured: `The audit set did not report (exit ${run.exitCode}).`,
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const record = parseJson(run.stdout) as { summary?: AuditSummary } | undefined
|
|
434
|
+
const summary = record?.summary
|
|
435
|
+
|
|
436
|
+
// An absent field is a record this stage cannot read, which is not the same
|
|
437
|
+
// as a run with nothing to report. Reading it as zero would print a clean
|
|
438
|
+
// line over a summary nobody parsed.
|
|
439
|
+
if (
|
|
440
|
+
typeof summary?.grown !== 'number' ||
|
|
441
|
+
typeof summary.facts !== 'number' ||
|
|
442
|
+
typeof summary.unmeasured !== 'number'
|
|
443
|
+
) {
|
|
444
|
+
return {
|
|
445
|
+
emissions: [],
|
|
446
|
+
unmeasured:
|
|
447
|
+
'The audit record carried no summary, so this stage measured nothing. Run bun src/cli.ts audits run.',
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const emissions: Emission[] = []
|
|
452
|
+
|
|
453
|
+
// An absent per-machine folder is the ordinary state here rather than a
|
|
454
|
+
// finding, since every one of them is gitignored and CI carries none. It is
|
|
455
|
+
// still stated, because a stage naming only what it measured claims a
|
|
456
|
+
// coverage it does not have.
|
|
457
|
+
if (typeof summary.absent === 'number' && summary.absent > 0) {
|
|
458
|
+
emissions.push(
|
|
459
|
+
info(
|
|
460
|
+
`${summary.absent} per-machine corpus/corpora absent, so unmeasured here by design`,
|
|
461
|
+
),
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
if (summary.unmeasured > 0) {
|
|
465
|
+
emissions.push(
|
|
466
|
+
warn(
|
|
467
|
+
`${summary.unmeasured} audit(s) did not report, so the set is incomplete. Run bun src/cli.ts audits run.`,
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
if (summary.facts > 0) {
|
|
472
|
+
emissions.push(
|
|
473
|
+
warn(
|
|
474
|
+
`${summary.facts} audit(s) carry a finding that is a fact. The stage above names the remedy.`,
|
|
475
|
+
),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
if (typeof summary.unrecorded === 'number' && summary.unrecorded > 0) {
|
|
479
|
+
emissions.push(
|
|
480
|
+
warn(
|
|
481
|
+
`${summary.unrecorded} tracked audit(s) have no recorded floor. Take one with bun src/cli.ts audits run --record.`,
|
|
482
|
+
),
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
emissions.push(
|
|
486
|
+
summary.grown > 0
|
|
487
|
+
? warn(
|
|
488
|
+
`${summary.grown} measure(s) grew against ${AUDITS_BASELINE}. Run bun src/cli.ts audits run to see which, then fix them or re-record and say why.`,
|
|
489
|
+
)
|
|
490
|
+
: info(`No measure grew against ${AUDITS_BASELINE}`),
|
|
491
|
+
)
|
|
492
|
+
if (typeof summary.shrunk === 'number' && summary.shrunk > 0) {
|
|
493
|
+
emissions.push(
|
|
494
|
+
info(`${summary.shrunk} measure(s) fell against ${AUDITS_BASELINE}`),
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { emissions }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The plugin is the second delivery path and this is the only stage gating it,
|
|
503
|
+
* so an absent binary is a contributor's machine rather than a clean tree. A
|
|
504
|
+
* runner installs the CLI as a workflow step, which makes an absent binary
|
|
505
|
+
* there a broken workflow, and passing would report a verdict for every
|
|
506
|
+
* manifest on the way to a marketplace install.
|
|
507
|
+
*
|
|
508
|
+
* A global install can also land the wrapper and no platform-native binary,
|
|
509
|
+
* which resolves on PATH and cannot run, so the two states are separated by
|
|
510
|
+
* whether the spawn started at all rather than by a second lookup.
|
|
511
|
+
*/
|
|
512
|
+
export const pluginManifests: Measure = async (ctx) => {
|
|
513
|
+
const version = await ctx.run(['claude', '--version'])
|
|
514
|
+
|
|
515
|
+
if (version.spawnError !== undefined) {
|
|
516
|
+
return {
|
|
517
|
+
emissions: [],
|
|
518
|
+
unmeasured:
|
|
519
|
+
'claude is not installed, so no manifest was read. CI installs it before this stage, so read the Install Plugin CLI step in .github/workflows/verify.yml.',
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (version.exitCode !== 0) {
|
|
524
|
+
return {
|
|
525
|
+
emissions: [],
|
|
526
|
+
unmeasured:
|
|
527
|
+
'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, and record the move in .claude/context/ci.md.',
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const manifests = await collectPluginManifests(ctx)
|
|
532
|
+
if (manifests.length === 0) {
|
|
533
|
+
return {
|
|
534
|
+
emissions: [],
|
|
535
|
+
unmeasured:
|
|
536
|
+
'No plugin or marketplace manifest is present, so none was validated.',
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
for (const manifest of manifests) {
|
|
541
|
+
const run = await ctx.run([
|
|
542
|
+
'claude',
|
|
543
|
+
'plugin',
|
|
544
|
+
'validate',
|
|
545
|
+
'--strict',
|
|
546
|
+
manifest,
|
|
547
|
+
])
|
|
548
|
+
if (run.exitCode !== 0) {
|
|
549
|
+
return {
|
|
550
|
+
emissions: [output(run.all)],
|
|
551
|
+
failure: `Manifest validation failed: ${manifest}`,
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return { emissions: [info('Manifests valid')] }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Whatever plugin and marketplace manifests the repository currently carries,
|
|
561
|
+
* so the stage picks up a new one without an edit here. Both listings honor
|
|
562
|
+
* `.gitignore`, which keeps linked worktrees and dependency copies out.
|
|
563
|
+
*/
|
|
564
|
+
async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
|
|
565
|
+
const patterns = [
|
|
566
|
+
'*.claude-plugin/plugin.json',
|
|
567
|
+
'*.claude-plugin/marketplace.json',
|
|
568
|
+
]
|
|
569
|
+
const tracked = await ctx.run(['git', 'ls-files', '--', ...patterns])
|
|
570
|
+
const untracked = await ctx.run([
|
|
571
|
+
'git',
|
|
572
|
+
'ls-files',
|
|
573
|
+
'--others',
|
|
574
|
+
'--exclude-standard',
|
|
575
|
+
'--',
|
|
576
|
+
...patterns,
|
|
577
|
+
])
|
|
578
|
+
|
|
579
|
+
const seen = new Set(
|
|
580
|
+
`${tracked.stdout}\n${untracked.stdout}`
|
|
581
|
+
.split('\n')
|
|
582
|
+
.map((line) => line.trim())
|
|
583
|
+
.filter((line) => line !== ''),
|
|
584
|
+
)
|
|
585
|
+
return [...seen].sort()
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The drift assert on the Hero stage covers the markup because the image beside
|
|
590
|
+
* it is a chromium render whose bytes move with the browser. That leaves the
|
|
591
|
+
* artifact a visitor actually sees asserted nowhere, so a branch regenerating
|
|
592
|
+
* the markup and never running the capture would pass every stage while
|
|
593
|
+
* shipping an image with the old counts.
|
|
594
|
+
*
|
|
595
|
+
* `aitk capture` records a digest of the markup it rendered and one of the image
|
|
596
|
+
* it wrote, so this reads provenance rather than timing. Comparing the commit
|
|
597
|
+
* that last touched each file passes any pair that moved together whatever the
|
|
598
|
+
* two files hold, which is what a binary conflict resolved by taking either
|
|
599
|
+
* side produces.
|
|
600
|
+
*
|
|
601
|
+
* Both digests are checked because either file can move alone. The markup side
|
|
602
|
+
* catches an edit committed with no capture, and the image side catches an
|
|
603
|
+
* image replaced under markup that never changed. All three absent passes,
|
|
604
|
+
* which is correct for a tree that carries none of them.
|
|
605
|
+
*/
|
|
606
|
+
export const heroStamp: Measure = async (ctx) => {
|
|
607
|
+
const set = [
|
|
608
|
+
['assets/hero.html', join(ctx.root, 'assets/hero.html')],
|
|
609
|
+
['assets/hero.png', join(ctx.root, 'assets/hero.png')],
|
|
610
|
+
['assets/hero.stamp', join(ctx.root, 'assets/hero.stamp')],
|
|
611
|
+
] as const
|
|
612
|
+
|
|
613
|
+
if (set.every(([, path]) => !existsSync(path))) return { emissions: [] }
|
|
614
|
+
|
|
615
|
+
const missing = set
|
|
616
|
+
.filter(([, path]) => !existsSync(path))
|
|
617
|
+
.map(([label]) => label)
|
|
618
|
+
if (missing.length > 0) {
|
|
619
|
+
return {
|
|
620
|
+
emissions: [output(`Missing from the hero set: ${missing.join(' ')}`)],
|
|
621
|
+
failure: HERO_STAMP_FAILURE,
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const [[, html], [, png], [, stamp]] = set
|
|
626
|
+
const lines = [
|
|
627
|
+
...assertStampField(ctx.root, stamp, 'source-sha256', html),
|
|
628
|
+
...assertStampField(ctx.root, stamp, 'image-sha256', png),
|
|
629
|
+
]
|
|
630
|
+
if (lines.length === 0) return { emissions: [] }
|
|
631
|
+
|
|
632
|
+
return { emissions: [output(lines.join('\n'))], failure: HERO_STAMP_FAILURE }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* One digest the stamp recorded against the file it was taken over, returning
|
|
637
|
+
* the lines to report and nothing when the two agree.
|
|
638
|
+
*
|
|
639
|
+
* An absent field reports itself rather than comparing against an empty string,
|
|
640
|
+
* so a stamp predating the current format is distinguishable from a file that
|
|
641
|
+
* moved. Both names in the message come off the paths rather than from
|
|
642
|
+
* arguments, which is what keeps a name from disagreeing with the file it
|
|
643
|
+
* labels once a second capture source calls this.
|
|
644
|
+
*/
|
|
645
|
+
export function assertStampField(
|
|
646
|
+
root: string,
|
|
647
|
+
stamp: string,
|
|
648
|
+
field: string,
|
|
649
|
+
file: string,
|
|
650
|
+
): string[] {
|
|
651
|
+
const stampLabel = relativeTo(root, stamp)
|
|
652
|
+
const fileLabel = relativeTo(root, file)
|
|
653
|
+
|
|
654
|
+
const recorded = readStampField(stamp, field)
|
|
655
|
+
if (recorded === undefined) {
|
|
656
|
+
return [
|
|
657
|
+
`${stampLabel} carries no ${field} line, so it predates the capture that writes one.`,
|
|
658
|
+
]
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const actual = createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
662
|
+
if (recorded !== actual) {
|
|
663
|
+
return [
|
|
664
|
+
`${stampLabel} records ${field} ${recorded}`,
|
|
665
|
+
`${fileLabel} hashes to ${actual}`,
|
|
666
|
+
]
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return []
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function readStampField(stamp: string, field: string): string | undefined {
|
|
673
|
+
for (const line of readFileSync(stamp, 'utf8').split('\n')) {
|
|
674
|
+
const [key, value] = line.trim().split(/\s+/)
|
|
675
|
+
if (key === `${field}:` && value !== undefined) return value
|
|
676
|
+
}
|
|
677
|
+
return undefined
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function relativeTo(root: string, path: string): string {
|
|
681
|
+
return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
|
|
682
|
+
}
|