@erclx/canon 4.13.0 → 4.14.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": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.13.0",
4
+ "version": "4.14.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -119,7 +119,7 @@ Test every ticked box too, bounded to one naming a file or a command. Confirm wh
119
119
 
120
120
  Test what `## Key Changes` claims against the diff it describes, with `canon 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 `canon` can flatten to zero.
121
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.
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` for yourself and raise nothing off the count. The class still covers a real omission and equally a generated asset or a regenerated index that earns no bullet, and the entries the reader can name as owing none are already held apart under `incidental`. Open the section and confirm no bullet names the file before putting a single one to the author, because this instruction has sent the question to three pull requests in one day over bullets that had named the files all along, and one of those authors added bullets nobody needed. Report `unresolved` and `incidental` nowhere: the first was judged in neither direction and the second owes no bullet, so neither says anything about the body.
123
123
 
124
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
125
 
@@ -23,7 +23,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
23
23
  - [Indexes](indexes.md): Flags, exit codes, and JSON shape for canon indexes regen, plus when it auto-stages what it rewrote
24
24
  - [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
25
25
  - [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
26
- - [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
26
+ - [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 the split inside each that decides what is worth raising, the span rules the extractor was measured into, and the three refusals that separate a clean pass from a read that produced nothing
27
27
  - [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
28
28
  - [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
29
29
  - [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
@@ -1,6 +1,6 @@
1
1
  ---
2
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
3
+ description: Comparing the files a pull request body's Key Changes names against its own diff, the two directions and the split inside each that decides what is worth raising, the span rules the extractor was measured into, and the three refusals that separate a clean pass from a read that produced nothing
4
4
  ---
5
5
 
6
6
  # Key Changes bijection
@@ -32,19 +32,24 @@ That view caps its file list at 100 rows and says nothing about having done so,
32
32
 
33
33
  They are reported apart because they want different tolerances.
34
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.
35
+ - **`unmet`** is a whole path the body claims ahead of its bullet's first comma 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 that a reader might have wanted one for. Reported with no grade, since a change can be too small to describe and still be correctly absent. Grading it would fire on nearly every branch.
37
+ - **`incidental`** is a changed file no bullet reached that owes none: a test beside its subject, anything under a fixture or snapshot folder, and a lockfile a package manager writes. Held apart so the count above reads, and reported rather than dropped so a run still says what it set aside.
38
+ - **`unresolved`** is a path the reading could not judge either way. Two causes reach it: a path written partially, such as `claude-worker/SKILL.md` for a file under `claude/skills/`, and a path past its bullet's first comma.
38
39
 
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
+ Each direction splits on one question, which is whether the evidence is strong enough to raise with a person. A partial path and a trailing path can each credit a changed file and can never accuse one, because nothing separates a path written short from a path written wrong, or a second claim from a file cited for context. Neither split drops anything: what comes out of `unmet` lands in `unresolved` and what comes out of `unnamed` lands in `incidental`, so a count a reader can act on never costs a file the run stayed silent about.
41
+
42
+ A generated asset and a regenerated index belong in the incidental class and are deliberately absent from it, because neither has a spelling that holds outside one project. Guessing at one would set aside a file that did owe a bullet, which hides a real omission, where leaving them out only leaves the count where it already was.
40
43
 
41
44
  ## What counts as a claim
42
45
 
43
46
  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
47
 
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.
48
+ Inside the section, every backticked span in a bullet is read, and the bullet's first comma outside a span divides the ones that can accuse from the ones that can only credit. 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.
49
+
50
+ The cut used to decide whether a span was read at all, and the paths past the comma fell out of the claim set into `unnamed`. That was accepted on the ground that the ungraded direction does no damage, and it did: `claude-pr-review` read `unnamed` as a question to put to the branch author, so on 2026-09-01 the question went to three pull requests over bullets that had named the files all along. Reading the whole bullet as claims outright is the obvious repair and the corpus refuses it, taking `unmet` from 10 to 19 over the 40 most recent merged pull requests carrying the section, with all nine additions in the context class the cut exists to exclude. Reading the whole bullet and gating the accusation on the cut gives claims 243 to 314 and unnamed 1124 to 1058 with `unmet` identical entry for entry.
46
51
 
47
- A span inside the claim region has to survive all of these:
52
+ A span anywhere in the bullet has to survive all of these:
48
53
 
49
54
  | Rule | What it keeps out |
50
55
  | ------------------------------------------------- | ------------------------------------------------------ |
@@ -88,16 +93,18 @@ An empty extraction read as a clean pass is the failure shape this repository ha
88
93
 
89
94
  ## What it was measured against
90
95
 
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.
96
+ Driven over the 40 most recent merged pull requests in this repository at the time, 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.
97
+
98
+ The re-measurement behind the trailing-path split is a wider corpus and its numbers do not compare to those. It takes the 40 most recent merged pull requests that carry the section, which reaches back through 90 merges rather than 40, and the reader reports 10 unmet over it. Those 10 predate the split and survive it entry for entry, which is the check that the widening moved nothing into the graded direction. Three name a gitignored path a diff can never carry and one is the definition-site class named below. The remaining six were not run down, so the residual over that corpus is 10 rather than zero and how much of it is the reader rather than the bodies is unmeasured.
92
99
 
93
100
  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
101
 
95
102
  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
103
 
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.
104
+ What it cannot see is a bullet claiming a change to a file the branch did touch for another reason, and a path written without backticks. A claim in a second coordinated clause after the comma is read and credited, and it stays out of the graded direction, since nothing here separates it from a file the same clause cites for context.
98
105
 
99
106
  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
107
 
101
108
  ## Where it runs
102
109
 
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.
110
+ `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. `unnamed` is read there and raised off no count, since the step's own history is a question sent over bullets that already answered it. `unresolved` and `incidental` are reported nowhere.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.13.0",
4
+ "version": "4.14.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -107,12 +107,14 @@ export function register(program: Command): void {
107
107
  'bullet names leaves it incomplete.',
108
108
  '',
109
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',
110
+ " unmet a whole path the body claims ahead of its bullet's first",
111
+ ' comma and the diff does not carry, the graded direction',
112
+ ' unnamed a changed file no bullet reached that a reader might have',
113
+ ' wanted one for, reported without a grade',
114
+ ' incidental a changed file no bullet reached that owes none: a test, a',
115
+ ' fixture, or a lockfile, held apart so the count above reads',
116
+ ' unresolved a path written partially, or one past its first comma,',
117
+ ' which can credit a changed file and never accuse one',
116
118
  '',
117
119
  `Only ## ${KEY_CHANGES} is read. ## Technical Context legitimately names`,
118
120
  'files a branch never touched, so widening the read manufactures findings.',
@@ -361,11 +363,16 @@ async function runKeyChanges(
361
363
  )
362
364
  }
363
365
  }
366
+ if (report.incidental.length > 0) {
367
+ logInfo(
368
+ `${plural(report.incidental.length, 'further changed file')} set aside as owing no bullet, whole in the --json record.`,
369
+ )
370
+ }
364
371
 
365
372
  if (report.unresolved.length > 0) {
366
373
  logStep('Unresolved')
367
374
  logInfo(
368
- `${plural(report.unresolved.length, 'path')} written partially, so neither direction judged it.`,
375
+ `${plural(report.unresolved.length, 'path')} written partially or trailing its bullet's first comma, so neither direction judged it.`,
369
376
  )
370
377
  for (const claim of report.unresolved) logInfo(claim.path)
371
378
  }
@@ -384,6 +391,7 @@ async function runKeyChanges(
384
391
  claims: report.claims,
385
392
  unmet: report.unmet,
386
393
  unnamed: report.unnamed,
394
+ incidental: report.incidental,
387
395
  unresolved: report.unresolved,
388
396
  })}\n`,
389
397
  )
@@ -32,6 +32,13 @@ interface UpgradeRecord {
32
32
  readonly latest?: string
33
33
  readonly state: 'upgraded' | 'current' | 'cancelled' | 'refused'
34
34
  readonly reason?: string
35
+ /**
36
+ * One rendered line for a caller that reports the outcome without parsing
37
+ * the rest of the record, such as `.husky/post-merge`. `current` reuses
38
+ * `describeSkew` verbatim so its wording never drifts from the line `canon
39
+ * sync --check` and `canon claude skills drift` already report.
40
+ */
41
+ readonly message: string
35
42
  }
36
43
 
37
44
  export function register(program: Command): void {
@@ -116,6 +123,7 @@ async function runUpgrade(opts: UpgradeOptions): Promise<number> {
116
123
  ...base(before, manager, command, skew),
117
124
  after: before,
118
125
  state: 'current',
126
+ message: describeSkew(skew),
119
127
  })
120
128
  return 0
121
129
  }
@@ -152,7 +160,11 @@ async function applyUpgrade(
152
160
  if (!proceed) {
153
161
  logWarn('Cancelled')
154
162
  outro()
155
- emit(opts, { ...base(before, manager, command, skew), state: 'cancelled' })
163
+ emit(opts, {
164
+ ...base(before, manager, command, skew),
165
+ state: 'cancelled',
166
+ message: `Cancelled. ${describeSkew(skew)}`,
167
+ })
156
168
  return 0
157
169
  }
158
170
 
@@ -169,10 +181,11 @@ async function applyUpgrade(
169
181
  })
170
182
 
171
183
  if (result.exitCode !== 0) {
184
+ const target = latestOf(skew) ?? UNKNOWN_LABEL
172
185
  return refuse(
173
186
  opts,
174
187
  before,
175
- `\`${command.join(' ')}\` exited ${result.exitCode}. Run it yourself to read what it reported.`,
188
+ `\`${command.join(' ')}\` exited ${result.exitCode} moving from ${before} to ${target}. Run it yourself to read what it reported.`,
176
189
  manager,
177
190
  )
178
191
  }
@@ -186,16 +199,23 @@ async function applyUpgrade(
186
199
  ...base(before, manager, command, skew),
187
200
  after,
188
201
  state: 'upgraded',
202
+ message: upgradedMessage(before, after),
189
203
  })
190
204
  return 0
191
205
  }
192
206
 
207
+ export function upgradedMessage(before: string, after: string): string {
208
+ return after === before
209
+ ? `Reinstalled ${after}, unchanged.`
210
+ : `Upgraded ${before} to ${after}.`
211
+ }
212
+
193
213
  function base(
194
214
  before: string,
195
215
  manager: Manager,
196
216
  command: readonly string[],
197
217
  skew: SkewReport,
198
- ): Omit<UpgradeRecord, 'state'> {
218
+ ): Omit<UpgradeRecord, 'state' | 'message'> {
199
219
  const latest = latestOf(skew)
200
220
 
201
221
  return {
@@ -221,11 +241,28 @@ function refuse(
221
241
  before,
222
242
  state: 'refused',
223
243
  reason,
244
+ message: reason,
224
245
  })
225
246
  return 1
226
247
  }
227
248
 
228
249
  function emit(opts: UpgradeOptions, record: UpgradeRecord): void {
229
250
  if (opts.json !== true) return
230
- process.stdout.write(`${JSON.stringify(record)}\n`)
251
+ process.stdout.write(
252
+ `${JSON.stringify({ ...record, message: singleLine(record.message) })}\n`,
253
+ )
254
+ }
255
+
256
+ /**
257
+ * `message` is the one field carrying arbitrary text: `describeSkew`'s
258
+ * `unknown` branch embeds a registry error verbatim, and a registry answering
259
+ * with HTML produces one already carrying a double quote. `.husky/post-merge`
260
+ * reads this field with a pattern rather than a parser, so a quote makes it
261
+ * through `JSON.stringify` as an escaped `\"` that the pattern stops at,
262
+ * truncating the line it prints. Collapsing whitespace and swapping the quote
263
+ * for an apostrophe here, once, is what keeps every emitter of `message` from
264
+ * having to reason about that reader.
265
+ */
266
+ export function singleLine(text: string): string {
267
+ return text.replace(/\s+/g, ' ').replace(/"/g, "'")
231
268
  }
@@ -24,17 +24,35 @@ export interface BijectionReport {
24
24
  */
25
25
  readonly unmet: readonly PathClaim[]
26
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.
27
+ * Claims no changed file answers that the comparison could not judge either
28
+ * way, reported so a run says what it declined rather than counting them met.
29
+ *
30
+ * Two causes land here. A claim whose first segment names no entry in the
31
+ * tree is a path written partially, and one past its bullet's first comma is
32
+ * a path the reader cannot separate from a file cited for context. Both are
33
+ * evidence strong enough to credit a changed file and too weak to accuse one,
34
+ * so neither reaches `unmet`, and the cause is on the claim rather than in a
35
+ * bucket of its own: a reader acts on both the same way, by opening the
36
+ * bullet on `preview`.
30
37
  */
31
38
  readonly unresolved: readonly PathClaim[]
32
39
  /**
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.
40
+ * Changed files no claim reaches that a reader might have wanted a bullet
41
+ * for. Reported without a severity, since a change can be too small to
42
+ * describe and still be correctly absent from the section.
36
43
  */
37
44
  readonly unnamed: readonly string[]
45
+ /**
46
+ * Changed files no claim reaches that owe no bullet in the first place.
47
+ *
48
+ * Held apart rather than dropped, so a run still says what it set aside.
49
+ * `#1331` reported seven unnamed files of which four were a test or a
50
+ * fixture, which is what makes the raw count unreadable: a number mixing
51
+ * files that owe a bullet with files that never could cannot be acted on at
52
+ * any value, and the reviewing skill reads `unnamed` as a question to a
53
+ * branch author.
54
+ */
55
+ readonly incidental: readonly string[]
38
56
  }
39
57
 
40
58
  export type Bijection =
@@ -50,6 +68,32 @@ export interface BijectionInput {
50
68
  readonly title?: string
51
69
  }
52
70
 
71
+ /**
72
+ * A changed file that owes no bullet, so its absence from the section is not a
73
+ * gap a reader would want reported.
74
+ *
75
+ * Three classes, each conventional rather than named for this repository: a
76
+ * test beside the subject it covers, anything under a fixture or snapshot
77
+ * folder, and a lockfile a package manager writes. All three change constantly
78
+ * as a consequence of work the section describes in its own terms, which is why
79
+ * a body naming them reads as noise rather than as diligence.
80
+ *
81
+ * A generated asset and a regenerated index belong in the class and are
82
+ * deliberately absent, because neither has a spelling that holds outside one
83
+ * project. Guessing at one would set aside a file that did owe a bullet, which
84
+ * is the direction that hides a real omission, where leaving them out only
85
+ * leaves the count where it already was.
86
+ */
87
+ const INCIDENTAL: readonly RegExp[] = [
88
+ /(?:^|\/)[^/]+\.(?:test|spec)\.[A-Za-z0-9]+$/,
89
+ /(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|fixtures|testdata)\//,
90
+ /(?:^|\/)(?:bun\.lockb?|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|Gemfile\.lock|poetry\.lock|uv\.lock|composer\.lock|go\.sum)$/,
91
+ ]
92
+
93
+ function owesNoBullet(path: string): boolean {
94
+ return INCIDENTAL.some((pattern) => pattern.test(path))
95
+ }
96
+
53
97
  /**
54
98
  * Whether one changed path is the file, or a file under the folder, a claim
55
99
  * names.
@@ -80,6 +124,13 @@ function covers(claim: PathClaim, path: string): boolean {
80
124
  * correct. Merging them into one count would either grade the second or excuse
81
125
  * the first.
82
126
  *
127
+ * Each direction then splits again on the same question, which is whether the
128
+ * evidence is strong enough to raise with a person. A claim reaches `unmet`
129
+ * only when it is both whole and leading, and a changed file reaches `unnamed`
130
+ * only when a bullet was owed for it. What each split sets aside is still
131
+ * reported, under `unresolved` and `incidental`, so a count a reader can act on
132
+ * never comes at the price of a file the run stayed silent about.
133
+ *
83
134
  * Pure, so the whole judgment is testable against a fixture. The caller reads
84
135
  * the body, the changed set, and the tree roots and hands all three in.
85
136
  */
@@ -104,10 +155,12 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
104
155
  const hits = input.changed.filter((path) => covers(claim, path))
105
156
  for (const path of hits) named.add(path)
106
157
  if (hits.length > 0) continue
107
- if (claim.anchored) unmet.push(claim)
158
+ if (claim.anchored && claim.leading) unmet.push(claim)
108
159
  else unresolved.push(claim)
109
160
  }
110
161
 
162
+ const reached = input.changed.filter((path) => !named.has(path))
163
+
111
164
  return {
112
165
  kind: 'measured',
113
166
  head: input.head,
@@ -115,7 +168,8 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
115
168
  claims: read.claims,
116
169
  unmet,
117
170
  unresolved,
118
- unnamed: input.changed.filter((path) => !named.has(path)),
171
+ unnamed: reached.filter((path) => !owesNoBullet(path)),
172
+ incidental: reached.filter(owesNoBullet),
119
173
  }
120
174
  }
121
175
 
package/src/pr/paths.ts CHANGED
@@ -24,6 +24,16 @@ export interface PathClaim {
24
24
  * genuinely wrong one.
25
25
  */
26
26
  readonly anchored: boolean
27
+ /**
28
+ * True when the span sits ahead of the bullet's first comma.
29
+ *
30
+ * A trailing span is a path the bullet names without the reader being able to
31
+ * tell a second claim from a file cited for context, so it credits a changed
32
+ * file and never accuses one. Same asymmetry as `anchored`, on a second
33
+ * question: both mark evidence strong enough to confirm and too weak to
34
+ * accuse with.
35
+ */
36
+ readonly leading: boolean
27
37
  /** The span exactly as the body wrote it, before the line suffix came off. */
28
38
  readonly span: string
29
39
  /** One-based index of the bullet inside the section. */
@@ -101,7 +111,7 @@ function maskSpans(text: string): string {
101
111
  }
102
112
 
103
113
  /**
104
- * The part of a bullet that asserts a change, which ends at its first comma.
114
+ * The part of a bullet that can accuse, which ends at its first comma.
105
115
  *
106
116
  * This is the one lever that separates a claim from a mention, and it was
107
117
  * chosen by measurement rather than by grammar. Over the 23 merged pull
@@ -113,10 +123,27 @@ function maskSpans(text: string): string {
113
123
  * removed nothing the comma had not already removed, because this corpus
114
124
  * punctuates every one of them.
115
125
  *
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.
126
+ * The cut used to bound what the scan collected at all, and that is the half
127
+ * that came out. A path past the comma was left out of the claim set entirely
128
+ * and fell to the unnamed direction, accepted here on the ground that the
129
+ * direction reports without grading. It graded anyway, one consumer removed:
130
+ * `claude-pr-review` reads `unnamed` as a question to put to the branch author,
131
+ * and on 2026-09-01 the question went to three pull requests over bullets that
132
+ * had named the files all along, with `#1329` gaining bullets it did not need.
133
+ *
134
+ * Collecting the whole bullet as claims was measured against the 40 most recent
135
+ * merged pull requests carrying the section and is not the repair: it takes
136
+ * `unmet` from 10 to 19, and every one of the nine is the class the cut was
137
+ * tuned to exclude, as in `#1276`'s "following the frame, the stream split, and
138
+ * the exit ladder `src/commands/labels.ts` already carries". So the scan reads
139
+ * the whole bullet and this cut decides `leading` instead, which is what lets a
140
+ * trailing path credit a changed file while `unmet` stays where the corpus put
141
+ * it. The same 40 give claims 243 to 314, unnamed 1124 to 1058, and unmet 10
142
+ * either way.
143
+ *
144
+ * `disclaimsChange` reads this region too, and for its own reason rather than
145
+ * by sharing this one: a disclaimer opens its bullet, so the cut reaches every
146
+ * one the corpus carries, and `NO_CHANGE` below records what depends on that.
120
147
  */
121
148
  function claimRegion(bullet: string): string {
122
149
  const at = maskSpans(bullet).indexOf(',')
@@ -149,8 +176,8 @@ function claimRegion(bullet: string): string {
149
176
  const NO_CHANGE =
150
177
  /\b(?:untouched|unchanged)\b|\bas written\b|^\s*(?:do not|don't|never)\b/i
151
178
 
152
- function disclaimsChange(region: string): boolean {
153
- return NO_CHANGE.test(maskSpans(region))
179
+ function disclaimsChange(bullet: string): boolean {
180
+ return NO_CHANGE.test(maskSpans(claimRegion(bullet)))
154
181
  }
155
182
 
156
183
  /**
@@ -296,12 +323,12 @@ export function extractKeyChangePaths(
296
323
  trimmed.length > PREVIEW_LIMIT
297
324
  ? `${trimmed.slice(0, PREVIEW_LIMIT)}…`
298
325
  : trimmed
299
- const region = claimRegion(trimmed)
300
- if (disclaimsChange(region)) continue
326
+ if (disclaimsChange(trimmed)) continue
301
327
 
328
+ const accusesBefore = claimRegion(trimmed).length
302
329
  let claimed = false
303
330
 
304
- for (const match of region.matchAll(BACKTICKED)) {
331
+ for (const match of trimmed.matchAll(BACKTICKED)) {
305
332
  const span = match[1] ?? ''
306
333
  const bare = span.replace(LINE_SUFFIX, '')
307
334
 
@@ -317,6 +344,9 @@ export function extractKeyChangePaths(
317
344
  const resolved = resolveSpan(bare)
318
345
  if (resolved === undefined) continue
319
346
  claimed = true
347
+ // First occurrence wins, across bullets as well as inside one. A path
348
+ // named trailing here and leading three bullets later keeps the weaker
349
+ // reading, which loses an accusation and can never add one.
320
350
  if (seen.has(resolved.path)) continue
321
351
  seen.add(resolved.path)
322
352
 
@@ -324,6 +354,7 @@ export function extractKeyChangePaths(
324
354
  path: resolved.path,
325
355
  directory: resolved.directory,
326
356
  anchored: roots.has(resolved.path.slice(0, resolved.path.indexOf('/'))),
357
+ leading: (match.index ?? 0) < accusesBefore,
327
358
  span,
328
359
  bullet: index + 1,
329
360
  preview,