@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,342 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import type { Command } from 'commander'
|
|
3
|
+
import {
|
|
4
|
+
BASELINE_REL,
|
|
5
|
+
type Baseline,
|
|
6
|
+
baselineFrom,
|
|
7
|
+
compareBaseline,
|
|
8
|
+
type Delta,
|
|
9
|
+
readBaseline,
|
|
10
|
+
writeBaseline,
|
|
11
|
+
} from '@/audits/baseline'
|
|
12
|
+
import { AUDITS, type AuditResult } from '@/audits/catalog'
|
|
13
|
+
import {
|
|
14
|
+
exitCodeFor,
|
|
15
|
+
runAudits,
|
|
16
|
+
spawnAudit,
|
|
17
|
+
type Summary,
|
|
18
|
+
summarize,
|
|
19
|
+
} from '@/audits/run'
|
|
20
|
+
import { gitEnv } from '@/git-env'
|
|
21
|
+
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
22
|
+
import { currentWorktreeRoot } from '@/worktree'
|
|
23
|
+
|
|
24
|
+
interface RunCommandOptions {
|
|
25
|
+
readonly json?: boolean
|
|
26
|
+
readonly record?: boolean
|
|
27
|
+
readonly root?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ListCommandOptions {
|
|
31
|
+
readonly json?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function register(program: Command): void {
|
|
35
|
+
const audits = program
|
|
36
|
+
.command('audits')
|
|
37
|
+
.description('Run every health check this repository owns as one set')
|
|
38
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
39
|
+
|
|
40
|
+
audits
|
|
41
|
+
.command('run')
|
|
42
|
+
.description(
|
|
43
|
+
'Run every audit, report per check under one verdict, and compare each count to the recorded baseline',
|
|
44
|
+
)
|
|
45
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
46
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
47
|
+
.option(
|
|
48
|
+
'--root <path>',
|
|
49
|
+
'Tree to measure, defaulting to the current worktree',
|
|
50
|
+
)
|
|
51
|
+
.option(
|
|
52
|
+
'--record',
|
|
53
|
+
`Write this run's tracked counts to ${BASELINE_REL} as the new baseline`,
|
|
54
|
+
)
|
|
55
|
+
.addHelpText(
|
|
56
|
+
'after',
|
|
57
|
+
[
|
|
58
|
+
'',
|
|
59
|
+
'Exit codes:',
|
|
60
|
+
' 0 every audit reported and none carried a finding that is a fact',
|
|
61
|
+
' 1 refused, with the reason on stderr',
|
|
62
|
+
' 2 an audit carries a finding that is a fact',
|
|
63
|
+
' 3 an audit did not report, so the run measured less than the set',
|
|
64
|
+
'',
|
|
65
|
+
'It gates on exactly what already gates a push and on nothing new:',
|
|
66
|
+
'an unresolved context citation, a banned character, word, or spelling,',
|
|
67
|
+
'and a skill folder carrying no REQUIREMENT.md. Every other measure is a',
|
|
68
|
+
'judgment a reader settles, and failing a push on one teaches',
|
|
69
|
+
'contributors to route around the stage.',
|
|
70
|
+
'',
|
|
71
|
+
'Exit 3 is a defect in the run rather than in the tree. An aggregate that',
|
|
72
|
+
'reports a pass over a set it did not finish measuring is the failure this',
|
|
73
|
+
'command exists against, so a verb that did not report takes its own code.',
|
|
74
|
+
'',
|
|
75
|
+
`The baseline at ${BASELINE_REL} holds the counts from the last recorded`,
|
|
76
|
+
'run, so a measure that reports rather than gates still costs something',
|
|
77
|
+
'when it grows. Only a tracked corpus is retained: a gitignored record',
|
|
78
|
+
"folder holds one machine's scratch, and its counts answer nobody else.",
|
|
79
|
+
'',
|
|
80
|
+
'Examples:',
|
|
81
|
+
' aitk audits run',
|
|
82
|
+
' aitk audits run --json',
|
|
83
|
+
' aitk audits run --record',
|
|
84
|
+
'',
|
|
85
|
+
].join('\n'),
|
|
86
|
+
)
|
|
87
|
+
.action(async (opts: RunCommandOptions) => {
|
|
88
|
+
process.exitCode = await runAll(opts)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
audits
|
|
92
|
+
.command('list')
|
|
93
|
+
.description(
|
|
94
|
+
'List every audit this command runs and what each one gates on',
|
|
95
|
+
)
|
|
96
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
97
|
+
.option('--json', 'Emit JSON with the id, invocation, corpus, and gate')
|
|
98
|
+
.addHelpText(
|
|
99
|
+
'after',
|
|
100
|
+
[
|
|
101
|
+
'',
|
|
102
|
+
'Exit codes:',
|
|
103
|
+
' 0 the catalog was listed',
|
|
104
|
+
'',
|
|
105
|
+
'Examples:',
|
|
106
|
+
' aitk audits list',
|
|
107
|
+
' aitk audits list --json',
|
|
108
|
+
'',
|
|
109
|
+
].join('\n'),
|
|
110
|
+
)
|
|
111
|
+
.action((opts: ListCommandOptions) => {
|
|
112
|
+
process.exitCode = runList(opts)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runList(opts: ListCommandOptions): number {
|
|
117
|
+
if (opts.json) {
|
|
118
|
+
process.stdout.write(
|
|
119
|
+
`${JSON.stringify({
|
|
120
|
+
audits: AUDITS.map((audit) => ({
|
|
121
|
+
id: audit.id,
|
|
122
|
+
label: audit.label,
|
|
123
|
+
command: `aitk ${audit.argv.join(' ')}`,
|
|
124
|
+
corpus: audit.corpus,
|
|
125
|
+
gates: audit.gatingExits.length > 0,
|
|
126
|
+
})),
|
|
127
|
+
})}\n`,
|
|
128
|
+
)
|
|
129
|
+
return 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
intro('aitk audits list')
|
|
133
|
+
for (const audit of AUDITS) {
|
|
134
|
+
logStep(audit.label)
|
|
135
|
+
logInfo(`aitk ${audit.argv.join(' ')}`)
|
|
136
|
+
logInfo(
|
|
137
|
+
`${audit.corpus} corpus, ${audit.gatingExits.length > 0 ? 'gates on a fact' : 'reports only'}`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
outro()
|
|
141
|
+
return 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The day a record is stamped with, as `YYYY-MM-DD`.
|
|
146
|
+
*
|
|
147
|
+
* Local rather than UTC, because the stamp is read beside a commit date in a
|
|
148
|
+
* context entry and a run taken in the evening should not record tomorrow.
|
|
149
|
+
*/
|
|
150
|
+
function today(): string {
|
|
151
|
+
const now = new Date()
|
|
152
|
+
return [
|
|
153
|
+
now.getFullYear(),
|
|
154
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
155
|
+
String(now.getDate()).padStart(2, '0'),
|
|
156
|
+
].join('-')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The commit the counts were read at, or `unknown` outside a repository.
|
|
161
|
+
*
|
|
162
|
+
* A target project can install this CLI without a git history behind it, and
|
|
163
|
+
* refusing there would withhold the whole report over a field that only makes
|
|
164
|
+
* the record reproducible.
|
|
165
|
+
*/
|
|
166
|
+
async function headCommit(root: string): Promise<string> {
|
|
167
|
+
const result = await execa('git', ['-C', root, 'rev-parse', 'HEAD'], {
|
|
168
|
+
reject: false,
|
|
169
|
+
env: gitEnv(),
|
|
170
|
+
extendEnv: false,
|
|
171
|
+
})
|
|
172
|
+
return result.exitCode === 0 ? result.stdout.trim() : 'unknown'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function runAll(opts: RunCommandOptions): Promise<number> {
|
|
176
|
+
const emitJson = opts.json ?? false
|
|
177
|
+
const root = opts.root ?? (await currentWorktreeRoot())
|
|
178
|
+
|
|
179
|
+
let baseline: Baseline | undefined
|
|
180
|
+
try {
|
|
181
|
+
baseline = await readBaseline(root)
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
184
|
+
if (emitJson) {
|
|
185
|
+
process.stderr.write(`${message}\n`)
|
|
186
|
+
process.stdout.write(
|
|
187
|
+
`${JSON.stringify({ ok: false, reason: 'bad-baseline', message })}\n`,
|
|
188
|
+
)
|
|
189
|
+
return 1
|
|
190
|
+
}
|
|
191
|
+
intro('aitk audits run')
|
|
192
|
+
logStep('Refused')
|
|
193
|
+
logError(message)
|
|
194
|
+
outro()
|
|
195
|
+
return 1
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const results = await runAudits(AUDITS, spawnAudit(root))
|
|
199
|
+
const deltas = compareBaseline(baseline, results)
|
|
200
|
+
const summary = summarize(results, deltas)
|
|
201
|
+
|
|
202
|
+
let recorded: string | undefined
|
|
203
|
+
if (opts.record) {
|
|
204
|
+
const next = baselineFrom(results, {
|
|
205
|
+
recordedAt: today(),
|
|
206
|
+
commit: await headCommit(root),
|
|
207
|
+
})
|
|
208
|
+
recorded = await writeBaseline(root, next)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (emitJson) {
|
|
212
|
+
process.stdout.write(
|
|
213
|
+
`${JSON.stringify({
|
|
214
|
+
ok: true,
|
|
215
|
+
root,
|
|
216
|
+
// Flat scalars, so a shell stage greps one out without a JSON parser.
|
|
217
|
+
// The nested arrays below carry the detail behind each number.
|
|
218
|
+
summary,
|
|
219
|
+
baseline: baseline
|
|
220
|
+
? { recordedAt: baseline.recordedAt, commit: baseline.commit }
|
|
221
|
+
: undefined,
|
|
222
|
+
recorded,
|
|
223
|
+
audits: results,
|
|
224
|
+
deltas,
|
|
225
|
+
})}\n`,
|
|
226
|
+
)
|
|
227
|
+
} else {
|
|
228
|
+
report(results, deltas, baseline, recorded, summary)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return exitCodeFor(results)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The counts of one result, rendered as `key n` pairs a reader can scan. */
|
|
235
|
+
function countLine(counts: Record<string, number>): string {
|
|
236
|
+
return Object.entries(counts)
|
|
237
|
+
.map(([key, value]) => `${key} ${value}`)
|
|
238
|
+
.join(', ')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The delta line for one audit, or nothing when there is no comparison to
|
|
243
|
+
* report. A first run says so rather than showing a delta of zero, since zero
|
|
244
|
+
* against an absent baseline says the same as a corpus that did not move.
|
|
245
|
+
*/
|
|
246
|
+
function deltaLine(delta: Delta): string | undefined {
|
|
247
|
+
if (delta.kind === 'per-machine' || delta.kind === 'unmeasured') {
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
if (delta.kind === 'unrecorded') return 'No recorded baseline to compare'
|
|
251
|
+
|
|
252
|
+
const parts = [
|
|
253
|
+
...delta.moved.map(
|
|
254
|
+
({ key, from, to, delta: moved }) =>
|
|
255
|
+
`${key} ${from} to ${to} (${moved > 0 ? '+' : ''}${moved})`,
|
|
256
|
+
),
|
|
257
|
+
...delta.added.map(({ key, to }) => `${key} ${to}, newly measured`),
|
|
258
|
+
...delta.dropped.map(({ key, from }) => `${key} was ${from}, not measured`),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
return parts.length === 0 ? undefined : parts.join(', ')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function report(
|
|
265
|
+
results: readonly AuditResult[],
|
|
266
|
+
deltas: readonly Delta[],
|
|
267
|
+
baseline: Baseline | undefined,
|
|
268
|
+
recorded: string | undefined,
|
|
269
|
+
summary: Summary,
|
|
270
|
+
): void {
|
|
271
|
+
const byId = new Map(deltas.map((delta) => [delta.id, delta]))
|
|
272
|
+
|
|
273
|
+
intro('aitk audits run')
|
|
274
|
+
|
|
275
|
+
logStep('Baseline')
|
|
276
|
+
if (baseline === undefined) {
|
|
277
|
+
logWarn(`None recorded. Take one with aitk audits run --record.`)
|
|
278
|
+
} else {
|
|
279
|
+
logInfo(`${baseline.recordedAt} at ${baseline.commit.slice(0, 8)}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const result of results) {
|
|
283
|
+
logStep(result.label)
|
|
284
|
+
|
|
285
|
+
if (result.status === 'absent') {
|
|
286
|
+
logInfo(`No corpus on this machine: ${result.reason ?? 'not found'}`)
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (result.status === 'unmeasured') {
|
|
291
|
+
logWarn(`Did not report: ${result.reason ?? 'no reason given'}`)
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const counts = result.counts ?? {}
|
|
296
|
+
const line = countLine(counts)
|
|
297
|
+
if (result.status === 'finding') {
|
|
298
|
+
logError(line === '' ? 'A finding that is a fact' : line)
|
|
299
|
+
} else {
|
|
300
|
+
logInfo(line === '' ? 'Reported' : line)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!result.tracked) {
|
|
304
|
+
logInfo('Per-machine corpus, so no baseline is kept')
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const moved = deltaLine(
|
|
309
|
+
byId.get(result.id) ?? { id: result.id, kind: 'unrecorded' },
|
|
310
|
+
)
|
|
311
|
+
if (moved !== undefined) logInfo(moved)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (recorded !== undefined) {
|
|
315
|
+
logStep('Recorded')
|
|
316
|
+
logInfo(recorded)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
logStep('Verdict')
|
|
320
|
+
|
|
321
|
+
if (summary.verdict === 'findings') {
|
|
322
|
+
logError(
|
|
323
|
+
`${plural(summary.facts, 'audit')} carrying a finding that is a fact. Fix what each names.`,
|
|
324
|
+
)
|
|
325
|
+
} else if (summary.verdict === 'incomplete') {
|
|
326
|
+
logError(
|
|
327
|
+
`${plural(summary.unmeasured, 'audit')} did not report, so this run measured less than the set.`,
|
|
328
|
+
)
|
|
329
|
+
} else if (summary.verdict === 'reported') {
|
|
330
|
+
logInfo('No fact. Every other finding is a judgment a reader settles.')
|
|
331
|
+
} else {
|
|
332
|
+
logInfo('Every audit reported and every count is zero.')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Stated on every run, including a clean one. A count of what passed reads as
|
|
336
|
+
// a verdict on the whole set unless the run also says what it never reached.
|
|
337
|
+
logInfo(
|
|
338
|
+
`${summary.audited} of ${results.length} corpora measured, ${summary.absent} absent on this machine`,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
outro()
|
|
342
|
+
}
|