@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/commands.md +2 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/rule-citations.md +98 -0
- package/package.json +1 -1
- package/scripts/core/verify.sh +16 -0
- package/src/commands/gov.ts +247 -0
- package/src/gov/citations.ts +514 -0
package/docs/agents/commands.md
CHANGED
|
@@ -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` |
|
package/docs/agents/index.md
CHANGED
|
@@ -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
package/scripts/core/verify.sh
CHANGED
|
@@ -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.
|
package/src/commands/gov.ts
CHANGED
|
@@ -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
|
+
}
|