@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/REQUIREMENT.md +1 -3
- package/claude/skills/claude-orchestrate/SKILL.md +10 -18
- package/claude/skills/claude-pr-review/REQUIREMENT.md +1 -1
- package/claude/skills/claude-pr-review/SKILL.md +6 -6
- package/claude/skills/migration-superseded/REQUIREMENT.md +1 -1
- package/claude/skills/migration-superseded/SKILL.md +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/docs/ai-workflow.md +2 -3
- package/docs/operating-model.md +18 -19
- 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
- package/standards/requirements.md +2 -2
- package/standards/tasks.md +4 -3
- package/claude/skills/claude-roadmap/REQUIREMENT.md +0 -41
- package/claude/skills/claude-roadmap/SKILL.md +0 -61
- package/claude/skills/claude-roadmap/references/roadmap.md +0 -62
- package/standards/bundled/roadmap.md +0 -62
|
@@ -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
|
+
}
|