@erclx/aitk 3.52.0 → 3.53.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,412 @@
1
+ import {
2
+ auditSet,
3
+ heroStamp,
4
+ markdownBans,
5
+ type Measure,
6
+ pluginManifests,
7
+ sandboxCoverage,
8
+ seedStandards,
9
+ standardCriteria,
10
+ unreferencedRules,
11
+ } from '@/gate/measures'
12
+
13
+ /**
14
+ * One thing a stage does, as an argument vector rather than a shell line.
15
+ *
16
+ * The script this replaces held every check as text and handed it to `eval`,
17
+ * which made each stage a quoting question: whether the string wanted a shell
18
+ * or was an argument list that should never see one. Nothing in the set turned
19
+ * out to depend on expansion once the drift assertion stopped being a shell
20
+ * line, so the whole table is vectors and the hazard is gone rather than
21
+ * carried forward.
22
+ */
23
+ export type Check =
24
+ /** Any binary on PATH, or a script named by path under the project root. */
25
+ | {
26
+ readonly kind: 'command'
27
+ readonly argv: readonly string[]
28
+ readonly failure: string
29
+ }
30
+ /** This checkout's own CLI, so a worktree gate never reads the main tree. */
31
+ | {
32
+ readonly kind: 'cli'
33
+ readonly argv: readonly string[]
34
+ readonly failure: string
35
+ }
36
+ /**
37
+ * A regenerated surface asserted against the index and the working tree.
38
+ *
39
+ * Both halves are read, since a regen emitting a file nobody ever committed
40
+ * satisfies a diff and fails here. The pathspec is one argument rather than a
41
+ * shell word, which is what git already receives: `*` matches a slash in a
42
+ * pathspec, so `*index.md` reaches a nested index the way it always did.
43
+ */
44
+ | {
45
+ readonly kind: 'drift'
46
+ readonly pathspec: string
47
+ readonly failure: string
48
+ }
49
+ /** A reading whose verdict is a comparison rather than an exit code. */
50
+ | { readonly kind: 'measure'; readonly measure: Measure }
51
+
52
+ export interface Stage {
53
+ readonly id: string
54
+ /** The heading this stage prints, matching the frame it replaces. */
55
+ readonly label: string
56
+ /**
57
+ * The changed-file pattern this stage needs before it runs. Absent means the
58
+ * stage always runs, which is correct wherever the input is diffuse enough
59
+ * that no path predicts it.
60
+ */
61
+ readonly scope?: RegExp
62
+ /** What a scoped-out stage says, so a skip never reads as a pass. */
63
+ readonly skipped?: string
64
+ /**
65
+ * Whether this stage belongs in the run at all, read off the write grant
66
+ * rather than the changed set. Absent means it always belongs.
67
+ */
68
+ readonly when?: (options: { readonly write: boolean }) => boolean
69
+ readonly checks: readonly Check[]
70
+ /** The line a clean stage prints, where the checks emit none of their own. */
71
+ readonly success?: string
72
+ }
73
+
74
+ /**
75
+ * Corpora a `src/` test asserts over from outside `src/`, censused in
76
+ * `.claude/context/development/verification.md`. This list and that census are
77
+ * two copies of one set with nothing comparing them, so a corpus joining the
78
+ * census joins this list in the same change. The first four are directory
79
+ * prefixes because their tests walk the tree whole, which is what reaches a
80
+ * rule or a skill a branch adds rather than edits.
81
+ */
82
+ export const TEST_CORPORA_PATTERNS = [
83
+ '^claude/skills/',
84
+ '^governance/rules/',
85
+ '^\\.claude/hooks/',
86
+ '^tooling/claude/seeds/\\.claude/hooks/',
87
+ '^standards/markdown\\.md$',
88
+ '^tooling/base/reference\\.md$',
89
+ '^tooling/web/configs/scripts/worktree-port\\.sh$',
90
+ '^\\.cspell/banned-spellings\\.txt$',
91
+ '^scripts/lib/worktree\\.sh$',
92
+ '^scripts/core/check-ignore-parity\\.sh$',
93
+ ]
94
+
95
+ const TESTS_SCOPE = new RegExp(
96
+ [
97
+ '^src/',
98
+ '^vitest\\.config\\.ts$',
99
+ '^tsconfig\\.json$',
100
+ '^package\\.json$',
101
+ ...TEST_CORPORA_PATTERNS,
102
+ ].join('|'),
103
+ )
104
+
105
+ const script = (name: string, failure: string): Check => ({
106
+ kind: 'command',
107
+ argv: ['bash', `scripts/core/${name}`],
108
+ failure,
109
+ })
110
+
111
+ /**
112
+ * Every stage the gate runs, in the order it runs them.
113
+ *
114
+ * Order is the product here rather than an accident of the file. The four
115
+ * regenerating stages run before anything reads what they wrote, the stages
116
+ * calling this checkout's CLI sit together so a reader meets one group rather
117
+ * than the same kind of stage at intervals, and the three scoped stages sit
118
+ * last where a skip costs the run nothing it already paid for.
119
+ *
120
+ * A stage halts the run when one of its checks fails, so clearing a
121
+ * regenerate-then-assert stage reveals the next one behind it rather than the
122
+ * whole set at once.
123
+ */
124
+ export const STAGES: readonly Stage[] = [
125
+ {
126
+ id: 'format',
127
+ label: 'Formatting',
128
+ when: ({ write }) => write,
129
+ checks: [
130
+ {
131
+ kind: 'command',
132
+ argv: ['bun', 'run', 'format'],
133
+ failure: 'Format failed',
134
+ },
135
+ ],
136
+ success: 'Format applied',
137
+ },
138
+ {
139
+ id: 'format-check',
140
+ label: 'Format check',
141
+ when: ({ write }) => !write,
142
+ checks: [
143
+ {
144
+ kind: 'command',
145
+ argv: ['bun', 'run', 'check:format'],
146
+ failure: 'Format check failed',
147
+ },
148
+ ],
149
+ success: 'Format check passed',
150
+ },
151
+ {
152
+ id: 'indexes',
153
+ label: 'Indexes',
154
+ checks: [
155
+ script('regen-indexes.sh', 'Index regen failed'),
156
+ {
157
+ kind: 'drift',
158
+ pathspec: '*index.md',
159
+ failure:
160
+ 'Indexes drifted. Run bun run check and commit the updated index files.',
161
+ },
162
+ ],
163
+ success: 'Indexes clean',
164
+ },
165
+ {
166
+ id: 'consumed-copies',
167
+ label: 'Consumed copies',
168
+ checks: [
169
+ script('regen-claude-copies.sh', 'Consumed-copy regen failed'),
170
+ {
171
+ kind: 'drift',
172
+ pathspec: '.claude/rules',
173
+ failure:
174
+ 'Consumed copies drifted. Run bun run check and commit .claude/rules.',
175
+ },
176
+ ],
177
+ success: 'Consumed copies clean',
178
+ },
179
+ {
180
+ // Only the markup is asserted for drift. The image beside it is a chromium
181
+ // render whose bytes move with the browser version, so a drift check over it
182
+ // would fail on a machine whose chromium differs rather than on a stale
183
+ // count. The stamp measure below is what covers the image instead.
184
+ id: 'hero',
185
+ label: 'Hero',
186
+ checks: [
187
+ script('regen-hero.sh', 'Hero regen failed'),
188
+ {
189
+ kind: 'drift',
190
+ pathspec: 'assets/hero.html',
191
+ failure:
192
+ 'Hero counts drifted. Run bun run check, then aitk capture assets/hero.html, and commit assets/hero.html with assets/hero.png and assets/hero.stamp.',
193
+ },
194
+ { kind: 'measure', measure: heroStamp },
195
+ ],
196
+ success: 'Hero clean',
197
+ },
198
+ {
199
+ id: 'tooling-paths',
200
+ label: 'Tooling paths',
201
+ checks: [
202
+ script('regen-tooling-paths.sh', 'Tooling-path regen failed'),
203
+ {
204
+ kind: 'drift',
205
+ pathspec: 'claude/skills/aitk-cli/SKILL.md',
206
+ failure:
207
+ 'The overwrite contract drifted from what the stacks hold. Run bun run check and commit claude/skills/aitk-cli/SKILL.md.',
208
+ },
209
+ ],
210
+ success: 'Tooling paths clean',
211
+ },
212
+ {
213
+ // The claude manifest is the only route a target's ignore set travels, and
214
+ // it is hand-maintained beside this repository's own `.gitignore` with
215
+ // nothing comparing the two. A drift between them reaches every target on
216
+ // the next `aitk tooling sync` and surfaces to nobody, which is why this
217
+ // gates rather than reports. It is not a drift assert: no generator
218
+ // produces either list, so there is nothing to regenerate and diff.
219
+ id: 'ignore-parity',
220
+ label: 'Ignore parity',
221
+ checks: [
222
+ script(
223
+ 'check-ignore-parity.sh',
224
+ "The ignore set a target receives disagrees with this repository's own.",
225
+ ),
226
+ ],
227
+ success: 'Ignore parity clean',
228
+ },
229
+ {
230
+ id: 'skill-paths',
231
+ label: 'Skill paths',
232
+ checks: [
233
+ script(
234
+ 'check-skill-paths.sh',
235
+ 'Shipped skills reference a repo-local path.',
236
+ ),
237
+ ],
238
+ success: 'Skill paths clean',
239
+ },
240
+ {
241
+ id: 'plugin-boundary',
242
+ label: 'Plugin boundary',
243
+ checks: [
244
+ script(
245
+ 'check-plugin-boundary.sh',
246
+ 'Plugin ships toolkit-internal content.',
247
+ ),
248
+ ],
249
+ success: 'Plugin boundary clean',
250
+ },
251
+ {
252
+ // Seed prose is installed into every scaffolded project and read there as
253
+ // instruction about that project, so a line naming this repository's CLI
254
+ // hands a target a verb it may not be able to run. This gates for the
255
+ // reason the seed-standards stage below gates: a defect authored once
256
+ // propagates into every project scaffolded after it.
257
+ id: 'seed-independence',
258
+ label: 'Seed independence',
259
+ checks: [
260
+ script('check-seed-independence.sh', 'Seed prose cites the toolkit CLI.'),
261
+ ],
262
+ success: 'Seed prose cites no toolkit CLI',
263
+ },
264
+ {
265
+ // A stack entry naming a rule folder takes every rule in it, which is what
266
+ // stops a new rule from needing a second edit to reach a target. The
267
+ // failure it leaves open is a rule authored into a folder no stack names,
268
+ // which `aitk gov install` never reaches on its own.
269
+ id: 'unreferenced-rules',
270
+ label: 'Unreferenced rules',
271
+ checks: [{ kind: 'measure', measure: unreferencedRules }],
272
+ },
273
+ {
274
+ // Only the citation half of the audit gates. Length, depth, table, and
275
+ // index findings are judgment thresholds, and failing a push on one would
276
+ // make the stage something to route around.
277
+ id: 'context-citations',
278
+ label: 'Context citations',
279
+ checks: [
280
+ {
281
+ kind: 'cli',
282
+ argv: ['context', 'audit', '--citations-only'],
283
+ failure:
284
+ 'A cited context path does not resolve. Run bun src/cli.ts context audit.',
285
+ },
286
+ ],
287
+ success: 'Context citations resolve',
288
+ },
289
+ {
290
+ // A rule citing a file that moved fails silently. The consumed-copy drift
291
+ // stage passes an authored rule and its copy that are wrong together, and
292
+ // nothing else resolves the path until a session opens it. A rule whose
293
+ // frontmatter glob names a directory that moved fails the same way, by
294
+ // never firing again. The classes where absence is correct are separated
295
+ // inside the verb rather than left as a threshold here.
296
+ id: 'rule-citations',
297
+ label: 'Rule citations',
298
+ checks: [
299
+ {
300
+ kind: 'cli',
301
+ argv: ['gov', 'citations'],
302
+ failure:
303
+ 'A path a rule cites, or an internal frontmatter glob, does not resolve. Run bun src/cli.ts gov citations.',
304
+ },
305
+ ],
306
+ success: 'Rule citations resolve',
307
+ },
308
+ {
309
+ id: 'markdown-bans',
310
+ label: 'Markdown bans',
311
+ checks: [{ kind: 'measure', measure: markdownBans }],
312
+ },
313
+ {
314
+ id: 'seed-standards',
315
+ label: 'Seed standards',
316
+ checks: [{ kind: 'measure', measure: seedStandards }],
317
+ },
318
+ {
319
+ // Presence of a required file is a fact, so it gates. The name,
320
+ // description, folder, and requirement-section measures beside it report
321
+ // and are read from a bare run.
322
+ id: 'skill-requirements',
323
+ label: 'Skill requirements',
324
+ checks: [
325
+ {
326
+ kind: 'cli',
327
+ argv: ['claude', 'skills', 'audit', '--requirements-only'],
328
+ failure:
329
+ 'A skill folder carries no REQUIREMENT.md. Run bun src/cli.ts claude skills audit.',
330
+ },
331
+ ],
332
+ success: 'Skill requirements present',
333
+ },
334
+ {
335
+ id: 'standard-criteria',
336
+ label: 'Standard success criteria',
337
+ checks: [{ kind: 'measure', measure: standardCriteria }],
338
+ },
339
+ {
340
+ id: 'sandbox-coverage',
341
+ label: 'Sandbox coverage',
342
+ checks: [{ kind: 'measure', measure: sandboxCoverage }],
343
+ },
344
+ {
345
+ id: 'audit-set',
346
+ label: 'Audit set',
347
+ checks: [{ kind: 'measure', measure: auditSet }],
348
+ },
349
+ {
350
+ id: 'plugin-manifests',
351
+ label: 'Plugin manifests',
352
+ checks: [{ kind: 'measure', measure: pluginManifests }],
353
+ },
354
+ {
355
+ id: 'spelling',
356
+ label: 'Spelling',
357
+ checks: [
358
+ {
359
+ kind: 'command',
360
+ argv: ['bun', 'run', 'check:spell'],
361
+ failure: 'Spell check failed',
362
+ },
363
+ ],
364
+ success: 'Spell check passed',
365
+ },
366
+ {
367
+ id: 'shell',
368
+ label: 'Shell',
369
+ scope: /\.sh$|^package\.json$/,
370
+ skipped: 'Skipped, no shell changes',
371
+ checks: [
372
+ {
373
+ kind: 'command',
374
+ argv: ['bun', 'run', 'check:shell'],
375
+ failure: 'Shell check failed',
376
+ },
377
+ script(
378
+ 'check-color-source.sh',
379
+ 'A color escape is defined outside scripts/lib/ui.sh.',
380
+ ),
381
+ ],
382
+ success: 'Shell check passed',
383
+ },
384
+ {
385
+ id: 'types',
386
+ label: 'Types',
387
+ scope: /^src\/|^tsconfig\.json$|^package\.json$/,
388
+ skipped: 'Skipped, no TypeScript changes',
389
+ checks: [
390
+ {
391
+ kind: 'command',
392
+ argv: ['bun', 'run', 'check:types'],
393
+ failure: 'Typecheck failed',
394
+ },
395
+ ],
396
+ success: 'Typecheck passed',
397
+ },
398
+ {
399
+ id: 'tests',
400
+ label: 'Tests',
401
+ scope: TESTS_SCOPE,
402
+ skipped: 'Skipped, no TypeScript or asserted-corpus changes',
403
+ checks: [
404
+ {
405
+ kind: 'command',
406
+ argv: ['bun', 'run', 'test'],
407
+ failure: 'Tests failed',
408
+ },
409
+ ],
410
+ success: 'Tests passed',
411
+ },
412
+ ]
@@ -0,0 +1,145 @@
1
+ import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
2
+
3
+ /**
4
+ * Why the comparison produced no reading.
5
+ *
6
+ * The three are apart because each names a different repair. `no-section` is a
7
+ * body missing the heading, `no-claims` is the extractor failing over a section
8
+ * full of prose, and `no-changes` is a pull request with no files. Folding any
9
+ * of them into a clean pass is the failure this check exists to prevent, since
10
+ * a reader cannot tell a check that found nothing from one that read nothing.
11
+ */
12
+ export type BijectionRefusal = 'no-section' | 'no-claims' | 'no-changes'
13
+
14
+ export interface BijectionReport {
15
+ readonly kind: 'measured'
16
+ /** The commit the changed set was read at, which a finding has to name. */
17
+ readonly head: string | undefined
18
+ readonly changed: readonly string[]
19
+ readonly claims: readonly PathClaim[]
20
+ /**
21
+ * Anchored claims no changed file answers, which is the graded direction. A
22
+ * bullet naming an untouched file is wrong more often than not, and this
23
+ * corpus reported zero of them across 23 correct bodies.
24
+ */
25
+ readonly unmet: readonly PathClaim[]
26
+ /**
27
+ * Claims whose first segment names no entry in the tree, so the comparison
28
+ * could not judge them either way. Reported so a run says what it declined
29
+ * rather than counting a partial spelling as met.
30
+ */
31
+ readonly unresolved: readonly PathClaim[]
32
+ /**
33
+ * Changed files no claim reaches. Reported without a severity, since the
34
+ * class covers a real omission and equally a lockfile, a generated asset, or
35
+ * a regenerated index that legitimately earns no bullet.
36
+ */
37
+ readonly unnamed: readonly string[]
38
+ }
39
+
40
+ export type Bijection =
41
+ | BijectionReport
42
+ | { readonly kind: 'refused'; readonly reason: BijectionRefusal }
43
+
44
+ export interface BijectionInput {
45
+ readonly body: string
46
+ readonly changed: readonly string[]
47
+ /** Top-level entries the tree holds, which decides what counts as anchored. */
48
+ readonly roots: ReadonlySet<string>
49
+ readonly head?: string
50
+ readonly title?: string
51
+ }
52
+
53
+ /**
54
+ * Whether one changed path is the file, or a file under the folder, a claim
55
+ * names.
56
+ *
57
+ * An unanchored claim matches on a segment-anchored suffix, which is what lets
58
+ * `claude-worker/SKILL.md` credit `claude/skills/claude-worker/SKILL.md`. That
59
+ * asymmetry is deliberate: a partial spelling can confirm a changed file was
60
+ * named and never accuse one of being absent, because nothing here separates a
61
+ * path written short from a path written wrong.
62
+ */
63
+ function covers(claim: PathClaim, path: string): boolean {
64
+ if (claim.directory) {
65
+ return claim.anchored
66
+ ? path.startsWith(claim.path)
67
+ : path.includes(`/${claim.path}`)
68
+ }
69
+ if (path === claim.path) return true
70
+ return !claim.anchored && path.endsWith(`/${claim.path}`)
71
+ }
72
+
73
+ /**
74
+ * Compares what a pull request body claims to have changed against what it
75
+ * actually changed, in both directions.
76
+ *
77
+ * The two directions are reported apart because they want different
78
+ * tolerances. A claim nobody made good on is a defect in the record that
79
+ * squash-merges onto the trunk, and a changed file nobody recorded is often
80
+ * correct. Merging them into one count would either grade the second or excuse
81
+ * the first.
82
+ *
83
+ * Pure, so the whole judgment is testable against a fixture. The caller reads
84
+ * the body, the changed set, and the tree roots and hands all three in.
85
+ */
86
+ export function compareKeyChanges(input: BijectionInput): Bijection {
87
+ if (input.changed.length === 0)
88
+ return { kind: 'refused', reason: 'no-changes' }
89
+
90
+ const read = extractKeyChangePaths(
91
+ input.body,
92
+ input.roots,
93
+ input.title ?? KEY_CHANGES,
94
+ )
95
+ if (read.kind === 'no-section')
96
+ return { kind: 'refused', reason: 'no-section' }
97
+ if (read.claims.length === 0) return { kind: 'refused', reason: 'no-claims' }
98
+
99
+ const unmet: PathClaim[] = []
100
+ const unresolved: PathClaim[] = []
101
+ const named = new Set<string>()
102
+
103
+ for (const claim of read.claims) {
104
+ const hits = input.changed.filter((path) => covers(claim, path))
105
+ for (const path of hits) named.add(path)
106
+ if (hits.length > 0) continue
107
+ if (claim.anchored) unmet.push(claim)
108
+ else unresolved.push(claim)
109
+ }
110
+
111
+ return {
112
+ kind: 'measured',
113
+ head: input.head,
114
+ changed: [...input.changed],
115
+ claims: read.claims,
116
+ unmet,
117
+ unresolved,
118
+ unnamed: input.changed.filter((path) => !named.has(path)),
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The top-level folders a claim may be anchored on: the first segment of every
124
+ * path the tree holds, plus the first segment of every changed path.
125
+ *
126
+ * The changed half is what admits a folder this branch created. Reading the
127
+ * tree alone would mark every claim under a new top-level directory unanchored,
128
+ * and an unanchored claim never accuses, so the first branch to open one would
129
+ * silently lose the graded direction.
130
+ *
131
+ * A path with no folder above it contributes nothing, since a claim carrying no
132
+ * slash never reaches the extractor's output and no root would ever be read
133
+ * against it.
134
+ */
135
+ export function treeRoots(
136
+ tracked: readonly string[],
137
+ changed: readonly string[],
138
+ ): Set<string> {
139
+ const roots = new Set<string>()
140
+ for (const path of [...tracked, ...changed]) {
141
+ const at = path.indexOf('/')
142
+ if (at > 0) roots.add(path.slice(0, at))
143
+ }
144
+ return roots
145
+ }