@erclx/aitk 3.52.1 → 3.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.52.1",
4
+ "version": "3.53.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -117,6 +117,14 @@ Test every unchecked box against the testing discipline in `${CLAUDE_SKILL_DIR}/
117
117
 
118
118
  Test every ticked box too, bounded to one naming a file or a command. Confirm what it names still exists at `<headRefOid>`, `git show <headRefOid>:<path>` for a file or `git grep <command> <headRefOid>` for a command, rather than re-running what it claims. A box naming neither stays untested, since nothing here can confirm a claim carrying no artifact. A box ticked before a later commit removed or replaced what it names is a `should-fix` finding on the body itself, filed under a `**PR body**` block ahead of the file blocks, since what it corrupts is the merge record rather than a file in the diff.
119
119
 
120
+ Test what `## Key Changes` claims against the diff it describes, with `aitk pr key-changes <number> --json`. This repository squash-merges, so that section becomes the commit message and the record on the trunk once the branch is gone, which is the same thing a stale ticked box corrupts. Branch on the record rather than on the exit code, which a shell function wrapping `aitk` can flatten to zero.
121
+
122
+ Read `unmet` as a `should-fix` finding under the same `**PR body**` block the stale box takes, one entry per path, quoting the bullet the record carries on `preview` and naming the `head` the comparison ran at. Read the bullet before filing it, since one class survives the reader: a bullet citing where something is defined while claiming an edit elsewhere, which puts a real path in the claim region and points the change at a locative the path does not name. Report that as the class rather than as a stale claim. A body is edited between pushes, so the reading is true at that commit and can be false a minute later. Read `unnamed` as a question rather than a finding, since the class covers a real omission and equally a lockfile, a generated asset, or a regenerated index that earns no bullet, and raise it only where a reader would want the bullet. Report `unresolved` nowhere: a path written partially was judged in neither direction and says nothing about the body.
123
+
124
+ A `reason` of `no-claims` means the reader resolved no path out of a section that exists, which is the extractor failing over prose rather than the body being wrong. Say so and raise nothing. `no-section` is the body carrying no such heading, which is a finding only where the pull request template asks for one.
125
+
126
+ Compose this on the body read Step 4 already performs rather than opening a second one. That step scans the title and body for a leaked phase label, and both reads answer from the same text at the same head.
127
+
120
128
  Ask rather than grade. Whether a human is genuinely required is a reading the branch author may hold a reason this session cannot see, so the question carries no severity and enters no count. What it does carry is the heading and the dispatch, on the rule Step 4 states, because a question the author never receives corrects nothing and the author is the only party who can answer it. Answering it closes it, and the answer may be that the requirement holds.
121
129
 
122
130
  Read `## For the reviewer` the same way, bounded to the bullets under that heading rather than the Summary or the Technical Context around it. Those two carry the author's argument for the change, and reading them while judging it is most of what an independent pass exists to avoid, so the read stops at the section itself.
@@ -61,6 +61,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
61
61
  | `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
62
62
  | `aitk labels scan` | Fail a pull request whose title or body carries a phase label, sorting a release pull request's tokens as semver rather than as a leak (`--event`, `--json`) |
63
63
  | `aitk autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
64
+ | `aitk pr key-changes` | Compare the files a pull request body's Key Changes names against its own diff, in both directions (`--body`, `--base`, `--json`) |
64
65
  | `aitk census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
65
66
  | `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
66
67
  | `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
@@ -22,6 +22,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
22
22
  - [Indexes](indexes.md): Flags, exit codes, and JSON shape for aitk indexes regen, plus when it auto-stages what it rewrote
23
23
  - [Install and sync](install-and-sync.md): What each install and sync verb writes, refuses, or leaves alone, and how drift is attributed in a target project
24
24
  - [Intake](intake.md): Reading intake folder counts and items, the three read states an item can be in, landing a batch of selections in one cluster, the refusal reasons, and why a call is scoped to one file
25
+ - [Key Changes bijection](key-changes.md): Comparing the files a pull request body's Key Changes names against its own diff, the two directions and why they are graded apart, the span rules the extractor was measured into, and the three refusals that separate a clean pass from a read that produced nothing
25
26
  - [Label coverage](label-coverage.md): Reading a changed set against the pull request label map, the two tables it matches, how a gap is separated from a decision, and why an absent map is an answer
26
27
  - [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
27
28
  - [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
@@ -0,0 +1,103 @@
1
+ ---
2
+ title: Key Changes bijection
3
+ description: Comparing the files a pull request body's Key Changes names against its own diff, the two directions and why they are graded apart, the span rules the extractor was measured into, and the three refusals that separate a clean pass from a read that produced nothing
4
+ ---
5
+
6
+ # Key Changes bijection
7
+
8
+ `aitk pr key-changes` reads the `## Key Changes` section of a pull request body, lifts the paths its bullets claim, and compares that set against the files the pull request actually changed. This repository squash-merges, so the body becomes the commit message and the record on the trunk once the branch is gone.
9
+
10
+ ```bash
11
+ aitk pr key-changes
12
+ aitk pr key-changes 1265 --json
13
+ aitk pr key-changes --body .claude/.tmp/pr/body.md --base origin/main
14
+ ```
15
+
16
+ The positional is the pull request to read, defaulting to the one open on this branch.
17
+
18
+ | Option | Behavior |
19
+ | --------------- | ------------------------------------------------------------------ |
20
+ | `--body <path>` | Read the body from a file, taking the changed set from git instead |
21
+ | `--base <ref>` | Far side of the range when `--body` supplies the body |
22
+ | `--root <path>` | Repository to read, defaulting to the cwd |
23
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
24
+
25
+ `--body` decides where both halves come from, so a number passed beside it is never read. The body comes off disk and the changed set from the local range, which is the shape a fixture and a body still being drafted both need.
26
+
27
+ Without `--body` the body, the file list, and the head commit come back from one `gh pr view` call, because the three have to describe the same commit and reading them apart leaves a window where a push between them compares a body against another head's files.
28
+
29
+ That view caps its file list at 100 rows and says nothing about having done so, which was measured against `#1250`: the pull request carries 101 files and the view reports 100. A pull request at the cap therefore takes a second read through the paginated endpoint, and a failure there refuses with `gh-truncated` rather than comparing against a set known to be short. A set silently one file short is the worst input this takes, since the missing file is exactly what a correct bullet would then be accused of inventing.
30
+
31
+ ## The two directions
32
+
33
+ They are reported apart because they want different tolerances.
34
+
35
+ - **`unmet`** is a whole path the body claims and the diff does not carry. This is the graded direction and it sets the exit code. A bullet naming an untouched file is wrong more often than not, and it corrupts the record that reaches the trunk.
36
+ - **`unnamed`** is a changed file no bullet reached. Reported with no grade, since the class covers a real omission and equally a lockfile, a generated asset, or a regenerated index that earns no bullet. Grading it would fire on nearly every branch.
37
+ - **`unresolved`** is a path written partially, such as `claude-worker/SKILL.md` for a file under `claude/skills/`. It is judged in neither direction.
38
+
39
+ A partial path can credit a changed file and can never accuse one. Nothing separates a path written short from a path written wrong, so the asymmetry keeps the accusing direction to spans the tree can anchor.
40
+
41
+ ## What counts as a claim
42
+
43
+ Only `## Key Changes` is read. `## Technical Context` legitimately names files a branch never touched, such as an install stamp inside a target, so widening the read manufactures a finding out of every argument an author made for the change.
44
+
45
+ Inside the section, the claim region of a bullet ends at its first comma outside a backticked span. That one lever was chosen by measurement. Over the 23 merged pull requests in this repository carrying the section, reading whole bullets reported 16 paths as claimed-but-untouched and every one was a file the body named for context. Cutting at the comma left 110 claims of the original 149 and took the false reports to 2. A list of sixteen clause-opening words tried beside it removed nothing the comma had not already removed, because this corpus punctuates every one of them.
46
+
47
+ A span inside the claim region has to survive all of these:
48
+
49
+ | Rule | What it keeps out |
50
+ | ------------------------------------------------- | ------------------------------------------------------ |
51
+ | No whitespace, `<`, `>`, `$`, `*`, `\|`, `?`, `^` | A backticked command, a placeholder, a glob, a pattern |
52
+ | No `://` and no leading `/`, `~`, `@`, `#`, `!` | A URL, an absolute path, a module alias |
53
+ | Contains `/` | A bare filename with no folder around it |
54
+ | Extension starts with a letter, or ends with `/` | A dotted number such as an address |
55
+ | Not a single top-level folder | `src/`, which nobody claims to have rewritten whole |
56
+ | A `file:line` span leads its bullet | A citation into a file the bullet is describing |
57
+ | The region carries no no-change marker | A bullet recording a file it deliberately left alone |
58
+
59
+ The last three rows earn their place from the corpus rather than from a rule. Every body that spelled a lone top-level folder was naming where something lives, and a line citation that follows another claim in the same bullet points into the file being described rather than naming a second one. A line citation leading its bullet stays a claim, which is how a body names the exact line it rewrote.
60
+
61
+ The no-change marker is the one rule the region cut cannot substitute for. A body writes "Leave `x` untouched, since the decision keeps it" to record a change it declined, and the path sits ahead of the first comma, so a stricter cut would not reach it and a looser one would find more. Since `keep` and `leave` each open a real claim often enough, the marker decides it rather than the leading verb. Three words carry the set: `untouched`, `unchanged`, and `as written`. `in place` was measured and dropped, because rewriting a file in place is an ordinary claim, and `no other line` was dropped because a correct bullet writes "as one insertion that touches no other line" about a change it did make. `alone` shipped in the set too, until review found every corpus occurrence sitting past the first comma, where the cut already excludes it, so the word voided no true claim there. Restrictive use is the more common one in this repository's own prose, and a comma-free bullet exposed it: "Move the threshold read into `src/gate/stages.ts` alone" asserts an edit, and the marker voided it while the word was still in the set. Over the 40-pull-request corpus the rule still voids no true claim.
62
+
63
+ A folder claim covers every changed file beneath it. A bare filename drops outright: resolved as a sibling of a path earlier in its bullet it produced seven wrong paths across this corpus against two right ones, because a compound bullet names a sibling folder as often as a sibling file.
64
+
65
+ ## Anchoring
66
+
67
+ A claim is anchored when its first segment names a folder the tree holds. The roots come from the tracked file list joined with the first segment of every changed path, and the second half is what admits a folder the branch created. Reading the tree alone would mark every claim under a new top-level directory unanchored, and an unanchored claim never accuses, so the first branch to open one would lose the graded direction with nothing reporting it.
68
+
69
+ ## Exit codes and refusals
70
+
71
+ | Code | Meaning |
72
+ | ---- | ------------------------------------------------- |
73
+ | `0` | every claimed path is in the diff |
74
+ | `1` | refused, with `reason` naming the cause |
75
+ | `2` | at least one claimed path is absent from the diff |
76
+
77
+ Branch on the record rather than on the exit code. A shell function wrapping `aitk` takes its status from whatever it runs last, so every non-zero exit can reach a caller as zero.
78
+
79
+ Three refusals separate a clean pass from a read that produced nothing:
80
+
81
+ - `no-section`. The body carries no such heading, so it claims nothing.
82
+ - `no-claims`. The heading is there and no span resolved, which is the extractor failing over prose rather than the body being wrong. Nothing is raised.
83
+ - `no-changes`. The pull request changed no files, so no claim has anything to answer.
84
+
85
+ An empty extraction read as a clean pass is the failure shape this repository has recorded twice, which is why the middle one is its own reason rather than a zero count.
86
+
87
+ `gh-missing`, `gh-failed`, `gh-truncated`, `unreadable-body`, `unreadable-tree`, `no-base`, `bad-base`, and `unreadable-changes` cover the reads that never reached a comparison.
88
+
89
+ ## What it was measured against
90
+
91
+ Driven over the 40 most recent merged pull requests in this repository, 23 of which carry the section, the shipped verb reported zero unmet paths and one `no-claims` refusal. The naive extractor it replaced reported 16.
92
+
93
+ The first body written after that corpus closed produced two, which is what the no-change marker and the open class above come from. One was the disclaiming bullet and the rule now covers it at no cost to the corpus. The other is the definition-site class, left open with the report naming it.
94
+
95
+ The bound is that the corpus is one repository writing to one house style. A project that punctuates differently loses claims to the comma cut rather than gaining false ones, since every rule above drops rather than invents, so the check degrades toward silence rather than toward noise.
96
+
97
+ What it cannot see is a bullet claiming a change to a file the branch did touch for another reason, a path written without backticks, and a claim in a second coordinated clause after the comma. The last of those falls to the ungraded direction rather than out of the reading.
98
+
99
+ One class stays open and is named rather than closed. A bullet can cite where something is defined while claiming an edit somewhere else, as `#1274` does with "Name the slug transform in `standards/slug.md` inline in the same step". The path is a definition site, the edit target is the step, and separating the two needs the sentence parsed rather than cut. A finding on such a bullet is this class rather than a stale claim, and a reader weighing an `unmet` path checks whether the bullet's real target is a locative the path does not name.
100
+
101
+ ## Where it runs
102
+
103
+ `claude-pr-review` Step 3 calls it and files an `unmet` path as a `should-fix` finding under the `**PR body**` block the stale ticked box already takes, since what both corrupt is the merge record rather than a file in the diff. A body is edited between pushes, so a finding names the head the comparison ran at.
@@ -73,6 +73,14 @@ They collide only if the worker also runs a deep pass. Keep the worker's review
73
73
  light and let the orchestrator own the deep, independent one. The human read at
74
74
  merge is the final gate. No layer repeats another.
75
75
 
76
+ The orchestrator's pass also reads the pull request body against itself, which is
77
+ a vantage the worker never has. This repository squash-merges, so the body
78
+ becomes the commit message and the record on the trunk once the branch is gone.
79
+ `aitk pr key-changes` compares the paths the body's Key Changes claims against
80
+ its own changed-file list, and a claim the diff does not carry is a finding on
81
+ the body rather than on a file. See
82
+ [Key Changes bijection](agents/key-changes.md).
83
+
76
84
  ## The review channel
77
85
 
78
86
  Findings travel on the PR. `claude-pr-review` posts them there.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.52.1",
4
+ "version": "3.53.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -35,6 +35,7 @@ import { register as secrets } from '@/commands/secrets'
35
35
  import { register as deps } from '@/commands/deps'
36
36
  import { register as labels } from '@/commands/labels'
37
37
  import { register as autoship } from '@/commands/autoship'
38
+ import { register as pr } from '@/commands/pr'
38
39
  import { register as census } from '@/commands/census'
39
40
  import { register as targets } from '@/commands/targets'
40
41
  import { register as upgrade } from '@/commands/upgrade'
@@ -83,6 +84,7 @@ function showHelp(): void {
83
84
  `${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
84
85
  `${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
85
86
  `${GREY}│${NC} autoship [cmd] ${GREY}# Decide whether a changed set needs the review pass (classify)${NC}`,
87
+ `${GREY}│${NC} pr [cmd] ${GREY}# Read a pull request body against its own diff (key-changes)${NC}`,
86
88
  `${GREY}│${NC} census [path] ${GREY}# Report tracked file count, extension breakdown, and line totals${NC}`,
87
89
  `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
88
90
  `${GREY}│${NC} gate [cmd] ${GREY}# Run the merge gate stage by stage (run)${NC}`,
@@ -187,6 +189,7 @@ secrets(program)
187
189
  deps(program)
188
190
  labels(program)
189
191
  autoship(program)
192
+ pr(program)
190
193
  census(program)
191
194
  audits(program)
192
195
  gate(program)
@@ -0,0 +1,411 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { $ } from 'bun'
4
+ import type { Command } from 'commander'
5
+ import { execa } from 'execa'
6
+ import { gitEnv } from '@/git-env'
7
+ import {
8
+ listChangedFiles,
9
+ listRepositoryFiles,
10
+ resolveBaseRef,
11
+ } from '@/git-files'
12
+ import {
13
+ type Bijection,
14
+ type BijectionRefusal,
15
+ compareKeyChanges,
16
+ treeRoots,
17
+ } from '@/pr/bijection'
18
+ import { KEY_CHANGES } from '@/pr/paths'
19
+ import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
20
+
21
+ const GH_TIMEOUT_MS = 30_000
22
+
23
+ /** How many unnamed files the frame prints before it names a count instead. */
24
+ const UNNAMED_PRINT_LIMIT = 10
25
+
26
+ /**
27
+ * Where `gh pr view --json files` stops.
28
+ *
29
+ * It pages the underlying query once and returns at most this many rows with
30
+ * nothing on the record saying so, which was measured against `#1250`: the
31
+ * pull request carries 101 files and the view reports 100. A set silently one
32
+ * short is the worst input this comparison can take, since the missing file is
33
+ * exactly what a claim would then be accused of inventing.
34
+ */
35
+ const GH_VIEW_FILE_CAP = 100
36
+
37
+ interface KeyChangesOptions {
38
+ readonly body?: string
39
+ readonly base?: string
40
+ readonly root?: string
41
+ readonly json?: boolean
42
+ }
43
+
44
+ /** Why the read produced no comparison, ahead of the ones the compare owns. */
45
+ type SourceRefusal =
46
+ | 'gh-missing'
47
+ | 'gh-failed'
48
+ | 'gh-truncated'
49
+ | 'unreadable-body'
50
+ | 'unreadable-tree'
51
+ | 'no-base'
52
+ | 'bad-base'
53
+ | 'unreadable-changes'
54
+
55
+ type Refusal = SourceRefusal | BijectionRefusal
56
+
57
+ /** What a reader does about each way this produced no reading. */
58
+ const REFUSALS: Record<Refusal, string> = {
59
+ 'gh-missing':
60
+ 'gh is not on the path, so no pull request body could be read. Pass --body <path> to read one off disk instead.',
61
+ 'gh-failed':
62
+ 'gh could not answer for this branch. Name the pull request number, or pass --body <path>.',
63
+ 'gh-truncated': `gh returned the first ${GH_VIEW_FILE_CAP} changed files and the paginated read that would complete the set failed, so a claim could be accused of naming a file this read never saw.`,
64
+ 'unreadable-body': 'The file named by --body could not be read.',
65
+ 'unreadable-tree':
66
+ 'git could not list this repository, so no path could be judged whole rather than partial.',
67
+ 'no-base': 'No base resolves against the trunk. Fetch origin or pass --base.',
68
+ 'bad-base':
69
+ 'The ref passed to --base resolves to no commit here. Pass one this tree carries.',
70
+ 'unreadable-changes':
71
+ 'git could not list what this branch changed, so the set is unknown.',
72
+ 'no-section': `This body carries no ## ${KEY_CHANGES} section, so it claims nothing to compare.`,
73
+ 'no-claims': `The ## ${KEY_CHANGES} section carried no path this reader could resolve. That is the extractor failing over prose rather than the body being wrong, so nothing is raised.`,
74
+ 'no-changes':
75
+ 'The pull request changed no files, so there is nothing for a claim to answer.',
76
+ }
77
+
78
+ export function register(program: Command): void {
79
+ const pr = program
80
+ .command('pr')
81
+ .description('Read a pull request body against the change it describes')
82
+ .helpOption('-h, --help', 'Show this help message')
83
+
84
+ pr.command('key-changes')
85
+ .description(
86
+ `Compare the files a body's ## ${KEY_CHANGES} names against its own diff`,
87
+ )
88
+ .argument('[number]', 'Pull request to read, defaulting to this branch')
89
+ .helpOption('-h, --help', 'Show this help message')
90
+ .option(
91
+ '--body <path>',
92
+ 'Read the body from a file rather than the API, ignoring any number',
93
+ )
94
+ .option(
95
+ '--base <ref>',
96
+ 'Far side of the range when --body supplies the body',
97
+ )
98
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
99
+ .option('--json', 'Add a machine-readable record on stdout')
100
+ .addHelpText(
101
+ 'after',
102
+ [
103
+ '',
104
+ 'This repository squash-merges, so a pull request body becomes the commit',
105
+ 'message and the record on the trunk once the branch is gone. A bullet',
106
+ 'claiming a change nobody made corrupts that record, and a changed file no',
107
+ 'bullet names leaves it incomplete.',
108
+ '',
109
+ 'The two directions carry different weight:',
110
+ ' unmet a whole path the body claims and the diff does not carry,',
111
+ ' which is the graded direction',
112
+ ' unnamed a changed file no bullet reached, reported without a grade,',
113
+ ' since a lockfile or a generated asset earns no bullet',
114
+ ' unresolved a path written partially, which can credit a changed file',
115
+ ' and never accuse one',
116
+ '',
117
+ `Only ## ${KEY_CHANGES} is read. ## Technical Context legitimately names`,
118
+ 'files a branch never touched, so widening the read manufactures findings.',
119
+ '',
120
+ 'One class survives the reader: a bullet citing where something is defined',
121
+ 'while claiming an edit elsewhere puts a real path in the claim region and',
122
+ 'points the change at a locative the path does not name. Read the bullet on',
123
+ "the record's preview before filing an unmet path as a stale claim.",
124
+ '',
125
+ 'Exit codes:',
126
+ ' 0 every claimed path is in the diff',
127
+ ' 1 refused, with the reason on stderr or in the JSON record',
128
+ ' 2 at least one claimed path is absent from the diff',
129
+ '',
130
+ 'Examples:',
131
+ ' aitk pr key-changes',
132
+ ' aitk pr key-changes 1265 --json',
133
+ ' aitk pr key-changes --body .claude/.tmp/body.md --base origin/main',
134
+ '',
135
+ ].join('\n'),
136
+ )
137
+ .action(async (number: string | undefined, opts: KeyChangesOptions) => {
138
+ process.exitCode = await runKeyChanges(number, opts)
139
+ })
140
+ }
141
+
142
+ interface PullRequestRead {
143
+ readonly body: string
144
+ readonly changed: readonly string[]
145
+ readonly head: string | undefined
146
+ readonly number: number | undefined
147
+ }
148
+
149
+ type SourceRead =
150
+ | { readonly kind: 'read'; readonly source: PullRequestRead }
151
+ | { readonly kind: 'refused'; readonly reason: SourceRefusal }
152
+
153
+ /**
154
+ * Reads the body and the changed set from the pull request the caller named,
155
+ * or from the one open on this branch.
156
+ *
157
+ * One call wherever the file list fits inside it. The body and the file list
158
+ * have to describe the same head, and reading them separately leaves a window
159
+ * where a push between them compares a body against another commit's files. A
160
+ * pull request at the view's cap takes the second read anyway, because a set
161
+ * short by an unknown number is worse than a set read a moment later.
162
+ */
163
+ async function readFromApi(
164
+ cwd: string,
165
+ number: string | undefined,
166
+ ): Promise<SourceRead> {
167
+ if (Bun.which('gh') === null) {
168
+ return { kind: 'refused', reason: 'gh-missing' }
169
+ }
170
+
171
+ const args = ['pr', 'view']
172
+ if (number !== undefined) args.push(number)
173
+ args.push('--json', 'body,files,headRefOid,number')
174
+
175
+ try {
176
+ // See src/worktrees/reclaim.ts for why gh needs the stripped environment:
177
+ // it resolves its repository through the same variables git does and they
178
+ // beat `cwd`, so a run from inside a hook would answer for another
179
+ // repository and compare this branch's claims against its files.
180
+ const result = await execa('gh', args, {
181
+ cwd,
182
+ timeout: GH_TIMEOUT_MS,
183
+ env: gitEnv(),
184
+ extendEnv: false,
185
+ })
186
+
187
+ const row = JSON.parse(result.stdout) as {
188
+ body?: string
189
+ files?: readonly { path: string }[]
190
+ headRefOid?: string
191
+ number?: number
192
+ }
193
+
194
+ const viewed = (row.files ?? []).map((file) => file.path)
195
+ const changed =
196
+ viewed.length < GH_VIEW_FILE_CAP || row.number === undefined
197
+ ? viewed
198
+ : await listFilesByPage(cwd, row.number)
199
+
200
+ if (changed === undefined) {
201
+ return { kind: 'refused', reason: 'gh-truncated' }
202
+ }
203
+
204
+ return {
205
+ kind: 'read',
206
+ source: {
207
+ body: row.body ?? '',
208
+ changed: [...changed].sort(),
209
+ head: row.headRefOid,
210
+ number: row.number,
211
+ },
212
+ }
213
+ } catch {
214
+ return { kind: 'refused', reason: 'gh-failed' }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Every file a pull request changed, read through the paginated endpoint.
220
+ *
221
+ * Only reached when the view came back at the cap, since it costs a request per
222
+ * page and nearly every pull request here fits in one view. Returns undefined
223
+ * when the follow-up fails, which refuses rather than falling back to the
224
+ * capped set: a comparison run against a set known to be short would accuse a
225
+ * correct bullet of naming a file nobody changed.
226
+ */
227
+ async function listFilesByPage(
228
+ cwd: string,
229
+ number: number,
230
+ ): Promise<string[] | undefined> {
231
+ try {
232
+ // See src/worktrees/reclaim.ts for why gh needs the stripped environment.
233
+ const result = await execa(
234
+ 'gh',
235
+ [
236
+ 'api',
237
+ '--paginate',
238
+ `repos/{owner}/{repo}/pulls/${number}/files`,
239
+ '--jq',
240
+ '.[].filename',
241
+ ],
242
+ { cwd, timeout: GH_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
243
+ )
244
+ return result.stdout.split('\n').filter(Boolean)
245
+ } catch {
246
+ return undefined
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Reads the body off disk and the changed set from git, which is the shape a
252
+ * fixture and a body still being drafted both need.
253
+ */
254
+ async function readFromFile(
255
+ root: string,
256
+ path: string,
257
+ base: string | undefined,
258
+ ): Promise<SourceRead> {
259
+ let body: string
260
+ try {
261
+ body = await readFile(resolve(root, path), 'utf8')
262
+ } catch {
263
+ return { kind: 'refused', reason: 'unreadable-body' }
264
+ }
265
+
266
+ const resolved = await resolveBaseRef(root, base)
267
+ if (resolved === undefined) {
268
+ return {
269
+ kind: 'refused',
270
+ reason: base === undefined ? 'no-base' : 'bad-base',
271
+ }
272
+ }
273
+
274
+ const changed = await listChangedFiles(root, resolved)
275
+ if (changed === undefined) {
276
+ return { kind: 'refused', reason: 'unreadable-changes' }
277
+ }
278
+
279
+ const head = await $`git -C ${root} rev-parse HEAD`
280
+ .env(gitEnv())
281
+ .quiet()
282
+ .nothrow()
283
+
284
+ return {
285
+ kind: 'read',
286
+ source: {
287
+ body,
288
+ changed,
289
+ head: head.exitCode === 0 ? head.text().trim() : undefined,
290
+ number: undefined,
291
+ },
292
+ }
293
+ }
294
+
295
+ async function runKeyChanges(
296
+ number: string | undefined,
297
+ opts: KeyChangesOptions,
298
+ ): Promise<number> {
299
+ const root = resolve(opts.root ?? process.cwd())
300
+ const emitJson = opts.json ?? false
301
+
302
+ intro('aitk pr key-changes')
303
+
304
+ const source =
305
+ opts.body === undefined
306
+ ? await readFromApi(root, number)
307
+ : await readFromFile(root, opts.body, opts.base)
308
+
309
+ if (source.kind === 'refused') return refuse(source.reason, emitJson, root)
310
+
311
+ const tracked = await listRepositoryFiles(root)
312
+ if (tracked === undefined) return refuse('unreadable-tree', emitJson, root)
313
+
314
+ const report: Bijection = compareKeyChanges({
315
+ body: source.source.body,
316
+ changed: source.source.changed,
317
+ roots: treeRoots(tracked, source.source.changed),
318
+ ...(source.source.head !== undefined && { head: source.source.head }),
319
+ })
320
+
321
+ if (report.kind === 'refused') return refuse(report.reason, emitJson, root)
322
+
323
+ logStep('Scope')
324
+ logInfo(
325
+ `${plural(report.claims.length, 'claim')} against ${plural(report.changed.length, 'changed file')}${
326
+ report.head === undefined ? '' : ` at ${report.head.slice(0, 8)}`
327
+ }`,
328
+ )
329
+
330
+ logStep(report.unmet.length === 0 ? 'Claimed' : 'Unmet')
331
+ if (report.unmet.length === 0) {
332
+ logInfo('every claimed path is in the diff')
333
+ } else {
334
+ logWarn(
335
+ `${plural(report.unmet.length, 'claimed path')} the diff does not carry. Correct the bullet, or make the change it describes.`,
336
+ )
337
+ for (const claim of report.unmet) {
338
+ logWarn(`${claim.path} — ${claim.preview}`)
339
+ }
340
+ }
341
+
342
+ // Named rather than counted into the verdict. A generated asset, a lockfile,
343
+ // and a regenerated index all change without earning a bullet, so grading
344
+ // this direction would fire on nearly every branch.
345
+ logStep('Unnamed')
346
+ if (report.unnamed.length === 0) {
347
+ logInfo('every changed file is reached by a bullet')
348
+ } else {
349
+ logInfo(
350
+ `${plural(report.unnamed.length, 'changed file')} no bullet reached. Add one where the change is worth a reader knowing about.`,
351
+ )
352
+ // Capped in the frame and whole in the record. A rename branch measured
353
+ // here left 71 of its 100 files unnamed, correctly, and printing all of
354
+ // them buries the graded direction above under a list nobody reads.
355
+ for (const path of report.unnamed.slice(0, UNNAMED_PRINT_LIMIT)) {
356
+ logInfo(path)
357
+ }
358
+ if (report.unnamed.length > UNNAMED_PRINT_LIMIT) {
359
+ logInfo(
360
+ `…and ${report.unnamed.length - UNNAMED_PRINT_LIMIT} more, whole in the --json record.`,
361
+ )
362
+ }
363
+ }
364
+
365
+ if (report.unresolved.length > 0) {
366
+ logStep('Unresolved')
367
+ logInfo(
368
+ `${plural(report.unresolved.length, 'path')} written partially, so neither direction judged it.`,
369
+ )
370
+ for (const claim of report.unresolved) logInfo(claim.path)
371
+ }
372
+
373
+ outro()
374
+
375
+ if (emitJson) {
376
+ process.stdout.write(
377
+ `${JSON.stringify({
378
+ root,
379
+ ...(source.source.number !== undefined && {
380
+ number: source.source.number,
381
+ }),
382
+ ...(report.head !== undefined && { head: report.head }),
383
+ changed: report.changed,
384
+ claims: report.claims,
385
+ unmet: report.unmet,
386
+ unnamed: report.unnamed,
387
+ unresolved: report.unresolved,
388
+ })}\n`,
389
+ )
390
+ }
391
+
392
+ return report.unmet.length === 0 ? 0 : 2
393
+ }
394
+
395
+ /**
396
+ * Frames a refusal on stderr in both modes and puts the record on stdout alone,
397
+ * so an operator reading the terminal sees the reason rather than a command
398
+ * that appeared to do nothing.
399
+ */
400
+ function refuse(reason: Refusal, emitJson: boolean, root: string): number {
401
+ logStep('Refused')
402
+ logWarn(REFUSALS[reason])
403
+ outro()
404
+
405
+ if (emitJson) {
406
+ process.stdout.write(
407
+ `${JSON.stringify({ root, reason, message: REFUSALS[reason] })}\n`,
408
+ )
409
+ }
410
+ return 1
411
+ }
@@ -0,0 +1,145 @@
1
+ import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
2
+
3
+ /**
4
+ * Why the comparison produced no reading.
5
+ *
6
+ * The three are apart because each names a different repair. `no-section` is a
7
+ * body missing the heading, `no-claims` is the extractor failing over a section
8
+ * full of prose, and `no-changes` is a pull request with no files. Folding any
9
+ * of them into a clean pass is the failure this check exists to prevent, since
10
+ * a reader cannot tell a check that found nothing from one that read nothing.
11
+ */
12
+ export type BijectionRefusal = 'no-section' | 'no-claims' | 'no-changes'
13
+
14
+ export interface BijectionReport {
15
+ readonly kind: 'measured'
16
+ /** The commit the changed set was read at, which a finding has to name. */
17
+ readonly head: string | undefined
18
+ readonly changed: readonly string[]
19
+ readonly claims: readonly PathClaim[]
20
+ /**
21
+ * Anchored claims no changed file answers, which is the graded direction. A
22
+ * bullet naming an untouched file is wrong more often than not, and this
23
+ * corpus reported zero of them across 23 correct bodies.
24
+ */
25
+ readonly unmet: readonly PathClaim[]
26
+ /**
27
+ * Claims whose first segment names no entry in the tree, so the comparison
28
+ * could not judge them either way. Reported so a run says what it declined
29
+ * rather than counting a partial spelling as met.
30
+ */
31
+ readonly unresolved: readonly PathClaim[]
32
+ /**
33
+ * Changed files no claim reaches. Reported without a severity, since the
34
+ * class covers a real omission and equally a lockfile, a generated asset, or
35
+ * a regenerated index that legitimately earns no bullet.
36
+ */
37
+ readonly unnamed: readonly string[]
38
+ }
39
+
40
+ export type Bijection =
41
+ | BijectionReport
42
+ | { readonly kind: 'refused'; readonly reason: BijectionRefusal }
43
+
44
+ export interface BijectionInput {
45
+ readonly body: string
46
+ readonly changed: readonly string[]
47
+ /** Top-level entries the tree holds, which decides what counts as anchored. */
48
+ readonly roots: ReadonlySet<string>
49
+ readonly head?: string
50
+ readonly title?: string
51
+ }
52
+
53
+ /**
54
+ * Whether one changed path is the file, or a file under the folder, a claim
55
+ * names.
56
+ *
57
+ * An unanchored claim matches on a segment-anchored suffix, which is what lets
58
+ * `claude-worker/SKILL.md` credit `claude/skills/claude-worker/SKILL.md`. That
59
+ * asymmetry is deliberate: a partial spelling can confirm a changed file was
60
+ * named and never accuse one of being absent, because nothing here separates a
61
+ * path written short from a path written wrong.
62
+ */
63
+ function covers(claim: PathClaim, path: string): boolean {
64
+ if (claim.directory) {
65
+ return claim.anchored
66
+ ? path.startsWith(claim.path)
67
+ : path.includes(`/${claim.path}`)
68
+ }
69
+ if (path === claim.path) return true
70
+ return !claim.anchored && path.endsWith(`/${claim.path}`)
71
+ }
72
+
73
+ /**
74
+ * Compares what a pull request body claims to have changed against what it
75
+ * actually changed, in both directions.
76
+ *
77
+ * The two directions are reported apart because they want different
78
+ * tolerances. A claim nobody made good on is a defect in the record that
79
+ * squash-merges onto the trunk, and a changed file nobody recorded is often
80
+ * correct. Merging them into one count would either grade the second or excuse
81
+ * the first.
82
+ *
83
+ * Pure, so the whole judgment is testable against a fixture. The caller reads
84
+ * the body, the changed set, and the tree roots and hands all three in.
85
+ */
86
+ export function compareKeyChanges(input: BijectionInput): Bijection {
87
+ if (input.changed.length === 0)
88
+ return { kind: 'refused', reason: 'no-changes' }
89
+
90
+ const read = extractKeyChangePaths(
91
+ input.body,
92
+ input.roots,
93
+ input.title ?? KEY_CHANGES,
94
+ )
95
+ if (read.kind === 'no-section')
96
+ return { kind: 'refused', reason: 'no-section' }
97
+ if (read.claims.length === 0) return { kind: 'refused', reason: 'no-claims' }
98
+
99
+ const unmet: PathClaim[] = []
100
+ const unresolved: PathClaim[] = []
101
+ const named = new Set<string>()
102
+
103
+ for (const claim of read.claims) {
104
+ const hits = input.changed.filter((path) => covers(claim, path))
105
+ for (const path of hits) named.add(path)
106
+ if (hits.length > 0) continue
107
+ if (claim.anchored) unmet.push(claim)
108
+ else unresolved.push(claim)
109
+ }
110
+
111
+ return {
112
+ kind: 'measured',
113
+ head: input.head,
114
+ changed: [...input.changed],
115
+ claims: read.claims,
116
+ unmet,
117
+ unresolved,
118
+ unnamed: input.changed.filter((path) => !named.has(path)),
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The top-level folders a claim may be anchored on: the first segment of every
124
+ * path the tree holds, plus the first segment of every changed path.
125
+ *
126
+ * The changed half is what admits a folder this branch created. Reading the
127
+ * tree alone would mark every claim under a new top-level directory unanchored,
128
+ * and an unanchored claim never accuses, so the first branch to open one would
129
+ * silently lose the graded direction.
130
+ *
131
+ * A path with no folder above it contributes nothing, since a claim carrying no
132
+ * slash never reaches the extractor's output and no root would ever be read
133
+ * against it.
134
+ */
135
+ export function treeRoots(
136
+ tracked: readonly string[],
137
+ changed: readonly string[],
138
+ ): Set<string> {
139
+ const roots = new Set<string>()
140
+ for (const path of [...tracked, ...changed]) {
141
+ const at = path.indexOf('/')
142
+ if (at > 0) roots.add(path.slice(0, at))
143
+ }
144
+ return roots
145
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The heading whose bullets state what a branch changed.
3
+ *
4
+ * Read alone, and never widened to a sibling section. `## Technical Context`
5
+ * legitimately names files a branch never touched, such as an install stamp
6
+ * inside a target, so a reader that took the whole body would manufacture a
7
+ * finding out of every argument the author made for the change.
8
+ */
9
+ export const KEY_CHANGES = 'Key Changes'
10
+
11
+ /** What one bullet claimed, kept with its bullet so a finding can quote it. */
12
+ export interface PathClaim {
13
+ /** Repository-relative, with a trailing slash kept on a directory claim. */
14
+ readonly path: string
15
+ /** True when the span named a folder, which covers every file beneath it. */
16
+ readonly directory: boolean
17
+ /**
18
+ * True when the first segment names an entry the tree actually holds.
19
+ *
20
+ * An unanchored claim is a path written partially, such as
21
+ * `claude-worker/SKILL.md` for a file under `claude/skills/`. It can confirm
22
+ * that a changed file was named and can never accuse one of being absent,
23
+ * because the comparison has no way to tell a partial spelling from a
24
+ * genuinely wrong one.
25
+ */
26
+ readonly anchored: boolean
27
+ /** The span exactly as the body wrote it, before the line suffix came off. */
28
+ readonly span: string
29
+ /** One-based index of the bullet inside the section. */
30
+ readonly bullet: number
31
+ /** The bullet, trimmed, so a finding names the sentence it came from. */
32
+ readonly preview: string
33
+ }
34
+
35
+ export type KeyChangeRead =
36
+ | {
37
+ readonly kind: 'read'
38
+ readonly claims: readonly PathClaim[]
39
+ /** Bullets the section carried, so an empty claim set is separable. */
40
+ readonly bullets: number
41
+ }
42
+ | { readonly kind: 'no-section' }
43
+
44
+ /** The longest bullet a claim carries forward, matching the citation sweep. */
45
+ const PREVIEW_LIMIT = 200
46
+
47
+ /** A backticked span, the only carrier this corpus writes a path in. */
48
+ const BACKTICKED = /`([^`\n]+)`/g
49
+
50
+ /** A list item at any indent, in either bullet spelling or as an ordinal. */
51
+ const BULLET = /^\s*(?:[-*+]|\d+\.)\s+(.*)$/
52
+
53
+ const FENCE = /^\s*(?:```|~~~)/
54
+
55
+ const HEADING = /^(#{1,6})\s+(.+?)\s*$/
56
+
57
+ /**
58
+ * A `file.ts:42` or `file.ts:42-58` suffix, which is a reader's click target
59
+ * rather than part of the name.
60
+ */
61
+ const LINE_SUFFIX = /:\d+(?:-\d+)?$/
62
+
63
+ /**
64
+ * A character that puts the span outside a path this comparison resolves.
65
+ *
66
+ * Whitespace separates a backticked command from a backticked path, and it is
67
+ * the whole answer to one of the four observed false-positive classes:
68
+ * `aitk markdown audit .claude/rules --json` carries a slash and names no file.
69
+ * Angle brackets answer a second, since `.claude/plans/feature-<slug>.md`
70
+ * describes a shape rather than naming a file. A glob and a caret describe a
71
+ * shape too, `^src/` being a grep pattern one body spelled in Key Changes, and
72
+ * a leading anchor names something outside this repository.
73
+ *
74
+ * Deliberately not shared with `classifySpan` in `@/gov/citations`, which asks
75
+ * a different question. That sweep resolves what a rule points a reader at,
76
+ * against the filesystem, with a sibling resolving inside the citing rule's own
77
+ * folder. This one resolves what a bullet claims to have changed, against a
78
+ * changed-file list, and it admits a folder where that sweep declines one.
79
+ */
80
+ function isNotRepositoryPath(span: string): boolean {
81
+ if (/[\s<>$*|?^]/.test(span)) return true
82
+ if (span.includes('://')) return true
83
+ return /^[/~@#!]/.test(span)
84
+ }
85
+
86
+ /**
87
+ * Whether the span's last segment carries a file extension.
88
+ *
89
+ * The extension has to start with a letter, which is what keeps `127.0.0.1`
90
+ * out. A bare dotted number reaching the comparison is the shape that put
91
+ * `src/serve/127.0.0.1` in a report over a body that was correct.
92
+ */
93
+ function hasExtension(span: string): boolean {
94
+ const segment = span.slice(span.lastIndexOf('/') + 1)
95
+ return /\.[A-Za-z][A-Za-z0-9]*$/.test(segment)
96
+ }
97
+
98
+ /** Blanks every backticked span so a cue search never fires inside one. */
99
+ function maskSpans(text: string): string {
100
+ return text.replace(/`[^`\n]*`/g, (span) => ' '.repeat(span.length))
101
+ }
102
+
103
+ /**
104
+ * The part of a bullet that asserts a change, which ends at its first comma.
105
+ *
106
+ * This is the one lever that separates a claim from a mention, and it was
107
+ * chosen by measurement rather than by grammar. Over the 23 merged pull
108
+ * requests in this repository that carry the section, reading whole bullets
109
+ * reported 16 paths as claimed-but-untouched and every one of them was a file
110
+ * the body named for context. Cutting at the comma left 110 claims of the
111
+ * original 149 and took the false reports to 2. A list of clause-opening words
112
+ * tried beside it (`which`, `since`, `because`, `rather than`, and eleven more)
113
+ * removed nothing the comma had not already removed, because this corpus
114
+ * punctuates every one of them.
115
+ *
116
+ * What it costs is a claim in a second coordinated clause, as in "Add `x` to
117
+ * `a.ts`, and delete the old inline `y` from `b.ts`", where `b.ts` stops being
118
+ * claimed and falls to the unnamed direction instead. That direction reports
119
+ * without grading, so the cost lands where it does no damage.
120
+ */
121
+ function claimRegion(bullet: string): string {
122
+ const at = maskSpans(bullet).indexOf(',')
123
+ return at === -1 ? bullet : bullet.slice(0, at)
124
+ }
125
+
126
+ /**
127
+ * A claim region that asserts nothing changed.
128
+ *
129
+ * A body writes such a bullet to record a decision it declined, and the path it
130
+ * names is the file it deliberately did not touch, which is the exact inverse
131
+ * of a claim. `#1274` opens one with "Leave `...expect.toml` untouched" and the
132
+ * path sits ahead of the first comma, so the region cut cannot reach it: a
133
+ * stricter cut would not catch this and a looser one would find more.
134
+ *
135
+ * The marker rather than the leading verb decides it, because `keep` and
136
+ * `leave` both open a real claim often enough and neither is safe alone. The
137
+ * set is deliberately three words. `in place` was measured and dropped, since
138
+ * rewriting a file in place is an ordinary claim, and `no other line` was
139
+ * dropped because `#1269` writes "as one insertion that touches no other line"
140
+ * about a change it did make. `alone` was in the set and came out on review:
141
+ * every occurrence across the 40-pull-request corpus sits past the first
142
+ * comma, where the region cut already excludes it, so the word caught nothing
143
+ * real there. Kept, it turns restrictive on a comma-free bullet, which is this
144
+ * repository's more common use of the word: "Move the threshold read into
145
+ * `src/gate/stages.ts` alone." asserts an edit and voided to an empty claim
146
+ * set while the word was in the set, unlike the other three, which disclaim
147
+ * wherever they land.
148
+ */
149
+ const NO_CHANGE =
150
+ /\b(?:untouched|unchanged)\b|\bas written\b|^\s*(?:do not|don't|never)\b/i
151
+
152
+ function disclaimsChange(region: string): boolean {
153
+ return NO_CHANGE.test(maskSpans(region))
154
+ }
155
+
156
+ /**
157
+ * The lines under a heading, ending at the next heading of the same level or
158
+ * higher. Undefined when the body carries no such heading, which the caller
159
+ * reports rather than reading as an empty section.
160
+ */
161
+ export function readSection(body: string, title: string): string | undefined {
162
+ const lines = body.replace(/\r\n/g, '\n').split('\n')
163
+ const wanted = title.toLowerCase()
164
+
165
+ let start = -1
166
+ let level = 0
167
+ let fenced = false
168
+
169
+ for (const [index, line] of lines.entries()) {
170
+ if (FENCE.test(line)) {
171
+ fenced = !fenced
172
+ continue
173
+ }
174
+ if (fenced) continue
175
+ const heading = line.match(HEADING)
176
+ if (heading === null) continue
177
+ if ((heading[2] ?? '').toLowerCase() !== wanted) continue
178
+ start = index + 1
179
+ level = (heading[1] ?? '').length
180
+ break
181
+ }
182
+
183
+ if (start === -1) return undefined
184
+
185
+ const out: string[] = []
186
+ fenced = false
187
+ for (const line of lines.slice(start)) {
188
+ if (FENCE.test(line)) fenced = !fenced
189
+ const heading = fenced ? null : line.match(HEADING)
190
+ if (heading !== null && (heading[1] ?? '').length <= level) break
191
+ out.push(line)
192
+ }
193
+
194
+ return out.join('\n')
195
+ }
196
+
197
+ /**
198
+ * Splits a section into bullets, folding a wrapped continuation line into the
199
+ * bullet above it and starting a new one at every list marker.
200
+ *
201
+ * A nested bullet is its own bullet rather than part of its parent, which keeps
202
+ * one claim region per claim a reader sees.
203
+ */
204
+ function splitBullets(section: string): string[] {
205
+ const bullets: string[] = []
206
+ let current: string[] | undefined
207
+ let fenced = false
208
+
209
+ for (const line of section.split('\n')) {
210
+ if (FENCE.test(line)) {
211
+ fenced = !fenced
212
+ current?.push(line)
213
+ continue
214
+ }
215
+
216
+ const marker = fenced ? null : line.match(BULLET)
217
+ if (marker !== null) {
218
+ if (current !== undefined) bullets.push(current.join(' '))
219
+ current = [marker[1] ?? '']
220
+ continue
221
+ }
222
+
223
+ if (current === undefined) continue
224
+ if (!fenced && line.trim() === '') {
225
+ bullets.push(current.join(' '))
226
+ current = undefined
227
+ continue
228
+ }
229
+ current.push(line.trim())
230
+ }
231
+
232
+ if (current !== undefined) bullets.push(current.join(' '))
233
+ return bullets
234
+ }
235
+
236
+ /** What one span resolved to, or nothing when it names no comparable path. */
237
+ interface ResolvedSpan {
238
+ readonly path: string
239
+ readonly directory: boolean
240
+ }
241
+
242
+ function resolveSpan(span: string): ResolvedSpan | undefined {
243
+ if (span === '' || isNotRepositoryPath(span)) return undefined
244
+
245
+ // A bare name is the fourth observed false-positive class and it drops
246
+ // outright. Resolved as a sibling of a path earlier in the bullet it produced
247
+ // seven wrong paths across this corpus against two right ones, because a
248
+ // compound bullet names a sibling folder as often as a sibling file. Dropping
249
+ // it under-reports in the unnamed direction and never fires in the other.
250
+ if (!span.includes('/')) return undefined
251
+
252
+ if (span.endsWith('/')) {
253
+ // A single top-level folder is never a claim. Nobody reports having changed
254
+ // the whole of `src/`, and every body that spelled one was naming where
255
+ // something lives.
256
+ return span.indexOf('/') === span.length - 1
257
+ ? undefined
258
+ : { path: span, directory: true }
259
+ }
260
+
261
+ return hasExtension(span) ? { path: span, directory: false } : undefined
262
+ }
263
+
264
+ /**
265
+ * Every path the `## Key Changes` section claims a change to.
266
+ *
267
+ * `roots` names the entries the tree holds at its top level, which is what
268
+ * separates a whole path from one written partially. It is passed in rather
269
+ * than read here so the extractor stays a pure function of the body, and there
270
+ * is no default: an absent set would silently mark every claim anchored, which
271
+ * is the direction that accuses.
272
+ *
273
+ * Reports `no-section` rather than an empty read when the heading is absent,
274
+ * and an empty claim set with a bullet count when the heading is there and
275
+ * nothing resolved. The caller needs those apart. A body with no section states
276
+ * nothing, a section that produced no claim is this extractor failing over
277
+ * prose, and only a section that produced claims supports a comparison. An
278
+ * empty extraction read as a clean pass is the failure shape this repository
279
+ * has already recorded twice.
280
+ */
281
+ export function extractKeyChangePaths(
282
+ body: string,
283
+ roots: ReadonlySet<string>,
284
+ title: string = KEY_CHANGES,
285
+ ): KeyChangeRead {
286
+ const section = readSection(body, title)
287
+ if (section === undefined) return { kind: 'no-section' }
288
+
289
+ const bullets = splitBullets(section)
290
+ const claims: PathClaim[] = []
291
+ const seen = new Set<string>()
292
+
293
+ for (const [index, bullet] of bullets.entries()) {
294
+ const trimmed = bullet.trim()
295
+ const preview =
296
+ trimmed.length > PREVIEW_LIMIT
297
+ ? `${trimmed.slice(0, PREVIEW_LIMIT)}…`
298
+ : trimmed
299
+ const region = claimRegion(trimmed)
300
+ if (disclaimsChange(region)) continue
301
+
302
+ let claimed = false
303
+
304
+ for (const match of region.matchAll(BACKTICKED)) {
305
+ const span = match[1] ?? ''
306
+ const bare = span.replace(LINE_SUFFIX, '')
307
+
308
+ // A `file:line` span following another claim in the same bullet is a
309
+ // citation into a file being described rather than a second claim, which
310
+ // is what "the stages at `verify.sh:634` and `:642`" is doing inside a
311
+ // bullet whose claim is the context entry that describes them. Leading
312
+ // its bullet it is an ordinary claim, which is how a body names the exact
313
+ // line it rewrote.
314
+ const cited = bare !== span
315
+ if (cited && claimed) continue
316
+
317
+ const resolved = resolveSpan(bare)
318
+ if (resolved === undefined) continue
319
+ claimed = true
320
+ if (seen.has(resolved.path)) continue
321
+ seen.add(resolved.path)
322
+
323
+ claims.push({
324
+ path: resolved.path,
325
+ directory: resolved.directory,
326
+ anchored: roots.has(resolved.path.slice(0, resolved.path.indexOf('/'))),
327
+ span,
328
+ bullet: index + 1,
329
+ preview,
330
+ })
331
+ }
332
+ }
333
+
334
+ return { kind: 'read', claims, bullets: bullets.length }
335
+ }