@erclx/aitk 0.109.0 → 0.110.0
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/docs/agents/audits.md +88 -0
- package/docs/agents/commands.md +3 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/scripting.md +13 -9
- package/package.json +1 -1
- package/scripts/core/verify.sh +74 -6
- package/src/audits/baseline.ts +194 -0
- package/src/audits/catalog.ts +464 -0
- package/src/audits/run.ts +202 -0
- package/src/cli.ts +4 -0
- package/src/commands/audits.ts +342 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
|
|
2
|
+
import type { ValidateRefusal as BoardRefusal } from '@/tasks/validate'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The audits this repository already owns, and how to read each one's record.
|
|
6
|
+
*
|
|
7
|
+
* Every verb here ships its own `--json` shape with its own keys, and this
|
|
8
|
+
* module reads each shape rather than forcing a common envelope on them. Each
|
|
9
|
+
* record already has consumers, so an envelope would be a breaking change to
|
|
10
|
+
* every one of them bought for tidiness.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** How far a finding from this audit reaches. */
|
|
14
|
+
export type Corpus =
|
|
15
|
+
/** Committed, so every clone measures the same files and a delta is shared. */
|
|
16
|
+
| 'tracked'
|
|
17
|
+
/** Gitignored session scratch, so the numbers are one machine's alone. */
|
|
18
|
+
| 'per-machine'
|
|
19
|
+
|
|
20
|
+
export type AuditStatus =
|
|
21
|
+
/** The audit reported and every count it produced is zero. */
|
|
22
|
+
| 'clean'
|
|
23
|
+
/** The audit reported findings it does not gate on. */
|
|
24
|
+
| 'reported'
|
|
25
|
+
/** The audit reported a finding that is a fact, which fails the aggregate. */
|
|
26
|
+
| 'finding'
|
|
27
|
+
/**
|
|
28
|
+
* The per-machine corpus this audit reads is not on this disk, which is the
|
|
29
|
+
* ordinary state of a gitignored folder on a fresh clone and in CI.
|
|
30
|
+
*/
|
|
31
|
+
| 'absent'
|
|
32
|
+
/** The audit did not report, so the aggregate measured less than the set. */
|
|
33
|
+
| 'unmeasured'
|
|
34
|
+
|
|
35
|
+
export interface AuditSpec {
|
|
36
|
+
readonly id: string
|
|
37
|
+
readonly label: string
|
|
38
|
+
/** Arguments after `aitk`, always ending in `--json`. */
|
|
39
|
+
readonly argv: readonly string[]
|
|
40
|
+
/**
|
|
41
|
+
* Exit codes this verb sets on a finding that is a fact.
|
|
42
|
+
*
|
|
43
|
+
* Empty for every verb whose findings are judgments. The catalog decides
|
|
44
|
+
* this rather than the exit code, because a reporting verb sets 2 on
|
|
45
|
+
* findings it deliberately does not gate on, so the code alone cannot say
|
|
46
|
+
* whether a finding is a fact.
|
|
47
|
+
*/
|
|
48
|
+
readonly gatingExits: readonly number[]
|
|
49
|
+
readonly corpus: Corpus
|
|
50
|
+
/**
|
|
51
|
+
* Pulls the counts worth retaining out of this verb's record.
|
|
52
|
+
*
|
|
53
|
+
* Returns `undefined` when the record does not carry the keys it reads,
|
|
54
|
+
* which is a measure that did not run rather than a corpus with nothing in
|
|
55
|
+
* it. Zero there would read as clean, which is the two-states-look-alike
|
|
56
|
+
* defect this repository has already fixed elsewhere.
|
|
57
|
+
*/
|
|
58
|
+
readonly counts: (record: unknown) => Record<string, number> | undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AuditResult {
|
|
62
|
+
readonly id: string
|
|
63
|
+
readonly label: string
|
|
64
|
+
readonly status: AuditStatus
|
|
65
|
+
readonly tracked: boolean
|
|
66
|
+
readonly exitCode: number
|
|
67
|
+
readonly counts?: Record<string, number>
|
|
68
|
+
/** Why the audit did not report, present only on `unmeasured`. */
|
|
69
|
+
readonly reason?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The exit code every command here sets when it refuses. */
|
|
73
|
+
const EXIT_REFUSED = 1
|
|
74
|
+
|
|
75
|
+
/** The exit code every command here sets when it carries findings. */
|
|
76
|
+
const EXIT_FINDINGS = 2
|
|
77
|
+
|
|
78
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
79
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
return value as Record<string, unknown>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function lengthOf(value: unknown): number | undefined {
|
|
86
|
+
return Array.isArray(value) ? value.length : undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Folds a set of per-key readings into one record, or `undefined` when any of
|
|
91
|
+
* them could not be read. One unreadable key means the record is not the shape
|
|
92
|
+
* the extractor was written against, and reporting the rest would publish a
|
|
93
|
+
* partial count under a name that claims to be whole.
|
|
94
|
+
*/
|
|
95
|
+
function allOf(
|
|
96
|
+
readings: Record<string, number | undefined>,
|
|
97
|
+
): Record<string, number> | undefined {
|
|
98
|
+
const counts: Record<string, number> = {}
|
|
99
|
+
for (const [key, value] of Object.entries(readings)) {
|
|
100
|
+
if (value === undefined) return undefined
|
|
101
|
+
counts[key] = value
|
|
102
|
+
}
|
|
103
|
+
return counts
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function contextCounts(record: unknown): Record<string, number> | undefined {
|
|
107
|
+
const root = asObject(record)
|
|
108
|
+
if (root === undefined) return undefined
|
|
109
|
+
|
|
110
|
+
const entries = root.entries
|
|
111
|
+
if (!Array.isArray(entries)) return undefined
|
|
112
|
+
|
|
113
|
+
let bareReferences = 0
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const bare = lengthOf(asObject(entry)?.bareReferences)
|
|
116
|
+
if (bare === undefined) return undefined
|
|
117
|
+
bareReferences += bare
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return allOf({
|
|
121
|
+
unresolvedCitations: lengthOf(asObject(root.citations)?.unresolved),
|
|
122
|
+
longEntries: lengthOf(root.length),
|
|
123
|
+
missingSections: lengthOf(root.missingSections),
|
|
124
|
+
indexDrift: lengthOf(root.indexDrift),
|
|
125
|
+
bareReferences,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function markdownCounts(record: unknown): Record<string, number> | undefined {
|
|
130
|
+
const root = asObject(record)
|
|
131
|
+
if (root === undefined) return undefined
|
|
132
|
+
|
|
133
|
+
const entries = root.entries
|
|
134
|
+
if (!Array.isArray(entries)) return undefined
|
|
135
|
+
|
|
136
|
+
const depth = asObject(root.checkpoints)?.run
|
|
137
|
+
// A depth reading needs the checkpoint it is measured against. Counting zero
|
|
138
|
+
// without it reports a corpus nobody measured for depth as one with no file
|
|
139
|
+
// past the line.
|
|
140
|
+
if (entries.length > 0 && typeof depth !== 'number') return undefined
|
|
141
|
+
|
|
142
|
+
let bans = 0
|
|
143
|
+
let heavyBullets = 0
|
|
144
|
+
let heavyParagraphs = 0
|
|
145
|
+
let filesPastDepth = 0
|
|
146
|
+
let flatParagraphs = 0
|
|
147
|
+
|
|
148
|
+
for (const raw of entries) {
|
|
149
|
+
const entry = asObject(raw)
|
|
150
|
+
if (entry === undefined) return undefined
|
|
151
|
+
|
|
152
|
+
const entryBans = lengthOf(entry.bans)
|
|
153
|
+
const bullets = lengthOf(entry.heavyBullets)
|
|
154
|
+
const paragraphs = lengthOf(entry.heavyParagraphs)
|
|
155
|
+
const run = entry.longestRun
|
|
156
|
+
if (
|
|
157
|
+
entryBans === undefined ||
|
|
158
|
+
bullets === undefined ||
|
|
159
|
+
paragraphs === undefined ||
|
|
160
|
+
typeof run !== 'number'
|
|
161
|
+
) {
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
bans += entryBans
|
|
166
|
+
heavyBullets += bullets
|
|
167
|
+
heavyParagraphs += paragraphs
|
|
168
|
+
if (typeof depth === 'number' && run > depth) filesPastDepth += 1
|
|
169
|
+
|
|
170
|
+
// A file below the measuring floor carries no cadence key at all, which is
|
|
171
|
+
// a file that was not measured rather than one with no flat paragraph.
|
|
172
|
+
const flat = asObject(entry.cadence)?.flat
|
|
173
|
+
if (typeof flat === 'number') flatParagraphs += flat
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { bans, heavyBullets, heavyParagraphs, filesPastDepth, flatParagraphs }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The finding arrays `aitk claude skills audit` publishes, in its own order. */
|
|
180
|
+
const SKILL_FINDINGS = [
|
|
181
|
+
'missingRequirement',
|
|
182
|
+
'nameMismatch',
|
|
183
|
+
'missingDescription',
|
|
184
|
+
'longDescription',
|
|
185
|
+
'readme',
|
|
186
|
+
'folderName',
|
|
187
|
+
'requirementSections',
|
|
188
|
+
] as const
|
|
189
|
+
|
|
190
|
+
function skillCounts(record: unknown): Record<string, number> | undefined {
|
|
191
|
+
const findings = asObject(asObject(record)?.findings)
|
|
192
|
+
if (findings === undefined) return undefined
|
|
193
|
+
|
|
194
|
+
return allOf(
|
|
195
|
+
Object.fromEntries(
|
|
196
|
+
SKILL_FINDINGS.map((key) => [key, lengthOf(findings[key])]),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function boardCounts(record: unknown): Record<string, number> | undefined {
|
|
202
|
+
const root = asObject(record)
|
|
203
|
+
if (root === undefined) return undefined
|
|
204
|
+
|
|
205
|
+
// The board keeps its untested rows apart from its findings on purpose, so
|
|
206
|
+
// they move no exit code. Folding them together here would undo that.
|
|
207
|
+
return allOf({
|
|
208
|
+
findings: lengthOf(root.findings),
|
|
209
|
+
untested: lengthOf(root.untested),
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function findingsOnly(record: unknown): Record<string, number> | undefined {
|
|
214
|
+
const root = asObject(record)
|
|
215
|
+
if (root === undefined) return undefined
|
|
216
|
+
|
|
217
|
+
return allOf({ findings: lengthOf(root.findings) })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function commentCounts(record: unknown): Record<string, number> | undefined {
|
|
221
|
+
const snapshot = asObject(record)?.snapshot
|
|
222
|
+
if (!Array.isArray(snapshot)) return undefined
|
|
223
|
+
|
|
224
|
+
let degradationHits = 0
|
|
225
|
+
for (const language of snapshot) {
|
|
226
|
+
const hits = lengthOf(asObject(language)?.degradationHits)
|
|
227
|
+
if (hits === undefined) return undefined
|
|
228
|
+
degradationHits += hits
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { degradationHits }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function testOrderCounts(record: unknown): Record<string, number> | undefined {
|
|
235
|
+
const root = asObject(record)
|
|
236
|
+
if (root === undefined) return undefined
|
|
237
|
+
|
|
238
|
+
// The unclassified count travels with the findings because it is the honest
|
|
239
|
+
// shape of this measure: a refactor and a new behavior cannot be told apart
|
|
240
|
+
// from history, and a findings count alone claims coverage it does not have.
|
|
241
|
+
return allOf({
|
|
242
|
+
findings: lengthOf(root.findings),
|
|
243
|
+
unclassified: lengthOf(root.unclassified),
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The record kinds `aitk records validate` takes, and the corpus each reads. */
|
|
248
|
+
const RECORD_KINDS: readonly (readonly [string, Corpus])[] = [
|
|
249
|
+
['plans', 'per-machine'],
|
|
250
|
+
['groundwork', 'per-machine'],
|
|
251
|
+
['intake', 'per-machine'],
|
|
252
|
+
['memory', 'per-machine'],
|
|
253
|
+
['standards', 'tracked'],
|
|
254
|
+
['teach', 'per-machine'],
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Every audit the aggregate runs.
|
|
259
|
+
*
|
|
260
|
+
* `context`, `markdown`, and `skills` are the three that gate, which is exactly
|
|
261
|
+
* the set `scripts/core/verify.sh` already fails a push on. Adding a fourth
|
|
262
|
+
* here widens what fails a push without anyone deciding to, and the split this
|
|
263
|
+
* repository records gates a fact and reports a judgment.
|
|
264
|
+
*
|
|
265
|
+
* Each verb runs once in its fullest form. Running the gating half separately
|
|
266
|
+
* would walk the same tree twice for a number the full record already carries.
|
|
267
|
+
*/
|
|
268
|
+
export const AUDITS: readonly AuditSpec[] = [
|
|
269
|
+
{
|
|
270
|
+
id: 'context',
|
|
271
|
+
label: 'Context folders',
|
|
272
|
+
argv: ['context', 'audit', '--json'],
|
|
273
|
+
gatingExits: [EXIT_FINDINGS],
|
|
274
|
+
corpus: 'tracked',
|
|
275
|
+
counts: contextCounts,
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: 'markdown',
|
|
279
|
+
label: 'Markdown corpus',
|
|
280
|
+
argv: ['markdown', 'audit', '--json'],
|
|
281
|
+
// 3 is the empty ban set, which is the corpus walked with nothing looked
|
|
282
|
+
// for. That is a broken check rather than a clean tree, so it fails here
|
|
283
|
+
// the way it already fails the push.
|
|
284
|
+
gatingExits: [EXIT_FINDINGS, 3],
|
|
285
|
+
corpus: 'tracked',
|
|
286
|
+
counts: markdownCounts,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: 'skills',
|
|
290
|
+
label: 'Skill corpora',
|
|
291
|
+
argv: ['claude', 'skills', 'audit', '--json'],
|
|
292
|
+
gatingExits: [EXIT_FINDINGS],
|
|
293
|
+
corpus: 'tracked',
|
|
294
|
+
counts: skillCounts,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: 'tasks',
|
|
298
|
+
label: 'Task board',
|
|
299
|
+
argv: ['tasks', 'validate', '--json'],
|
|
300
|
+
gatingExits: [],
|
|
301
|
+
corpus: 'per-machine',
|
|
302
|
+
counts: boardCounts,
|
|
303
|
+
},
|
|
304
|
+
...RECORD_KINDS.map(([kind, corpus]) => ({
|
|
305
|
+
id: `records-${kind}`,
|
|
306
|
+
label: `Records: ${kind}`,
|
|
307
|
+
argv: ['records', 'validate', kind, '--json'],
|
|
308
|
+
gatingExits: [],
|
|
309
|
+
corpus,
|
|
310
|
+
counts: findingsOnly,
|
|
311
|
+
})),
|
|
312
|
+
{
|
|
313
|
+
id: 'comments',
|
|
314
|
+
label: 'Comment census',
|
|
315
|
+
argv: ['comments', 'scan', '--json'],
|
|
316
|
+
gatingExits: [],
|
|
317
|
+
corpus: 'tracked',
|
|
318
|
+
counts: commentCounts,
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: 'test-order',
|
|
322
|
+
label: 'Test order',
|
|
323
|
+
argv: ['gov', 'test-order', '--json'],
|
|
324
|
+
gatingExits: [],
|
|
325
|
+
corpus: 'tracked',
|
|
326
|
+
counts: testOrderCounts,
|
|
327
|
+
},
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
export function auditFor(id: string): AuditSpec | undefined {
|
|
331
|
+
return AUDITS.find((audit) => audit.id === id)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function isTracked(spec: AuditSpec): boolean {
|
|
335
|
+
return spec.corpus === 'tracked'
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function countsFor(
|
|
339
|
+
spec: AuditSpec,
|
|
340
|
+
record: unknown,
|
|
341
|
+
): Record<string, number> | undefined {
|
|
342
|
+
return spec.counts(record)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Reads the reason a refusing verb published, so the aggregate names the cause
|
|
347
|
+
* rather than only the exit. Every command here puts it in `reason`, and the
|
|
348
|
+
* ones that also carry a sentence put that in `message`.
|
|
349
|
+
*/
|
|
350
|
+
function refusalReason(record: unknown, exitCode: number): string {
|
|
351
|
+
const root = asObject(record)
|
|
352
|
+
const reason = typeof root?.reason === 'string' ? root.reason : undefined
|
|
353
|
+
const message = typeof root?.message === 'string' ? root.message : undefined
|
|
354
|
+
|
|
355
|
+
if (reason !== undefined && message !== undefined) {
|
|
356
|
+
return `${reason}: ${message}`
|
|
357
|
+
}
|
|
358
|
+
return reason ?? message ?? `exited ${exitCode} with no reason on stdout`
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The reasons a verb publishes when the folder it reads is simply not there.
|
|
363
|
+
*
|
|
364
|
+
* Typed against the unions those two verbs export rather than spelled as bare
|
|
365
|
+
* strings, so renaming a reason at its source fails this build. A literal here
|
|
366
|
+
* would go on matching nothing and quietly turn every expected absence back
|
|
367
|
+
* into an unmeasured audit.
|
|
368
|
+
*/
|
|
369
|
+
const ABSENT_REASONS: readonly (RecordRefusal | BoardRefusal)[] = [
|
|
370
|
+
'no-folder',
|
|
371
|
+
'no-board',
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Whether a refusal is a folder this machine never created rather than a break.
|
|
376
|
+
*
|
|
377
|
+
* Every gitignored record folder is absent on a fresh clone and in CI, so six
|
|
378
|
+
* of the twelve refuse there on every run. Counting those as unmeasured pins
|
|
379
|
+
* the verdict at `incomplete` forever, and a signal that never changes is one
|
|
380
|
+
* nobody reads after the second time they see it.
|
|
381
|
+
*
|
|
382
|
+
* A tracked corpus gets no such allowance. That tree ships to targets, so a
|
|
383
|
+
* checkout that cannot find it is broken, and reading the absence as ordinary
|
|
384
|
+
* would report a pass over a corpus nobody measured.
|
|
385
|
+
*/
|
|
386
|
+
function isExpectedAbsence(spec: AuditSpec, record: unknown): boolean {
|
|
387
|
+
if (spec.corpus !== 'per-machine') return false
|
|
388
|
+
|
|
389
|
+
const reason = asObject(record)?.reason
|
|
390
|
+
return (
|
|
391
|
+
typeof reason === 'string' &&
|
|
392
|
+
(ABSENT_REASONS as readonly string[]).includes(reason)
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Turns one verb's exit code and stdout into a result the aggregate can report.
|
|
398
|
+
*
|
|
399
|
+
* Unparseable output is `unmeasured` rather than clean, for the reason the
|
|
400
|
+
* verify pipeline already states about its own skipped stages: a run that does
|
|
401
|
+
* not report is a broken command, and skipping would report the pass the check
|
|
402
|
+
* exists to withhold.
|
|
403
|
+
*/
|
|
404
|
+
export function classify(
|
|
405
|
+
spec: AuditSpec,
|
|
406
|
+
exitCode: number,
|
|
407
|
+
stdout: string,
|
|
408
|
+
): AuditResult {
|
|
409
|
+
const base = {
|
|
410
|
+
id: spec.id,
|
|
411
|
+
label: spec.label,
|
|
412
|
+
tracked: isTracked(spec),
|
|
413
|
+
exitCode,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let record: unknown
|
|
417
|
+
let parsed = true
|
|
418
|
+
try {
|
|
419
|
+
record = JSON.parse(stdout)
|
|
420
|
+
} catch {
|
|
421
|
+
parsed = false
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (exitCode === EXIT_REFUSED) {
|
|
425
|
+
return {
|
|
426
|
+
...base,
|
|
427
|
+
status: isExpectedAbsence(spec, record) ? 'absent' : 'unmeasured',
|
|
428
|
+
reason: parsed
|
|
429
|
+
? refusalReason(record, exitCode)
|
|
430
|
+
: `refused with no record on stdout`,
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const counts = parsed ? spec.counts(record) : undefined
|
|
435
|
+
|
|
436
|
+
if (spec.gatingExits.includes(exitCode)) {
|
|
437
|
+
return { ...base, status: 'finding', ...(counts && { counts }) }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (exitCode !== 0 && exitCode !== EXIT_FINDINGS) {
|
|
441
|
+
return {
|
|
442
|
+
...base,
|
|
443
|
+
status: 'unmeasured',
|
|
444
|
+
reason: `exited ${exitCode}, which this verb does not document`,
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (counts === undefined) {
|
|
449
|
+
return {
|
|
450
|
+
...base,
|
|
451
|
+
status: 'unmeasured',
|
|
452
|
+
reason: parsed
|
|
453
|
+
? 'the record did not carry the keys this audit reads'
|
|
454
|
+
: 'stdout carried no JSON record',
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (exitCode === EXIT_FINDINGS) {
|
|
459
|
+
return { ...base, status: 'reported', counts }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const quiet = Object.values(counts).every((value) => value === 0)
|
|
463
|
+
return { ...base, status: quiet ? 'clean' : 'reported', counts }
|
|
464
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
import type { Delta } from '@/audits/baseline'
|
|
4
|
+
import { type AuditResult, type AuditSpec, classify } from '@/audits/catalog'
|
|
5
|
+
import { gitEnv } from '@/git-env'
|
|
6
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
7
|
+
|
|
8
|
+
/** At least one audit reported a finding that is a fact. */
|
|
9
|
+
export const EXIT_FINDING = 2
|
|
10
|
+
|
|
11
|
+
/** At least one audit did not report, so the run measured less than the set. */
|
|
12
|
+
export const EXIT_UNMEASURED = 3
|
|
13
|
+
|
|
14
|
+
export type Verdict =
|
|
15
|
+
/** Every audit reported and every count it produced was zero. */
|
|
16
|
+
| 'clean'
|
|
17
|
+
/** Every audit reported and at least one carries a judgment finding. */
|
|
18
|
+
| 'reported'
|
|
19
|
+
/** At least one audit carries a finding that is a fact. */
|
|
20
|
+
| 'findings'
|
|
21
|
+
/** At least one audit did not report, so this is not a pass. */
|
|
22
|
+
| 'incomplete'
|
|
23
|
+
|
|
24
|
+
export interface SpawnResult {
|
|
25
|
+
readonly exitCode: number
|
|
26
|
+
readonly stdout: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Spawn = (spec: AuditSpec) => Promise<SpawnResult>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runs each verb out of the checkout this CLI is executing from.
|
|
33
|
+
*
|
|
34
|
+
* `process.execPath` and the resolved `cli.ts` rather than a bare `aitk`, for
|
|
35
|
+
* the reason `verify.sh` already names: a globally installed binary resolves to
|
|
36
|
+
* the main checkout no matter which worktree is running, so the aggregate would
|
|
37
|
+
* measure the wrong tree and report a pass over a branch it never read.
|
|
38
|
+
*
|
|
39
|
+
* `reject: false` because a findings exit is the ordinary outcome for half of
|
|
40
|
+
* these verbs, and a throw there would be an error path for a working check.
|
|
41
|
+
*/
|
|
42
|
+
export function spawnAudit(root: string): Spawn {
|
|
43
|
+
const cli = join(PROJECT_ROOT, 'src', 'cli.ts')
|
|
44
|
+
|
|
45
|
+
return async (spec) => {
|
|
46
|
+
const result = await execa(process.execPath, [cli, ...spec.argv], {
|
|
47
|
+
cwd: root,
|
|
48
|
+
reject: false,
|
|
49
|
+
// `gitEnv` already returns the whole ambient environment minus git's
|
|
50
|
+
// resolution variables, so it replaces the environment rather than
|
|
51
|
+
// extending it. Spreading `process.env` alongside would put back the very
|
|
52
|
+
// variables it strips, and a hook's `GIT_DIR` would then point every
|
|
53
|
+
// history-reading verb at a repository nobody asked about.
|
|
54
|
+
env: { ...gitEnv(), AITK_NON_INTERACTIVE: '1' },
|
|
55
|
+
extendEnv: false,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return { exitCode: result.exitCode ?? 1, stdout: result.stdout }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Runs every audit and reads each one's own record shape.
|
|
64
|
+
*
|
|
65
|
+
* The verbs walk separate trees and share no state, so they run together
|
|
66
|
+
* rather than in sequence. Serially, the aggregate would be the slowest thing
|
|
67
|
+
* in the verify pipeline for no reason beyond the order they were written in.
|
|
68
|
+
*
|
|
69
|
+
* A spawn that throws becomes an `unmeasured` result rather than a rejection.
|
|
70
|
+
* One absent binary would otherwise take the whole aggregate down and report
|
|
71
|
+
* nothing about the audits that did run.
|
|
72
|
+
*/
|
|
73
|
+
export async function runAudits(
|
|
74
|
+
specs: readonly AuditSpec[],
|
|
75
|
+
spawn: Spawn,
|
|
76
|
+
): Promise<AuditResult[]> {
|
|
77
|
+
return Promise.all(
|
|
78
|
+
specs.map(async (spec) => {
|
|
79
|
+
try {
|
|
80
|
+
const { exitCode, stdout } = await spawn(spec)
|
|
81
|
+
return classify(spec, exitCode, stdout)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
id: spec.id,
|
|
85
|
+
label: spec.label,
|
|
86
|
+
status: 'unmeasured' as const,
|
|
87
|
+
tracked: spec.corpus === 'tracked',
|
|
88
|
+
exitCode: 1,
|
|
89
|
+
reason: `could not be started: ${error instanceof Error ? error.message : String(error)}`,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The single verdict over the set.
|
|
98
|
+
*
|
|
99
|
+
* `incomplete` outranks a quiet set on purpose. An aggregate reporting a pass
|
|
100
|
+
* over a tree it did not finish measuring is the failure this whole command
|
|
101
|
+
* exists against, and an empty set takes it for the same reason: nothing ran,
|
|
102
|
+
* so nothing passed.
|
|
103
|
+
*
|
|
104
|
+
* An `absent` per-machine corpus does not reach it. That folder is gitignored
|
|
105
|
+
* and missing on every fresh clone, so folding it in would pin the verdict at
|
|
106
|
+
* `incomplete` on every CI run and leave the word meaning nothing.
|
|
107
|
+
*/
|
|
108
|
+
export function verdictOf(results: readonly AuditResult[]): Verdict {
|
|
109
|
+
if (results.length === 0) return 'incomplete'
|
|
110
|
+
if (results.some((result) => result.status === 'finding')) return 'findings'
|
|
111
|
+
if (results.some((result) => result.status === 'unmeasured')) {
|
|
112
|
+
return 'incomplete'
|
|
113
|
+
}
|
|
114
|
+
if (results.some((result) => result.status === 'reported')) return 'reported'
|
|
115
|
+
return 'clean'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Exits non-zero only on a fact, which is the split this aggregate inherits
|
|
120
|
+
* rather than moves. A growing judgment count reports as a delta and fails
|
|
121
|
+
* nothing, because the standards behind those measures set no hard cap.
|
|
122
|
+
*
|
|
123
|
+
* An audit that did not report takes its own code rather than the findings one.
|
|
124
|
+
* The two mean opposite things to whoever reads the exit: a fact is a defect in
|
|
125
|
+
* the tree, and an unmeasured audit is a defect in the run.
|
|
126
|
+
*/
|
|
127
|
+
export function exitCodeFor(results: readonly AuditResult[]): number {
|
|
128
|
+
const verdict = verdictOf(results)
|
|
129
|
+
if (verdict === 'findings') return EXIT_FINDING
|
|
130
|
+
if (verdict === 'incomplete') return EXIT_UNMEASURED
|
|
131
|
+
return 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface Summary {
|
|
135
|
+
readonly verdict: Verdict
|
|
136
|
+
/** Audits that reported, out of the whole set. */
|
|
137
|
+
readonly audited: number
|
|
138
|
+
/** Audits carrying a finding that is a fact. */
|
|
139
|
+
readonly facts: number
|
|
140
|
+
/** Audits that did not report at all. */
|
|
141
|
+
readonly unmeasured: number
|
|
142
|
+
/**
|
|
143
|
+
* Audits whose per-machine folder is not on this disk.
|
|
144
|
+
*
|
|
145
|
+
* Published rather than folded into `audited`, so a run stating twelve
|
|
146
|
+
* audits never implies twelve corpora were read.
|
|
147
|
+
*/
|
|
148
|
+
readonly absent: number
|
|
149
|
+
/** Tracked counts that rose against the recorded floor. */
|
|
150
|
+
readonly grown: number
|
|
151
|
+
/** Tracked counts that fell against the recorded floor. */
|
|
152
|
+
readonly shrunk: number
|
|
153
|
+
/** Tracked audits with no recorded floor to compare against. */
|
|
154
|
+
readonly unrecorded: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The flat reading a shell stage takes without parsing the nested record.
|
|
159
|
+
*
|
|
160
|
+
* Published rather than left to a consumer to derive, for the reason the
|
|
161
|
+
* context audit already gives about its own join: deriving it means restating
|
|
162
|
+
* which question each number answers, and one wrong restatement is growth
|
|
163
|
+
* reported against a measure that never moved. Every key here is unique across
|
|
164
|
+
* the whole record, so a grep for one reaches the top level alone.
|
|
165
|
+
*/
|
|
166
|
+
export function summarize(
|
|
167
|
+
results: readonly AuditResult[],
|
|
168
|
+
deltas: readonly Delta[],
|
|
169
|
+
): Summary {
|
|
170
|
+
let grown = 0
|
|
171
|
+
let shrunk = 0
|
|
172
|
+
let unrecorded = 0
|
|
173
|
+
|
|
174
|
+
for (const delta of deltas) {
|
|
175
|
+
if (delta.kind === 'unrecorded') {
|
|
176
|
+
unrecorded += 1
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
if (delta.kind !== 'compared') continue
|
|
180
|
+
|
|
181
|
+
for (const moved of delta.moved) {
|
|
182
|
+
if (moved.delta > 0) grown += 1
|
|
183
|
+
else shrunk += 1
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const counting = (status: AuditResult['status']) =>
|
|
188
|
+
results.filter((result) => result.status === status).length
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
verdict: verdictOf(results),
|
|
192
|
+
audited: results.filter(
|
|
193
|
+
(result) => result.status !== 'unmeasured' && result.status !== 'absent',
|
|
194
|
+
).length,
|
|
195
|
+
facts: counting('finding'),
|
|
196
|
+
unmeasured: counting('unmeasured'),
|
|
197
|
+
absent: counting('absent'),
|
|
198
|
+
grown,
|
|
199
|
+
shrunk,
|
|
200
|
+
unrecorded,
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { register as context } from '@/commands/context'
|
|
|
27
27
|
import { register as markdown } from '@/commands/markdown'
|
|
28
28
|
import { register as records } from '@/commands/records'
|
|
29
29
|
import { register as sessions } from '@/commands/sessions'
|
|
30
|
+
import { register as audits } from '@/commands/audits'
|
|
30
31
|
import { PROJECT_ROOT } from '@/project-root'
|
|
31
32
|
|
|
32
33
|
const GREY = '\x1b[0;90m'
|
|
@@ -63,6 +64,7 @@ function showHelp(): void {
|
|
|
63
64
|
`${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
|
|
64
65
|
`${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, push, pull)${NC}`,
|
|
65
66
|
`${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
|
|
67
|
+
`${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
|
|
66
68
|
`${GREY}│${NC}`,
|
|
67
69
|
`${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
|
|
68
70
|
`${GREY}│${NC} aitk sandbox ${GREY}# Interactive scenario picker${NC}`,
|
|
@@ -101,6 +103,7 @@ function showHelp(): void {
|
|
|
101
103
|
`${GREY}│${NC} aitk records validate plans`,
|
|
102
104
|
`${GREY}│${NC} aitk records push --json`,
|
|
103
105
|
`${GREY}│${NC} aitk sessions list --json`,
|
|
106
|
+
`${GREY}│${NC} aitk audits run --json`,
|
|
104
107
|
`${GREY}└${NC}`,
|
|
105
108
|
]
|
|
106
109
|
console.log(lines.join('\n'))
|
|
@@ -159,5 +162,6 @@ context(program)
|
|
|
159
162
|
markdown(program)
|
|
160
163
|
records(program)
|
|
161
164
|
sessions(program)
|
|
165
|
+
audits(program)
|
|
162
166
|
|
|
163
167
|
program.parse()
|