@erclx/aitk 0.108.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.
@@ -0,0 +1,194 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import type { AuditResult } from '@/audits/catalog'
4
+
5
+ /**
6
+ * Where the retained counts live, relative to the project root.
7
+ *
8
+ * Committed rather than per-machine, because the question this half answers is
9
+ * whether a number grew since anyone last looked, and a fresh checkout has to
10
+ * inherit that answer. A per-machine record makes every contributor's first run
11
+ * a first run.
12
+ *
13
+ * Under the project root rather than beside the aggregator in `src/`, because
14
+ * the numbers describe one repository's corpus and `src/` ships to every target
15
+ * that installs the CLI. A baseline in the package would hand a target this
16
+ * repository's counts to measure its own tree against.
17
+ */
18
+ export const BASELINE_REL = join('.claude', 'audits', 'baseline.json')
19
+
20
+ export interface Baseline {
21
+ /** The day the record was taken, as `YYYY-MM-DD`. */
22
+ readonly recordedAt: string
23
+ /** The commit the counts were read at, so a reader can reproduce them. */
24
+ readonly commit: string
25
+ readonly checks: Readonly<Record<string, Readonly<Record<string, number>>>>
26
+ }
27
+
28
+ export interface Stamp {
29
+ readonly recordedAt: string
30
+ readonly commit: string
31
+ }
32
+
33
+ export interface MovedCount {
34
+ readonly key: string
35
+ readonly from: number
36
+ readonly to: number
37
+ readonly delta: number
38
+ }
39
+
40
+ export type Delta =
41
+ | {
42
+ readonly id: string
43
+ readonly kind: 'compared'
44
+ readonly moved: readonly MovedCount[]
45
+ /** Keys the run reproduced exactly. */
46
+ readonly steady: readonly string[]
47
+ /** Keys the run produced that the baseline never recorded. */
48
+ readonly added: readonly { key: string; to: number }[]
49
+ /** Keys the baseline holds that this run did not produce. */
50
+ readonly dropped: readonly { key: string; from: number }[]
51
+ }
52
+ /** No recorded floor, so a zero delta would be indistinguishable from quiet. */
53
+ | { readonly id: string; readonly kind: 'unrecorded' }
54
+ /** Gitignored scratch, whose counts are one machine's and answer nobody else. */
55
+ | { readonly id: string; readonly kind: 'per-machine' }
56
+ /** The audit did not report, so there is nothing to compare. */
57
+ | { readonly id: string; readonly kind: 'unmeasured' }
58
+
59
+ /**
60
+ * Builds the record a run leaves behind.
61
+ *
62
+ * Only a tracked corpus is retained. A gitignored record folder holds one
63
+ * machine's session scratch, so committing its counts writes a floor no other
64
+ * clone can reproduce, and every contributor would read a regression against a
65
+ * number that describes somebody else's disk.
66
+ *
67
+ * An audit that did not report is left out rather than written as zero. Zeros
68
+ * there record a clean corpus nobody measured, and the next run reads its real
69
+ * numbers as a regression against a floor that was never taken.
70
+ */
71
+ export function baselineFrom(
72
+ results: readonly AuditResult[],
73
+ stamp: Stamp,
74
+ ): Baseline {
75
+ const checks: Record<string, Record<string, number>> = {}
76
+
77
+ for (const result of results) {
78
+ if (!result.tracked || result.counts === undefined) continue
79
+ checks[result.id] = { ...result.counts }
80
+ }
81
+
82
+ return { recordedAt: stamp.recordedAt, commit: stamp.commit, checks }
83
+ }
84
+
85
+ /**
86
+ * Compares this run against the recorded floor, one audit at a time.
87
+ *
88
+ * A first run reports `unrecorded` rather than a delta of zero. A zero delta
89
+ * against an absent baseline says the same thing as a corpus that did not move,
90
+ * and those are the two states this repository has already had to separate
91
+ * twice elsewhere.
92
+ */
93
+ export function compareBaseline(
94
+ baseline: Baseline | undefined,
95
+ results: readonly AuditResult[],
96
+ ): Delta[] {
97
+ return results.map((result) => {
98
+ if (!result.tracked) return { id: result.id, kind: 'per-machine' as const }
99
+ if (result.counts === undefined) {
100
+ return { id: result.id, kind: 'unmeasured' as const }
101
+ }
102
+
103
+ const recorded = baseline?.checks[result.id]
104
+ if (recorded === undefined) {
105
+ return { id: result.id, kind: 'unrecorded' as const }
106
+ }
107
+
108
+ const moved: MovedCount[] = []
109
+ const steady: string[] = []
110
+ const added: { key: string; to: number }[] = []
111
+ const dropped: { key: string; from: number }[] = []
112
+
113
+ for (const [key, to] of Object.entries(result.counts)) {
114
+ const from = recorded[key]
115
+ if (from === undefined) {
116
+ added.push({ key, to })
117
+ } else if (from === to) {
118
+ steady.push(key)
119
+ } else {
120
+ moved.push({ key, from, to, delta: to - from })
121
+ }
122
+ }
123
+
124
+ for (const [key, from] of Object.entries(recorded)) {
125
+ if (!(key in result.counts)) dropped.push({ key, from })
126
+ }
127
+
128
+ return {
129
+ id: result.id,
130
+ kind: 'compared' as const,
131
+ moved,
132
+ steady,
133
+ added,
134
+ dropped,
135
+ }
136
+ })
137
+ }
138
+
139
+ function isBaseline(value: unknown): value is Baseline {
140
+ if (typeof value !== 'object' || value === null) return false
141
+ const record = value as Record<string, unknown>
142
+ return (
143
+ typeof record.recordedAt === 'string' &&
144
+ typeof record.commit === 'string' &&
145
+ typeof record.checks === 'object' &&
146
+ record.checks !== null &&
147
+ !Array.isArray(record.checks)
148
+ )
149
+ }
150
+
151
+ /**
152
+ * Reads the recorded floor, or `undefined` when none has been taken.
153
+ *
154
+ * An absent file and a broken one are different answers. Absent is a first run.
155
+ * Broken is a record someone hand-edited into a shape nothing can read, and
156
+ * reading that as absent would silently reset the floor the file exists to hold.
157
+ */
158
+ export async function readBaseline(
159
+ root: string,
160
+ ): Promise<Baseline | undefined> {
161
+ const path = join(root, BASELINE_REL)
162
+
163
+ let raw: string
164
+ try {
165
+ raw = await readFile(path, 'utf8')
166
+ } catch {
167
+ return undefined
168
+ }
169
+
170
+ let parsed: unknown
171
+ try {
172
+ parsed = JSON.parse(raw)
173
+ } catch {
174
+ throw new Error(`${BASELINE_REL} does not parse as JSON. Fix or delete it.`)
175
+ }
176
+
177
+ if (!isBaseline(parsed)) {
178
+ throw new Error(
179
+ `${BASELINE_REL} carries no recordedAt, commit, and checks. Fix or delete it.`,
180
+ )
181
+ }
182
+
183
+ return parsed
184
+ }
185
+
186
+ export async function writeBaseline(
187
+ root: string,
188
+ baseline: Baseline,
189
+ ): Promise<string> {
190
+ const path = join(root, BASELINE_REL)
191
+ await mkdir(dirname(path), { recursive: true })
192
+ await writeFile(path, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8')
193
+ return path
194
+ }
@@ -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
+ }