@erclx/aitk 3.48.1 → 3.49.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.48.1",
4
+ "version": "3.49.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -55,6 +55,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
55
55
  | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
56
56
  | `aitk gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value and on the family stem behind a templated citation (`--json`) |
57
57
  | `aitk gov restated` | Report every instruction the always-loaded file or a rule shares with the seed, a shipped skill body, or another rule, classed and with its anchors named (`--json`) |
58
+ | `aitk gov citations` | Resolve every path a rule cites and every internal frontmatter glob, failing on one reaching nothing (`--json`) |
58
59
  | `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
59
60
  | `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
60
61
  | `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
@@ -96,7 +97,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
96
97
  | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
97
98
  | `snippets` | `list`, `create` |
98
99
  | `standards` | `list`, `audit`, `<name>` |
99
- | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded` |
100
+ | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded`, `citations` |
100
101
  | `claude` | `init`, `sync`, `routing`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `skills rank`, `setup [dest]` |
101
102
  | `demo` | `compile`, `run` |
102
103
  | `inventory` | `run` |
@@ -28,6 +28,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
28
28
  - [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
29
29
  - [Restated instructions](restated.md): Counting the instructions the always-loaded file and every path-scoped rule share with the seed, the shipped skill bodies, and each other, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
30
30
  - [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
31
+ - [Rule citations](rule-citations.md): Resolving every path a governance rule cites and every frontmatter glob the internal corpus declares, the three forms a citation is written in, the shapes that look like citations and are not, the two classes where an absent path is correct, why the glob half reads one corpus, and why this one gates
31
32
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
32
33
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
33
34
  - [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, and what each session surface can see
@@ -0,0 +1,98 @@
1
+ ---
2
+ title: Rule citations
3
+ description: Resolving every path a governance rule cites and every frontmatter glob the internal corpus declares, the three forms a citation is written in, the shapes that look like citations and are not, the two classes where an absent path is correct, why the glob half reads one corpus, and why this one gates
4
+ ---
5
+
6
+ # Rule citations
7
+
8
+ `aitk gov citations` resolves every path a rule cites and every frontmatter glob the internal corpus declares, naming the ones reaching nothing. It answers a failure no other stage sees: a rule points a reader at a file, the file moves, and nothing reports it until a session opens the path and finds an absence.
9
+
10
+ A glob fails the same way and more quietly. A rule scoped at a directory that moved stops matching, so it never loads again, and a rule that never fires looks exactly like a rule nobody violated.
11
+
12
+ ```bash
13
+ aitk gov citations
14
+ aitk gov citations --json
15
+ aitk gov citations --root ../my-app
16
+ ```
17
+
18
+ | Option | Behavior |
19
+ | --------------- | ---------------------------------------------------------- |
20
+ | `--root <path>` | Tree to read, defaulting to the current directory |
21
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
22
+
23
+ Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode.
24
+
25
+ ## What the drift check cannot see
26
+
27
+ `bun run check` asserts that an authored rule and its consumed copy agree, and a dead citation passes that assertion because both copies carry it. `governance/rules/claude/561-teach.md` told its reader to open a `references/glossary.md` inside the teach skill that had never existed at that path. The rule shipped, survived a release, and fired during a real teach run before anyone noticed.
28
+
29
+ Two files agreeing is what a drift check measures. Whether either one is right about the tree is a different question, and this is the stage that asks it.
30
+
31
+ ## The three forms
32
+
33
+ A citation reaches a reader in one of three shapes, and each resolves against a different root.
34
+
35
+ | Form | Shape | Resolves against |
36
+ | ---------- | ------------------------ | ---------------------------------- |
37
+ | `standard` | `aitk standards <name>` | `standards/<name>.md` |
38
+ | `path` | a backticked path | the root |
39
+ | `sibling` | a bare `<nnn>-<slug>.md` | the folder the citing rule sits in |
40
+
41
+ `standard` is the live form and carries most of the corpus, which is what makes scoping this check to the two path shapes a check over almost nothing.
42
+
43
+ A standard name resolves against the authoring root and nowhere else, matching `standardRoots` in `src/standards/read.ts`, which reads `standards/` at the working root and then the package corpus. The stage refuses a tree holding no rule corpus, so it runs only where those two roots are one directory. `internal/standards/` is deliberately not tried: `aitk standards <name>` never reaches it, so admitting it would pass a citation that refuses for the session opening it, which is a gate failing open.
44
+
45
+ A `path` is anchored on the whole backticked span rather than on a trailing pattern inside it. Cutting `standards/tooling-reference.md` out of `internal/standards/tooling-reference.md` and resolving that against the standards root reports a file that exists as missing, which is a mistake made by hand while measuring this corpus before the stage was written.
46
+
47
+ ## What is not a citation
48
+
49
+ A rule body is full of backticked spans carrying a slash, and almost none of them names a file in this tree. The stage declines four shapes outright.
50
+
51
+ - A placeholder or glob segment describes a shape rather than naming a file: `.claude/context/<domain>.md`, `standards/<name>.md`, `app/**/route.ts`, `${CLAUDE_SKILL_DIR}/../../standards/<name>.md`.
52
+ - A bare filename names a convention: `route.ts`, `manifest.toml`, `components.json`, `playwright.config.ts`. A bare name is read as a citation only when it matches a rule filename, which is how `562-session.md` points at `555-tasks.md`.
53
+ - A span carrying no file extension is a folder or a module specifier: `src/pages/`, `next/font`, `try/except`, `react-hooks/set-state-in-effect`, `oven-sh/setup-bun@v2`, `@/lib/utils`.
54
+ - A fenced block displays a path rather than pointing at one.
55
+
56
+ The extension test is what carries most of the separation, and it costs one real path: `claude/standards` is a symlink this stage declines to check because nothing in the span says it is a file. Declining is the right direction for a gate, where a guess that goes wrong fails a push over prose.
57
+
58
+ ## Where an absent path is correct
59
+
60
+ Two classes resolve to nothing and are right to. Both are reported by name rather than dropped, so a reader can see what the verdict declined to judge.
61
+
62
+ **Governed.** A rule spelling a path in its own frontmatter `paths:` is naming an artifact a target holds rather than a file here. `governance/rules/claude/560-diagrams.md` declares `.claude/DIAGRAMS.md` and then tells its reader to convert one an older install left behind, so the file is correctly absent from this tree and correctly named in the rule.
63
+
64
+ Only an exact declaration exempts, never a glob match against one. A glob declares a shape, so a body path sitting inside it is still a citation and a stale one is still a defect. A rule scoped at `docs/**` citing a `docs/agents/renamed.md` that moved is exactly the class this stage exists to catch, and matching the glob would excuse it.
65
+
66
+ **Ignored.** A path git ignores is session scratch no clone is expected to hold. `governance/rules/claude/555-tasks.md` cites `.claude/tasks/index.md`, which is real in a live project and absent from a fresh clone and from every linked worktree. Resolving against the filesystem alone would make the verdict depend on which tree the stage ran in, so the unresolved paths go to one batched `git check-ignore` and an ignored one is excused. A read git cannot answer refuses rather than reporting those paths dead.
67
+
68
+ ## Why the glob half reads one corpus
69
+
70
+ Bodies are read across both corpora and frontmatter globs across `internal/rules/` alone.
71
+
72
+ A rule under `governance/rules/` installs into a target, and its `paths:` entries name the shape that project holds rather than anything here. Measured over that corpus, 32 of its 72 globs match nothing in this tree and every one of them is correct. `src/pages/**` in the Astro rule cannot be told by pattern from a path this repository might hold, so a check cannot separate the two, and gating them would ship an exemption list the length of the corpus. Nothing reads the shipped globs, and the measurement is the reason rather than a gap left for someone to close.
73
+
74
+ `internal/rules/` ships nowhere. The tree it governs is the tree present, which makes the question answerable, and all 14 of its globs across 7 rules match at the commit this shipped on.
75
+
76
+ What that leaves unreached is a glob that matches real files and still reaches none of the work it was scoped at. `governance/rules/lib/305-e2e-reliability.md` scopes itself at `e2e/*.ts` and `e2e/**/*.ts`, and no probe in this repository is written under `e2e/`, so the rule asking a session to watch a new guard fail never fired for the session writing guards. Resolution is mechanical and reach is a judgment about where the work happens, so only the first is here.
77
+
78
+ ## The exemption marker
79
+
80
+ Everything the classifier can separate mechanically is separated there. For the residue, a line carries `aitk-allow-citation: <reason>` on itself or the one directly above, which moves it into the report's `Exempt` section. Only a marker naming a reason counts, since a bare token is a line that meant to say something and did not. This is the `aitk-allow-superseded` shape, and both read the same placement rule through one helper.
81
+
82
+ ## The blind spots
83
+
84
+ A citation that resolves and points at the wrong file passes. Resolution is mechanical and correctness is a reading, and only the first is a gate's business.
85
+
86
+ A path written into running prose without backticks is not read at all. Matching one would report every sentence that happens to name a file, and the backticks are what separate a reference a reader follows from a name in a sentence.
87
+
88
+ A folder or a path carrying no extension is declined rather than guessed at, which is stated above as the cost of the test that carries the separation.
89
+
90
+ A glob under `governance/rules/` is not read, and a glob that resolves while reaching none of the work it was scoped at is a reading rather than a resolution. Both are stated above.
91
+
92
+ ## Exit codes
93
+
94
+ Exit codes are `0` when every citation resolves or is excused and every glob read matches, `1` for a refusal, and `2` for at least one cited path reaching nothing or one glob matching nothing. It refuses a tree holding neither rule corpus, since a tree with no rules passes each of its zero rules, and a `git check-ignore` read that fails.
95
+
96
+ This gates, and `bun run check` runs it as the `Rule citations` stage. The sibling sweeps report rather than gate because a value appears for reasons unrelated to the convention, so their output is a reading. A path resolving to nothing carries no judgment: either the file is there or the citation is stale, and the two classes where absence is correct are separated before the verdict rather than left for a reader to settle. The corpus is clean at the commit this shipped on, so the gate starts green and stays that way until something breaks.
97
+
98
+ An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the JSON record's `citations` array and filter on `status` rather than the exit when a skill consumes this.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.48.1",
4
+ "version": "3.49.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -387,6 +387,22 @@ main() {
387
387
  run_check "cd $PROJECT_ROOT && bun src/cli.ts context audit --citations-only" "A cited context path does not resolve. Run bun src/cli.ts context audit."
388
388
  log_info "Context citations resolve"
389
389
 
390
+ # A rule citing a file that moved fails silently. The consumed-copy drift
391
+ # stage passes an authored rule and its copy that are wrong together, and
392
+ # nothing else resolves the path until a session opens it, which is how
393
+ # `561-teach.md` shipped a `references/glossary.md` that had never existed. A
394
+ # rule whose frontmatter glob names a directory that moved fails the same way,
395
+ # by never firing again.
396
+ #
397
+ # This gates for the reason the stage above gates: a path resolving to
398
+ # nothing carries no judgment. The classes where absence is correct, a path
399
+ # the rule declares in its own frontmatter and one git ignores, are separated
400
+ # inside the verb rather than left as a threshold here. Globs are read under
401
+ # `internal/rules/` alone, since a shipped rule's glob names a target's shape.
402
+ log_step "Rule citations"
403
+ run_check "cd $PROJECT_ROOT && bun src/cli.ts gov citations" "A path a rule cites, or an internal frontmatter glob, does not resolve. Run bun src/cli.ts gov citations."
404
+ log_info "Rule citations resolve"
405
+
390
406
  # A banned character, word, or spelling is a fact rather than a threshold, so
391
407
  # it fails the push while bullet, paragraph, and depth weight stay advisory
392
408
  # for the reason the stage above leaves its own thresholds so.
@@ -30,6 +30,14 @@ import {
30
30
  SEED_REL,
31
31
  SHIPPED_SKILLS_REL,
32
32
  } from '@/gov/restated'
33
+ import {
34
+ CITATION_MARKER,
35
+ type CitationReport,
36
+ GLOB_CORPUS,
37
+ readCitations,
38
+ RULE_DIRS,
39
+ type RuleCitation,
40
+ } from '@/gov/citations'
33
41
  import {
34
42
  readSuperseded,
35
43
  SUPERSEDED_MARKER,
@@ -85,6 +93,11 @@ interface SupersededOptions {
85
93
  readonly json?: boolean
86
94
  }
87
95
 
96
+ interface CitationsOptions {
97
+ readonly root?: string
98
+ readonly json?: boolean
99
+ }
100
+
88
101
  interface RestatedOptions {
89
102
  readonly root?: string
90
103
  readonly json?: boolean
@@ -348,6 +361,86 @@ export function register(program: Command): void {
348
361
  },
349
362
  )
350
363
 
364
+ gov
365
+ .command('citations')
366
+ .description(
367
+ 'Resolve every path a rule cites and every internal frontmatter glob, naming the ones reaching nothing',
368
+ )
369
+ .helpOption('-h, --help', 'Show this help message')
370
+ .option('--root <path>', 'Tree to read, defaulting to the cwd')
371
+ .option('--json', 'Add a machine-readable record on stdout')
372
+ .addHelpText(
373
+ 'after',
374
+ [
375
+ '',
376
+ `Reads bodies across ${RULE_DIRS.join(' and ')}, and frontmatter globs`,
377
+ `under ${GLOB_CORPUS} alone. A rule citing a file that moved fails`,
378
+ 'silently: nothing resolves the path until a session opens it, and the',
379
+ 'drift check beside this one passes an authored rule and its consumed',
380
+ 'copy that are wrong together. A rule whose glob names a directory that',
381
+ 'moved fails the same way, by never firing again.',
382
+ '',
383
+ 'This gates, unlike the superseded sweep beside it. A path resolving to',
384
+ 'nothing carries no judgment, and the classes where absence is correct',
385
+ 'are separated before the verdict rather than left for a reader.',
386
+ '',
387
+ `Frontmatter globs read under ${GLOB_CORPUS} and nowhere else. A rule`,
388
+ `under ${RULE_DIRS[0]} installs into a target and its paths: entries`,
389
+ "name that project's shape, so src/pages/** in the Astro rule cannot be",
390
+ 'told by pattern from a path here. Measured over that corpus, 32 of 72',
391
+ 'globs match nothing in this tree and every one of them is correct, so',
392
+ 'gating them would ship an exemption list the length of the corpus. The',
393
+ 'internal corpus ships nowhere, which makes the tree it governs the tree',
394
+ 'present and the question answerable.',
395
+ '',
396
+ 'Forms read, with where each resolves:',
397
+ ' standard aitk standards <name> , against standards/ alone, which',
398
+ ' is where the verb itself looks. internal/standards/ is not',
399
+ ' tried, since a name resolving only there refuses for the',
400
+ ' session that opens the citation.',
401
+ ' path a backticked path carrying a directory segment and a file',
402
+ ' extension, against the root',
403
+ " sibling a bare <nnn>-<slug>.md , against the citing rule's folder",
404
+ '',
405
+ 'Not read, each a shape the corpus writes and none of them a citation:',
406
+ ' a placeholder or glob segment, which describes a shape rather than',
407
+ ' naming a file, such as .claude/context/<domain>.md or app/**/route.ts',
408
+ ' a bare filename naming a convention, such as route.ts or manifest.toml',
409
+ ' a span carrying no file extension, which is a folder or a module',
410
+ ' specifier, such as src/pages/ , next/font , or claude/standards',
411
+ ' a fenced block, which displays a path rather than pointing at one',
412
+ '',
413
+ 'Exempt, reported by name rather than dropped:',
414
+ ' governed the citing rule spells the path in its own frontmatter, so',
415
+ ' it names a target artifact rather than a file here. A glob',
416
+ ' never exempts, since a typo inside one is the defect.',
417
+ ' ignored git ignores the path, which is session scratch a clone is',
418
+ ' not expected to hold',
419
+ ` exempt the line carries a \`${CITATION_MARKER}: <reason>\` marker`,
420
+ ' on itself or the one above',
421
+ '',
422
+ 'Blind spots: a citation that resolves and points at the wrong file, a',
423
+ 'path written without backticks, a folder or a path carrying no extension,',
424
+ 'which this declines rather than guesses at, and a glob that matches real',
425
+ 'files while reaching none of the work it was scoped at, which is a',
426
+ 'reading rather than a resolution.',
427
+ '',
428
+ 'Exit codes:',
429
+ ' 0 every citation resolves or is exempt, and every glob read matches',
430
+ ' 1 refused, with the reason on stderr or in the JSON record',
431
+ ' 2 at least one cited path resolves to nothing, or one glob matches',
432
+ ' nothing in this tree',
433
+ '',
434
+ 'Examples:',
435
+ ' aitk gov citations',
436
+ ' aitk gov citations --json',
437
+ '',
438
+ ].join('\n'),
439
+ )
440
+ .action(async (opts: CitationsOptions) => {
441
+ process.exitCode = await runCitations(opts)
442
+ })
443
+
351
444
  gov
352
445
  .command('restated')
353
446
  .description(
@@ -529,6 +622,160 @@ async function runSuperseded(
529
622
  return report.findings.length > 0 ? 2 : 0
530
623
  }
531
624
 
625
+ /**
626
+ * Gates, unlike `superseded` and `test-order` above. The classes where a path
627
+ * reaching nothing is correct are separated inside the sweep, which is what
628
+ * leaves the remainder a defect with no judgment in it, so a finding fails the
629
+ * push rather than asking a reader to weigh it.
630
+ */
631
+ async function runCitations(opts: CitationsOptions): Promise<number> {
632
+ const root = resolve(opts.root ?? process.cwd())
633
+ const report = await readCitations(root)
634
+ const emitJson = opts.json ?? false
635
+
636
+ if (report.kind === 'unreadable') {
637
+ intro('aitk gov citations')
638
+ logStep('Refused')
639
+ logError(report.reason)
640
+ outro()
641
+
642
+ if (emitJson) {
643
+ process.stdout.write(
644
+ `${JSON.stringify({ ok: false, reason: report.reason })}\n`,
645
+ )
646
+ }
647
+
648
+ return 1
649
+ }
650
+
651
+ const dead = report.citations.filter((citation) => citation.status === 'dead')
652
+ const unmatched = report.globs.filter((glob) => !glob.matched)
653
+ reportCitations(report, root)
654
+
655
+ if (emitJson) {
656
+ process.stdout.write(`${JSON.stringify({ ok: true, root, ...report })}\n`)
657
+ }
658
+
659
+ return dead.length + unmatched.length > 0 ? 2 : 0
660
+ }
661
+
662
+ function describeCitation(citation: RuleCitation): string {
663
+ const tried = citation.candidates.join(', ')
664
+ return `[${citation.form}] ${citation.file}:${citation.line}: ${citation.cited} reaches nothing at ${tried}`
665
+ }
666
+
667
+ /**
668
+ * Named rather than counted, matching the exempt section of the sweep beside
669
+ * this one. An exemption is a judgment the tree recorded, and a reader weighing
670
+ * this report has to be able to reach the line that carries it.
671
+ */
672
+ function reportExcused(
673
+ citations: readonly RuleCitation[],
674
+ status: RuleCitation['status'],
675
+ label: string,
676
+ empty: string,
677
+ ): void {
678
+ const excused = citations.filter((citation) => citation.status === status)
679
+
680
+ logStep(label)
681
+ if (excused.length === 0) {
682
+ logInfo(empty)
683
+ return
684
+ }
685
+
686
+ for (const citation of excused) {
687
+ logInfo(`${citation.file}:${citation.line}: ${citation.cited}`)
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Names the corpus on every run, clean or not.
693
+ *
694
+ * A section reporting that every glob resolves, over a corpus the reader
695
+ * assumes is both of them, says the shipped rules were checked and they were
696
+ * not. The scope is the finding here as much as the count is.
697
+ */
698
+ function reportGlobs(
699
+ report: Extract<CitationReport, { kind: 'measured' }>,
700
+ ): void {
701
+ const unmatched = report.globs.filter((glob) => !glob.matched)
702
+
703
+ logStep('Frontmatter globs')
704
+ logInfo(`${report.globs.length} globs read, under ${GLOB_CORPUS} alone`)
705
+
706
+ if (unmatched.length === 0) {
707
+ logInfo('every glob matches a file in this tree')
708
+ } else {
709
+ for (const glob of unmatched) {
710
+ logError(
711
+ `${glob.file}:${glob.line}: ${glob.glob} matches nothing, so the rule never fires`,
712
+ )
713
+ }
714
+ }
715
+
716
+ logInfo(
717
+ `not read: a glob under ${RULE_DIRS[0]}, which names the shape a target holds rather than this tree, and a glob matching real files while reaching none of the work it was scoped at`,
718
+ )
719
+ }
720
+
721
+ function reportCitations(
722
+ report: Extract<CitationReport, { kind: 'measured' }>,
723
+ root: string,
724
+ ): void {
725
+ intro('aitk gov citations')
726
+
727
+ logStep('Sweep')
728
+ logInfo(`${report.rules} rules under ${RULE_DIRS.join(' and ')} in ${root}`)
729
+
730
+ const counted = new Map<RuleCitation['form'], number>()
731
+ for (const citation of report.citations) {
732
+ counted.set(citation.form, (counted.get(citation.form) ?? 0) + 1)
733
+ }
734
+ logInfo(
735
+ report.citations.length === 0
736
+ ? 'no citation in either corpus'
737
+ : `${report.citations.length} citations: ${[...counted]
738
+ .map(([form, count]) => `${count} ${form}`)
739
+ .join(', ')}`,
740
+ )
741
+
742
+ const dead = report.citations.filter((citation) => citation.status === 'dead')
743
+
744
+ logStep('Unresolved')
745
+ if (dead.length === 0) {
746
+ logInfo('every cited path resolves')
747
+ } else {
748
+ for (const citation of dead) logError(describeCitation(citation))
749
+ }
750
+
751
+ reportGlobs(report)
752
+
753
+ reportExcused(
754
+ report.citations,
755
+ 'governed',
756
+ 'Governed',
757
+ 'no rule cites a path it declares in its own frontmatter',
758
+ )
759
+ reportExcused(
760
+ report.citations,
761
+ 'ignored',
762
+ 'Ignored',
763
+ 'no rule cites a path git ignores',
764
+ )
765
+ reportExcused(
766
+ report.citations,
767
+ 'exempt',
768
+ 'Exempt',
769
+ `no line carries a ${CITATION_MARKER} marker`,
770
+ )
771
+
772
+ logInfo(
773
+ 'not read: a citation that resolves and points at the wrong file, a path written without backticks, and a folder or a path carrying no extension',
774
+ )
775
+
776
+ outro()
777
+ }
778
+
532
779
  function describeHit(hit: SupersededHit): string {
533
780
  const note = hit.carriesReplacement
534
781
  ? ' (the replacement is on this line)'
@@ -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
+ }