@erclx/aitk 3.48.1 → 3.50.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,514 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { $ } from 'bun'
5
+ import { isMarked } from '@/exempt-marker'
6
+ import { gitEnv } from '@/git-env'
7
+ import { listRuleFiles } from '@/gov/payload'
8
+ import { parseFrontmatter } from '@/indexes/frontmatter'
9
+
10
+ /**
11
+ * The inline token exempting one line from this sweep, shaped on the
12
+ * `aitk-allow-superseded` precedent and read by the same two-line rule.
13
+ *
14
+ * Everything the classifier below can separate mechanically is separated
15
+ * there. This is for the residue: a line whose path is written as a reference
16
+ * and is correct in naming something absent, for a reason a later reader has
17
+ * to be able to weigh. A bare token names no reason, so it mutes nothing.
18
+ */
19
+ export const CITATION_MARKER = 'aitk-allow-citation'
20
+
21
+ /** The two rule corpora, authored here and read from the repository root. */
22
+ export const RULE_DIRS: readonly string[] = [
23
+ join('governance', 'rules'),
24
+ join('internal', 'rules'),
25
+ ]
26
+
27
+ /**
28
+ * The corpus whose frontmatter globs resolve against this tree.
29
+ *
30
+ * Bodies are read across both corpora and globs across this one alone. A rule
31
+ * under `governance/rules/` installs into a target and its `paths:` entries
32
+ * name that project's shape, which is why 32 of the 72 globs there match
33
+ * nothing here and every one of them is correct: `src/pages/**` in the Astro
34
+ * rule is indistinguishable by pattern from a path this repository might hold.
35
+ * Gating on them would ship a permanent exemption list the length of the
36
+ * corpus. The internal corpus ships nowhere, so the tree it governs is the
37
+ * tree present and a glob matching nothing there is a rule that stopped
38
+ * firing.
39
+ */
40
+ export const GLOB_CORPUS = join('internal', 'rules')
41
+
42
+ /**
43
+ * How the citation was written, kept on the finding because the three resolve
44
+ * against different roots and a reader repairing one needs to know which.
45
+ *
46
+ * `standard` is the live form, carried by 20 rules. `path` is a backticked
47
+ * repository path. `sibling` is a rule naming another rule by filename alone,
48
+ * which resolves inside the folder the citing rule sits in.
49
+ */
50
+ export type CitationForm = 'path' | 'standard' | 'sibling'
51
+
52
+ /**
53
+ * What the sweep decided about one citation.
54
+ *
55
+ * `governed` and `ignored` are the two classes where a path reaching nothing is
56
+ * correct rather than stale, and they are named rather than dropped so the
57
+ * report states what it declined to judge. `exempt` is the marker.
58
+ */
59
+ export type CitationStatus =
60
+ | 'resolved'
61
+ | 'governed'
62
+ | 'ignored'
63
+ | 'exempt'
64
+ | 'dead'
65
+
66
+ export interface RuleCitation {
67
+ /** The citing rule, relative to the root that was swept. */
68
+ readonly file: string
69
+ /** One-based, matching the `file:line` form a reader clicks. */
70
+ readonly line: number
71
+ readonly form: CitationForm
72
+ /** The citation exactly as the rule wrote it. */
73
+ readonly cited: string
74
+ /** Repository-relative paths tried, in order, so a finding names its net. */
75
+ readonly candidates: readonly string[]
76
+ /** Which candidate answered, absent when none did. */
77
+ readonly resolved: string | undefined
78
+ readonly status: CitationStatus
79
+ readonly preview: string
80
+ }
81
+
82
+ /**
83
+ * One `paths:` entry from a rule's frontmatter, resolved against the tree.
84
+ *
85
+ * Only the internal corpus is read. A rule shipping to a target declares the
86
+ * shape that target holds, so its globs answer about a tree that is not this
87
+ * one, which `GLOB_CORPUS` states and the report repeats on every run.
88
+ */
89
+ export interface RuleGlob {
90
+ readonly file: string
91
+ readonly line: number
92
+ readonly glob: string
93
+ readonly matched: boolean
94
+ }
95
+
96
+ export type CitationReport =
97
+ | {
98
+ readonly kind: 'measured'
99
+ /** Rule files opened, which is what the verdict covers. */
100
+ readonly rules: number
101
+ readonly citations: readonly RuleCitation[]
102
+ /** Frontmatter globs read, from `GLOB_CORPUS` alone. */
103
+ readonly globs: readonly RuleGlob[]
104
+ }
105
+ | { readonly kind: 'unreadable'; readonly reason: string }
106
+
107
+ /**
108
+ * The longest preview a finding carries, matching the superseded sweep beside
109
+ * it. A rule bullet runs long and the report prints one line per citation.
110
+ */
111
+ const PREVIEW_LIMIT = 200
112
+
113
+ const FENCE = /^\s*(?:```|~~~)/
114
+
115
+ const FRONTMATTER_DELIMITER = /^---\s*$/
116
+
117
+ /**
118
+ * A backticked span, which is the only carrier a rule writes a citation in. No
119
+ * rule in either corpus uses a markdown link, and matching running prose would
120
+ * report every sentence that happens to name a file.
121
+ */
122
+ const BACKTICKED = /`([^`\n]+)`/g
123
+
124
+ /**
125
+ * The verb form, with the name captured. A leading letter or digit is required,
126
+ * which is what leaves `aitk standards <name>` unmatched: that line teaches the
127
+ * form rather than citing a standard, and it is the only one in either corpus.
128
+ */
129
+ const STANDARD_CALL = /aitk standards ([A-Za-z0-9][A-Za-z0-9._-]*)/g
130
+
131
+ /** A rule filename, which is how a rule names a sibling with no folder around it. */
132
+ const SIBLING_RULE = /^\d{3}-[a-z0-9-]+\.md$/
133
+
134
+ /**
135
+ * A character that puts the span outside this repository, or outside paths
136
+ * altogether.
137
+ *
138
+ * `<` and `$` are the placeholder forms, `*` is a glob, and both describe a
139
+ * shape rather than name a file. The rest are anchors nothing here resolves
140
+ * against: an absolute path, a home path, a module alias, a URL scheme.
141
+ */
142
+ function isNotRepositoryPath(span: string): boolean {
143
+ if (/[\s<>$*|]/.test(span)) return true
144
+ if (span.includes('://')) return true
145
+ return /^[/~@#!]/.test(span)
146
+ }
147
+
148
+ /**
149
+ * Whether the span's last segment carries a file extension.
150
+ *
151
+ * This is what separates a citation from the folder and module conventions the
152
+ * corpus is full of. `next/font`, `try/except`, `react-hooks/set-state-in-effect`,
153
+ * and `oven-sh/setup-bun@v2` all carry a slash and name no file, and a trailing
154
+ * slash is a folder rather than a document. The cost is that `claude/standards`
155
+ * is a real path this declines to check, which is the bound the report states.
156
+ */
157
+ function namesAFile(span: string): boolean {
158
+ if (span.endsWith('/')) return false
159
+ const segment = span.slice(span.lastIndexOf('/') + 1)
160
+ return /\.[A-Za-z0-9]+$/.test(segment)
161
+ }
162
+
163
+ /**
164
+ * Which form the span is written in, or nothing when it names no file this
165
+ * sweep can resolve.
166
+ *
167
+ * The whole span is classified rather than a trailing pattern inside it.
168
+ * Extracting `standards/tooling-reference.md` out of
169
+ * `internal/standards/tooling-reference.md` and resolving that against the
170
+ * standards root manufactures a dead citation out of a file that exists, which
171
+ * a session measuring this corpus did before the check was written.
172
+ */
173
+ export function classifySpan(span: string): CitationForm | undefined {
174
+ if (isNotRepositoryPath(span)) return undefined
175
+ if (!namesAFile(span)) return undefined
176
+ if (span.includes('/')) return 'path'
177
+ return SIBLING_RULE.test(span) ? 'sibling' : undefined
178
+ }
179
+
180
+ /**
181
+ * Where a citation could answer, which is wherever the reader's own tools look
182
+ * and nowhere else.
183
+ *
184
+ * A standard name takes the authoring root alone, matching `standardRoots` in
185
+ * `@/standards/read`, which reads `standards/` at the working root and then the
186
+ * package corpus. This verb refuses a tree holding no rule corpus, so it runs
187
+ * only where those two roots are one directory. `internal/standards/` is
188
+ * deliberately absent: `aitk standards <name>` never reaches it, so admitting it
189
+ * here would pass a citation that refuses for the session opening it, which is a
190
+ * gate failing open.
191
+ *
192
+ * A sibling resolves inside the folder the citing rule sits in, since that is
193
+ * the only place a bare rule filename means anything.
194
+ */
195
+ function candidatesFor(
196
+ form: CitationForm,
197
+ cited: string,
198
+ ruleFile: string,
199
+ ): string[] {
200
+ if (form === 'standard') return [join('standards', `${cited}.md`)]
201
+ if (form === 'sibling') return [join(dirname(ruleFile), cited)]
202
+ return [cited]
203
+ }
204
+
205
+ interface RawCitation {
206
+ readonly line: number
207
+ readonly form: CitationForm
208
+ readonly cited: string
209
+ readonly preview: string
210
+ readonly marked: boolean
211
+ }
212
+
213
+ /**
214
+ * Every citation one rule body carries.
215
+ *
216
+ * The frontmatter block is skipped, since a `paths:` glob declares what the
217
+ * rule governs rather than what it points a reader at, and the two questions
218
+ * resolve against different trees. Fenced blocks are skipped for the reason the
219
+ * marker exists: a fenced example displays a path rather than citing one. No
220
+ * rule in either corpus opens a fence today, so this is a floor rather than a
221
+ * filter over anything present.
222
+ */
223
+ export function collectCitations(text: string): RawCitation[] {
224
+ const lines = text.split('\n')
225
+ const found: RawCitation[] = []
226
+ let fenced = false
227
+ let inFrontmatter = FRONTMATTER_DELIMITER.test(lines[0] ?? '')
228
+
229
+ for (const [index, line] of lines.entries()) {
230
+ if (inFrontmatter) {
231
+ if (index > 0 && FRONTMATTER_DELIMITER.test(line)) inFrontmatter = false
232
+ continue
233
+ }
234
+ if (FENCE.test(line)) {
235
+ fenced = !fenced
236
+ continue
237
+ }
238
+ if (fenced) continue
239
+
240
+ const trimmed = line.trim()
241
+ const preview =
242
+ trimmed.length > PREVIEW_LIMIT
243
+ ? `${trimmed.slice(0, PREVIEW_LIMIT)}…`
244
+ : trimmed
245
+ const marked = isMarked(lines, index, CITATION_MARKER)
246
+
247
+ for (const match of line.matchAll(STANDARD_CALL)) {
248
+ found.push({
249
+ line: index + 1,
250
+ form: 'standard',
251
+ cited: match[1] ?? '',
252
+ preview,
253
+ marked,
254
+ })
255
+ }
256
+
257
+ for (const match of line.matchAll(BACKTICKED)) {
258
+ const span = match[1] ?? ''
259
+ const form = classifySpan(span)
260
+ if (form === undefined) continue
261
+ found.push({ line: index + 1, form, cited: span, preview, marked })
262
+ }
263
+ }
264
+
265
+ return found
266
+ }
267
+
268
+ /**
269
+ * The paths one rule declares in its own frontmatter.
270
+ *
271
+ * A rule spelling a whole path there is declaring that exact artifact, and a
272
+ * body line naming it again is naming what the rule governs rather than
273
+ * pointing a reader somewhere. `governance/rules/claude/560-diagrams.md` tells
274
+ * its reader to convert a `.claude/DIAGRAMS.md` left by an older install, which
275
+ * is correctly absent from this tree and correctly named in the rule.
276
+ */
277
+ function governedPaths(text: string): string[] {
278
+ const parsed = parseFrontmatter(text)
279
+ const paths = parsed?.fields.paths
280
+ if (!Array.isArray(paths)) return []
281
+ return paths.filter((entry): entry is string => typeof entry === 'string')
282
+ }
283
+
284
+ /**
285
+ * Exact declarations only, never a glob match against one.
286
+ *
287
+ * A glob declares a shape rather than an artifact, so a body path sitting
288
+ * inside one is still a citation and a stale one is still a defect. Matching
289
+ * the glob would exempt a rule scoped at `docs/**` citing a
290
+ * `docs/agents/renamed.md` that moved, which is the class this check exists
291
+ * to catch.
292
+ */
293
+ function isGoverned(cited: string, declared: readonly string[]): boolean {
294
+ return declared.includes(cited)
295
+ }
296
+
297
+ /**
298
+ * A `paths:` list entry, with the glob captured and its quoting dropped.
299
+ *
300
+ * Anchored on the entry shape rather than searched for as a substring. A bare
301
+ * scan for the glob text finds it in the `description:` line first wherever a
302
+ * rule names what it governs in prose, which is how `596-claude-md.md` reported
303
+ * its `CLAUDE.md` glob against line 2 instead of line 4.
304
+ */
305
+ const LIST_ENTRY = /^\s*-\s*(?:'([^']*)'|"([^"]*)"|(\S.*?))\s*$/
306
+
307
+ /**
308
+ * Where each declared glob sits, so a finding names a line a reader can click.
309
+ *
310
+ * The values come from the YAML parse and the line numbers from a scan of the
311
+ * frontmatter block alone, rather than from a second parse of the list syntax.
312
+ * A quoted entry, a bare one, and a flow sequence all reach the parse
313
+ * identically, and only the first two are what this corpus writes, so a flow
314
+ * sequence resolves its value and reports no line.
315
+ */
316
+ function locateGlobs(text: string): { line: number; glob: string }[] {
317
+ const declared = governedPaths(text)
318
+ if (declared.length === 0) return []
319
+
320
+ const lines = text.split('\n')
321
+ const close = lines.findIndex(
322
+ (line, index) => index > 0 && FRONTMATTER_DELIMITER.test(line),
323
+ )
324
+ const block = close === -1 ? lines : lines.slice(0, close)
325
+ const taken = new Set<number>()
326
+
327
+ return declared.map((glob) => {
328
+ const at = block.findIndex((line, index) => {
329
+ if (taken.has(index)) return false
330
+ const entry = line.match(LIST_ENTRY)
331
+ return entry !== null && (entry[1] ?? entry[2] ?? entry[3]) === glob
332
+ })
333
+ if (at !== -1) taken.add(at)
334
+ return { line: at + 1, glob }
335
+ })
336
+ }
337
+
338
+ /**
339
+ * Whether the glob matches a file present in this tree.
340
+ *
341
+ * Read only for `internal/rules/`. A shipped rule's glob names the shape a
342
+ * target holds, so `src/pages/**` in the Astro rule is indistinguishable by
343
+ * pattern from a path here and resolving it would report 32 of 72 correct
344
+ * globs as defects. The internal corpus ships nowhere, which makes the tree it
345
+ * governs the tree present and the question answerable.
346
+ */
347
+ function globMatches(root: string, glob: string): boolean {
348
+ const scan = new Bun.Glob(glob).scanSync({
349
+ cwd: root,
350
+ onlyFiles: true,
351
+ dot: true,
352
+ })
353
+ for (const _ of scan) return true
354
+ return false
355
+ }
356
+
357
+ /**
358
+ * Which of `paths` git ignores, or nothing when git could not answer.
359
+ *
360
+ * Session scratch is the class this reaches. `.claude/tasks/index.md` is real
361
+ * in a live project, absent from a fresh clone and from every linked worktree,
362
+ * and a rule naming it is right either way. Resolving against the filesystem
363
+ * alone would make the verdict depend on which tree the check ran in.
364
+ *
365
+ * `git check-ignore` exits 1 when nothing matches, which is a clean answer
366
+ * rather than a failure, so only a higher code is read as one.
367
+ */
368
+ async function readIgnored(
369
+ root: string,
370
+ paths: readonly string[],
371
+ ): Promise<Set<string> | undefined> {
372
+ if (paths.length === 0) return new Set()
373
+
374
+ const input = Buffer.from(`${paths.join('\n')}\n`)
375
+ const result = await $`git -C ${root} check-ignore --stdin < ${input}`
376
+ .env(gitEnv())
377
+ .quiet()
378
+ .nothrow()
379
+
380
+ if (result.exitCode > 1) return undefined
381
+ return new Set(result.text().split('\n').filter(Boolean))
382
+ }
383
+
384
+ /**
385
+ * Resolves every path the two rule corpora cite and names the ones reaching
386
+ * nothing, plus every frontmatter glob under `GLOB_CORPUS`.
387
+ *
388
+ * Two questions rather than one, because they fail the same way. A citation
389
+ * broken by a move sends a reader to an absence, and a glob broken by a move
390
+ * stops the rule firing at all, and neither says anything when it happens. One
391
+ * stage reads both rather than two reading one file each.
392
+ *
393
+ * This gates rather than reports, unlike the superseded sweep it sits beside. A
394
+ * path resolving to nothing carries no judgment: either the file is there or the
395
+ * citation is stale, and the classes where absence is correct are separated
396
+ * before the verdict rather than left for a reader to settle.
397
+ *
398
+ * What it cannot see is a citation that resolves and points somewhere wrong,
399
+ * a path written without backticks, a folder or module specifier carrying no
400
+ * extension, which `namesAFile` declines rather than guessing at, and a glob
401
+ * that matches real files while reaching none of the work it was scoped at.
402
+ */
403
+ export async function readCitations(root: string): Promise<CitationReport> {
404
+ const dirs = RULE_DIRS.map((rel) => ({
405
+ rel,
406
+ abs: resolve(root, rel),
407
+ })).filter((dir) => existsSync(dir.abs))
408
+
409
+ if (dirs.length === 0) {
410
+ return {
411
+ kind: 'unreadable',
412
+ reason: `No rule corpus under ${root}. A tree holding neither ${RULE_DIRS.join(' nor ')} passes each of its zero rules, so it refuses rather than reporting clean.`,
413
+ }
414
+ }
415
+
416
+ const citations: RuleCitation[] = []
417
+ const globs: RuleGlob[] = []
418
+ let rules = 0
419
+
420
+ for (const dir of dirs) {
421
+ for (const abs of listRuleFiles(dir.abs)) {
422
+ const file = join(dir.rel, abs.slice(dir.abs.length + 1))
423
+
424
+ let text: string
425
+ try {
426
+ text = await readFile(abs, 'utf8')
427
+ } catch {
428
+ // A rule git listed and the filesystem will not open is a file removed
429
+ // since the glob answered. Skipping it under-reports rather than
430
+ // failing a push on a race.
431
+ continue
432
+ }
433
+
434
+ rules += 1
435
+ const declared = governedPaths(text)
436
+
437
+ if (dir.rel === GLOB_CORPUS) {
438
+ for (const { line, glob } of locateGlobs(text)) {
439
+ globs.push({ file, line, glob, matched: globMatches(root, glob) })
440
+ }
441
+ }
442
+
443
+ for (const raw of collectCitations(text)) {
444
+ const candidates = candidatesFor(raw.form, raw.cited, file)
445
+ const resolved = candidates.find((path) =>
446
+ existsSync(resolve(root, path)),
447
+ )
448
+
449
+ citations.push({
450
+ file,
451
+ line: raw.line,
452
+ form: raw.form,
453
+ cited: raw.cited,
454
+ candidates,
455
+ resolved,
456
+ status: classifyStatus(raw, resolved, declared),
457
+ preview: raw.preview,
458
+ })
459
+ }
460
+ }
461
+ }
462
+
463
+ return applyIgnored(root, { kind: 'measured', rules, citations, globs })
464
+ }
465
+
466
+ function classifyStatus(
467
+ raw: RawCitation,
468
+ resolved: string | undefined,
469
+ declared: readonly string[],
470
+ ): CitationStatus {
471
+ if (resolved !== undefined) return 'resolved'
472
+ if (raw.marked) return 'exempt'
473
+ if (raw.form === 'path' && isGoverned(raw.cited, declared)) return 'governed'
474
+ return 'dead'
475
+ }
476
+
477
+ /**
478
+ * Moves the unresolved paths git ignores out of the dead set.
479
+ *
480
+ * Batched into one call over the whole sweep rather than one per citation, and
481
+ * skipped outright when nothing is unresolved, so a clean corpus spawns no git
482
+ * at all. A read git cannot answer refuses, since treating it as "nothing is
483
+ * ignored" would fail a push over session scratch that was never in the tree.
484
+ */
485
+ async function applyIgnored(
486
+ root: string,
487
+ measured: Extract<CitationReport, { kind: 'measured' }>,
488
+ ): Promise<CitationReport> {
489
+ const pending = measured.citations.filter(
490
+ (citation) => citation.status === 'dead' && citation.form === 'path',
491
+ )
492
+ if (pending.length === 0) return measured
493
+
494
+ const ignored = await readIgnored(
495
+ root,
496
+ pending.map((citation) => citation.cited),
497
+ )
498
+
499
+ if (ignored === undefined) {
500
+ return {
501
+ kind: 'unreadable',
502
+ reason: `Git could not say which of ${pending.length} unresolved paths it ignores under ${root}. Session scratch is absent from a fresh clone and correctly cited anyway, so an unreadable answer refuses rather than reporting those paths dead.`,
503
+ }
504
+ }
505
+
506
+ return {
507
+ ...measured,
508
+ citations: measured.citations.map((citation) =>
509
+ citation.status === 'dead' && ignored.has(citation.cited)
510
+ ? { ...citation, status: 'ignored' as const }
511
+ : citation,
512
+ ),
513
+ }
514
+ }