@erclx/canon 5.55.0 → 5.57.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +8 -10
  4. package/claude/skills/auto-ship/SKILL.md +4 -5
  5. package/claude/skills/auto-ship/references/failure-recovery.md +37 -17
  6. package/claude/skills/auto-ship/references/tier-failures.md +9 -7
  7. package/claude/skills/auto-ship/references/worktree-name.md +8 -5
  8. package/claude/skills/backlog-triage/SKILL.md +10 -10
  9. package/claude/skills/backlog-triage/references/apply.md +6 -6
  10. package/claude/skills/canon-feedback-triage/SKILL.md +7 -5
  11. package/claude/skills/context-fold/references/classify.md +17 -4
  12. package/claude/skills/create-rule/references/rule.md +4 -0
  13. package/claude/skills/create-skill/SKILL.md +2 -2
  14. package/claude/skills/deploy-app/SKILL.md +2 -0
  15. package/claude/skills/deprecation-migration/SKILL.md +7 -9
  16. package/claude/skills/document-health/references/axes.md +1 -1
  17. package/claude/skills/git-followup/REQUIREMENT.md +4 -4
  18. package/claude/skills/git-followup/SKILL.md +24 -7
  19. package/claude/skills/git-issue/references/issue.md +9 -11
  20. package/claude/skills/git-pr/references/closing-issues.md +8 -0
  21. package/claude/skills/git-pr/references/publish.md +4 -5
  22. package/claude/skills/git-split/SKILL.md +11 -4
  23. package/claude/skills/markdown-craft/SKILL.md +1 -1
  24. package/claude/skills/markdown-craft/references/markdown.md +2 -2
  25. package/claude/skills/markdown-craft/references/tables.md +9 -7
  26. package/claude/skills/plan-feature/REQUIREMENT.md +2 -1
  27. package/claude/skills/plan-feature/SKILL.md +19 -1
  28. package/claude/skills/plan-feature/references/onboarding.md +51 -0
  29. package/claude/skills/plan-feature/references/plan.md +18 -0
  30. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  31. package/claude/skills/plan-intake/REQUIREMENT.md +3 -3
  32. package/claude/skills/plan-intake/SKILL.md +4 -0
  33. package/claude/skills/plan-intake/references/intake.md +10 -0
  34. package/claude/skills/review-address/SKILL.md +10 -8
  35. package/claude/skills/review-craft/SKILL.md +7 -9
  36. package/claude/skills/review-craft/references/adopted.md +10 -8
  37. package/claude/skills/review-pr/SKILL.md +2 -0
  38. package/claude/skills/review-pr/references/key-changes.md +8 -6
  39. package/claude/skills/review-ui/references/driving.md +10 -8
  40. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +6 -4
  41. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +9 -3
  42. package/claude/skills/role-reviewer/SKILL.md +5 -5
  43. package/claude/skills/search-craft/SKILL.md +5 -7
  44. package/claude/skills/session-worktree/SKILL.md +2 -0
  45. package/claude/skills/session-worktree/references/main-root-writes.md +2 -0
  46. package/claude/skills/systematic-debugging/SKILL.md +9 -9
  47. package/claude/skills/systematic-debugging/references/adopted.md +1 -1
  48. package/claude/skills/target-update/REQUIREMENT.md +1 -0
  49. package/claude/skills/target-update/SKILL.md +22 -0
  50. package/claude/skills/task-board/SKILL.md +2 -4
  51. package/claude/skills/task-board/references/tasks.md +5 -5
  52. package/claude/skills/task-complete/references/archive.md +5 -2
  53. package/claude/skills/test-craft/SKILL.md +2 -0
  54. package/claude/skills/test-craft/references/adopted.md +2 -0
  55. package/claude/skills/ui-checklist/SKILL.md +8 -9
  56. package/claude/skills/write-doc/SKILL.md +8 -8
  57. package/claude/skills/write-doc/references/readme.md +2 -0
  58. package/claude/skills/write-doc/references/wiki-kind.md +11 -4
  59. package/docs/agents/context-audit.md +16 -14
  60. package/docs/agents/markdown-audit.md +11 -4
  61. package/docs/agents/migrate.md +15 -13
  62. package/docs/agents/output-shape.md +2 -0
  63. package/docs/target/migrations.md +3 -3
  64. package/docs/workflow/operating-model.md +2 -0
  65. package/package.json +1 -1
  66. package/src/commands/markdown.ts +25 -29
  67. package/src/commands/tasks.ts +10 -4
  68. package/src/labels/density.ts +5 -46
  69. package/src/markdown/density.ts +41 -6
  70. package/src/markdown/scope.ts +6 -1
  71. package/src/markdown/structure.ts +18 -9
  72. package/src/markdown/tables.ts +32 -18
  73. package/src/pr/bijection.ts +5 -3
  74. package/src/pr/paths.ts +32 -10
  75. package/src/process/harness.ts +8 -0
  76. package/src/process/seeded-package.ts +21 -0
  77. package/src/records/validate.ts +1 -0
  78. package/src/serve/static.ts +2 -60
  79. package/src/tasks/branch.ts +6 -1
  80. package/src/tasks/record.ts +44 -0
  81. package/tooling/base/reference.md +7 -6
  82. package/tooling/claude/reference.md +7 -5
  83. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +7 -6
  84. package/src/serve/live.ts +0 -113
@@ -141,9 +141,10 @@ export function register(program: Command): void {
141
141
  'Depth weight is a judgment a reader settles, so it reports and never',
142
142
  'fails a run.',
143
143
  '',
144
- 'Density holds six limits: a 350-character paragraph, 2 paragraphs in',
145
- 'a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a',
146
- 'paragraph or table opening every H3. Only context, docs, skills, tooling',
144
+ 'Density holds eight limits: a 350-character paragraph, 2 paragraphs in',
145
+ 'a row, a 200-character bullet, 5 plain bullets in a row, no H4, a',
146
+ 'paragraph or table opening every H3, a 120-character table cell, and no',
147
+ 'list as the block after a table. Only context, docs, skills, tooling',
147
148
  'seeds and references, the wiki, README.md, and CONTRIBUTING.md take a',
148
149
  'finding. CLAUDE.md and its seed take only the bullet run and the H3',
149
150
  'opening, inside Key paths. In a canon checkout one gates under a',
@@ -154,10 +155,9 @@ export function register(program: Command): void {
154
155
  'and none gates.',
155
156
  'A config that does not parse refuses with its path named.',
156
157
  '',
157
- 'Tables take four limits on the same paths, CLAUDE.md aside: a',
158
- '120-character cell, 8 body rows, a list as the next block, and a code',
159
- 'span with a run past 30 characters. A table finding reports and never',
160
- 'fails a run.',
158
+ 'Tables take two more limits on the same paths, CLAUDE.md aside: 8 body',
159
+ 'rows, and a code span with a run past 30 characters. Each reports and',
160
+ 'never fails a run.',
161
161
  '',
162
162
  'A page whose frontmatter carries kind: reference takes the reference',
163
163
  'shape: no H2 beyond Output, Refusals, and See also, each at most once',
@@ -637,15 +637,19 @@ const DENSITY_LABELS: Record<DensityKind, string> = {
637
637
  'bullet-run': 'bullets in a row',
638
638
  'heading-depth': 'heading depth',
639
639
  'h3-opening': 'H3 opening',
640
+ 'table-cell': 'cell length',
641
+ 'table-then-list': 'list after table',
640
642
  }
641
643
 
642
644
  function describeDensity(finding: GatedDensityFinding): string {
643
645
  const measured =
644
646
  finding.kind === 'h3-opening'
645
647
  ? 'opens with neither a paragraph nor a table'
646
- : finding.kind === 'heading-depth'
647
- ? `H${finding.value} past H${finding.limit}`
648
- : `${finding.value} past ${finding.limit}`
648
+ : finding.kind === 'table-then-list'
649
+ ? 'follows the table with no lead sentence'
650
+ : finding.kind === 'heading-depth'
651
+ ? `H${finding.value} past H${finding.limit}`
652
+ : `${finding.value} past ${finding.limit}`
649
653
  const gate = finding.gating ? ' gating' : ''
650
654
  return ` :${finding.line} ${DENSITY_LABELS[finding.kind]} ${measured}${gate}`
651
655
  }
@@ -662,10 +666,10 @@ function reportDensity(
662
666
  ): void {
663
667
  logStep('Density')
664
668
  logInfo(
665
- `Paragraphs past ${CHECKPOINTS.paragraph} characters, more than 2 paragraphs in a row, bullets past ${CHECKPOINTS.bullet} characters, more than 5 plain bullets in a row, a heading past H3, and an H3 opening with neither a paragraph nor a table.`,
669
+ `Paragraphs past ${CHECKPOINTS.paragraph} characters, more than 2 paragraphs in a row, bullets past ${CHECKPOINTS.bullet} characters, more than 5 plain bullets in a row, a heading past H3, an H3 opening with neither a paragraph nor a table, a table cell past ${TABLE_CELL} characters, and a list directly after a table.`,
666
670
  )
667
671
  logInfo(
668
- 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. CLAUDE.md and its seed take the bullet run and the H3 opening inside Key paths alone. Every other path takes no density finding.',
672
+ 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. CLAUDE.md and its seed take the bullet run and the H3 opening inside Key paths alone, with no table finding. Every other path takes no density finding.',
669
673
  )
670
674
  logInfo(densityGatingLine(gated, source))
671
675
 
@@ -689,7 +693,7 @@ function reportDensity(
689
693
  )
690
694
  if (gating > 0) logWarn('A gating finding fails the run.')
691
695
  logInfo(
692
- 'Split a long block at its seam, put a heading or a table between paragraphs, give a long list H3s, and open each H3 with a sentence or a table.',
696
+ 'Split a long block at its seam, put a heading or a table between paragraphs, give a long list H3s, open each H3 with a sentence or a table, move a long cell into a bullet under the lead, and give a trailing list its own lead sentence.',
693
697
  )
694
698
  pipeOutput(
695
699
  carrying
@@ -718,29 +722,23 @@ function densityGatingLine(
718
722
  }
719
723
 
720
724
  const TABLE_LABELS: Record<TableKind, string> = {
721
- 'table-cell': 'cell length',
722
725
  'table-rows': 'rows',
723
- 'table-then-list': 'list after table',
724
726
  'table-token': 'wide token',
725
727
  }
726
728
 
727
729
  function describeTable(finding: TableFinding): string {
728
- const measured =
729
- finding.kind === 'table-then-list'
730
- ? 'follows the table with no lead sentence'
731
- : `${finding.value} past ${finding.limit}`
732
- return ` :${finding.line} ${TABLE_LABELS[finding.kind]} ${measured}`
730
+ return ` :${finding.line} ${TABLE_LABELS[finding.kind]} ${finding.value} past ${finding.limit}`
733
731
  }
734
732
 
735
733
  /**
736
- * Lists every table finding on the density scope's paths. None gates yet,
737
- * since a gate is decided per path rather than per kind, and a new density
738
- * kind would fail every gated folder carrying a table hit at once.
734
+ * Lists the table findings that only report, on the density scope's paths. The
735
+ * cell and the trailing list gate as density kinds, so `reportDensity` lists
736
+ * them.
739
737
  */
740
738
  function reportTables(reports: readonly FileReport[]): void {
741
739
  logStep('Tables')
742
740
  logInfo(
743
- `Cells past ${TABLE_CELL} characters, tables past ${TABLE_ROWS} body rows, a list directly after a table, and a code span in a cell holding a run past ${TABLE_TOKEN} characters.`,
741
+ `Tables past ${TABLE_ROWS} body rows and a code span in a cell holding a run past ${TABLE_TOKEN} characters.`,
744
742
  )
745
743
  logInfo(
746
744
  'Measured on the paths density measures in full, so never on CLAUDE.md, outside fenced blocks. This never fails a run.',
@@ -760,7 +758,7 @@ function reportTables(reports: readonly FileReport[]): void {
760
758
  `${plural(total, 'finding')} across ${plural(carrying.length, 'file')}`,
761
759
  )
762
760
  logInfo(
763
- 'Move a long cell into a bullet under the lead, split a long table into groups each under a lead sentence, give a trailing list its own lead, and move a wide token out of the table.',
761
+ 'Split a long table into groups each under a lead sentence, and move a wide token out of the table.',
764
762
  )
765
763
  pipeOutput(
766
764
  carrying
@@ -917,11 +915,9 @@ function reportBullets(
917
915
  ): void {
918
916
  logStep('Bullets')
919
917
  logInfo(
920
- 'Top-level bullets measure characters, folding in continuation lines.',
921
- )
922
- logInfo(
923
- 'Nested items and fenced blocks are excluded. Weight is a judgment, never a defect.',
918
+ 'Bullets measure characters, folding in continuation lines, and a nested bullet measures on its own.',
924
919
  )
920
+ logInfo('Fenced blocks are excluded. Weight is a judgment, never a defect.')
925
921
 
926
922
  const carrying = reports
927
923
  .filter((report) => report.structure.heavyBullets.length > 0)
@@ -668,12 +668,15 @@ export function register(program: Command): void {
668
668
  ' no-board the repository holds no `.canon/tasks/` board',
669
669
  ' no-match no task matches the selector',
670
670
  ' ambiguous more than one task matches the selector',
671
- ' bad-input a task and a plan both named, or neither',
671
+ ' bad-input a task and a plan both named',
672
672
  '',
673
673
  'Exit codes:',
674
674
  ' 0 the task is read, whether or not it closes',
675
675
  ' 1 refused, with the reason on stderr or in the JSON record',
676
676
  '',
677
+ 'With no task or plan named, the task whose plan derives the branch is read,',
678
+ 'and a branch nothing owns is no-match.',
679
+ '',
677
680
  'Reads the `Issue:` lines, each `#NNN` or a comma list, and reports',
678
681
  '`issues` and `closing`. The pull request closes them only when it is',
679
682
  "the task's last slice, the condition the merge-time archive applies:",
@@ -1513,15 +1516,18 @@ async function runCloses(
1513
1516
  opts: ClosesCommandOptions,
1514
1517
  ): Promise<number> {
1515
1518
  const emitJson = opts.json ?? false
1516
- const selector = selectorFor(task, opts.plan)
1519
+ const branch = opts.branch ?? (await workingBranch())
1520
+ const branches = branch ? [branch, ...(await formerNames(branch))] : []
1521
+ const selector =
1522
+ !task && !opts.plan
1523
+ ? ({ kind: 'branch', branches } as const)
1524
+ : selectorFor(task, opts.plan)
1517
1525
 
1518
1526
  if ('ok' in selector) {
1519
1527
  return reportRecord('canon tasks closes', selector, emitJson, process.cwd())
1520
1528
  }
1521
1529
 
1522
1530
  const root = opts.root ?? (await mainWorktreeRoot())
1523
- const branch = opts.branch ?? (await workingBranch())
1524
- const branches = branch ? [branch, ...(await formerNames(branch))] : []
1525
1531
  const outcome = await readClosure(
1526
1532
  root,
1527
1533
  selector,
@@ -1,10 +1,6 @@
1
- import {
2
- type DensityFinding,
3
- type DensityKind,
4
- densityFindings,
5
- } from '@/markdown/density'
6
- import { type BodyLine, bodyLines, visibleText } from '@/markdown/scan'
7
- import { CHECKPOINTS, LIST_ITEM } from '@/markdown/structure'
1
+ import { type DensityKind, densityFindings } from '@/markdown/density'
2
+ import { type BodyLine, bodyLines } from '@/markdown/scan'
3
+ import { LIST_ITEM } from '@/markdown/structure'
8
4
 
9
5
  /**
10
6
  * The four counted limits a pull request section carries. The heading limits
@@ -119,7 +115,6 @@ export interface PostDensityFinding extends Omit<BodyDensityFinding, 'kind'> {
119
115
  }
120
116
 
121
117
  const LENGTHS: readonly DensityKind[] = ['paragraph-length', 'bullet-length']
122
- const NESTED_ITEM = /^(\s+)(?:[-*+]|\d+\.)\s+(.*)$/
123
118
  const CHECKBOX = /^\s*[-*+]\s+\[[ xX]\]/
124
119
  const FOOTER = /^🤖/
125
120
 
@@ -134,8 +129,8 @@ const FOOTER = /^🤖/
134
129
  * paragraph run, and a checklist box under `What to look at` takes no length
135
130
  * limit, since a driver needs its route, action, and result.
136
131
  *
137
- * A nested item is measured as a bullet of its own, which `densityFindings()`
138
- * leaves out of its parent, so a finding's subject and its evidence never sum.
132
+ * A nested item is measured as a bullet of its own by `densityFindings()`, which
133
+ * leaves it out of its parent, so a finding's subject and its evidence never sum.
139
134
  */
140
135
  export function postDensity(body: string): PostDensityFinding[] {
141
136
  const lines = bodyLines(body).map((line) =>
@@ -153,7 +148,6 @@ export function postDensity(body: string): PostDensityFinding[] {
153
148
  (chunk) => densityFindings(chunk),
154
149
  ),
155
150
  ...densityFindings(inside).filter(({ kind }) => LENGTHS.includes(kind)),
156
- ...nestedLengths(section.lines),
157
151
  ]
158
152
 
159
153
  for (const finding of measured) {
@@ -214,41 +208,6 @@ function foldedApart(lines: readonly BodyLine[]): {
214
208
  return { outside, inside }
215
209
  }
216
210
 
217
- /**
218
- * A `bullet-length` finding for each nested item past the bullet limit, its
219
- * length taken over the item and the lines indented under it that open no
220
- * item of their own.
221
- */
222
- function nestedLengths(lines: readonly BodyLine[]): DensityFinding[] {
223
- const found: DensityFinding[] = []
224
-
225
- for (const [index, line] of lines.entries()) {
226
- const match = line.fenced ? null : NESTED_ITEM.exec(line.text)
227
- if (!match) continue
228
-
229
- const indent = match[1].length
230
- const parts = [match[2].trim()]
231
- for (const next of lines.slice(index + 1)) {
232
- const lead = /^\s*/.exec(next.text)?.[0].length ?? 0
233
- if (next.fenced || next.text.trim() === '' || lead <= indent) break
234
- if (LIST_ITEM.test(next.text)) break
235
- parts.push(next.text.trim())
236
- }
237
-
238
- const length = visibleText(parts.join(' ')).length
239
- if (length > CHECKPOINTS.bullet) {
240
- found.push({
241
- kind: 'bullet-length',
242
- line: line.number,
243
- value: length,
244
- limit: CHECKPOINTS.bullet,
245
- })
246
- }
247
- }
248
-
249
- return found
250
- }
251
-
252
211
  function sectionsOf(lines: readonly BodyLine[]): Section[] {
253
212
  const sections: Section[] = []
254
213
  let current: { name: string | null; lines: BodyLine[] } = {
@@ -22,6 +22,8 @@ export type DensityKind =
22
22
  | 'bullet-run'
23
23
  | 'heading-depth'
24
24
  | 'h3-opening'
25
+ | 'table-cell'
26
+ | 'table-then-list'
25
27
 
26
28
  export interface DensityFinding {
27
29
  readonly kind: DensityKind
@@ -52,14 +54,16 @@ type Block =
52
54
  * here it belongs to the item: it counts toward the bullet's length and never
53
55
  * toward a paragraph run, since a reader sees it inside the list.
54
56
  *
55
- * A nested item is left out of its parent, as `heavyBullets` leaves it, and so
56
- * is everything indented under the nested item. An indented fence inside an
57
- * item stays with the item, and a column-zero one ends it.
57
+ * A nested item is a block of its own, taking the lines indented past its
58
+ * marker, and its parent leaves it out along with everything indented under it.
59
+ * An indented fence inside an item joins neither and ends the nested item, and
60
+ * a column-zero one ends both.
58
61
  */
59
62
  function blocksOf(lines: readonly BodyLine[]): Block[] {
60
63
  const blocks: Block[] = []
61
64
  let paragraph: { line: number; parts: string[] } | undefined
62
65
  let item: { line: number; parts: string[]; nested: boolean } | undefined
66
+ let child: { line: number; parts: string[]; indent: number } | undefined
63
67
  let inFence = false
64
68
  let afterBlank = false
65
69
 
@@ -74,7 +78,19 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
74
78
  paragraph = undefined
75
79
  }
76
80
 
81
+ const closeChild = (): void => {
82
+ if (child) {
83
+ blocks.push({
84
+ kind: 'item',
85
+ line: child.line,
86
+ text: child.parts.join(' '),
87
+ })
88
+ }
89
+ child = undefined
90
+ }
91
+
77
92
  const closeItem = (): void => {
93
+ closeChild()
78
94
  if (item) {
79
95
  blocks.push({ kind: 'item', line: item.line, text: item.parts.join(' ') })
80
96
  }
@@ -95,7 +111,8 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
95
111
  // The opening delimiter decides who owns the block. `bodyLines` marks a
96
112
  // delimiter and the body alike, so the opener is the first fenced line.
97
113
  if (!inFence) {
98
- if (!(item && indented)) {
114
+ if (item && indented) closeChild()
115
+ else {
99
116
  closeAll()
100
117
  blocks.push({ kind: 'other', line: line.number, table: false })
101
118
  }
@@ -108,13 +125,27 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
108
125
 
109
126
  if (text === '') {
110
127
  closeParagraph()
128
+ closeChild()
111
129
  afterBlank = true
112
130
  continue
113
131
  }
114
132
 
115
133
  if (indented && item) {
116
- if (LIST_ITEM.test(raw)) item.nested = true
117
- else if (!item.nested && !TABLE_ROW.test(raw)) item.parts.push(text)
134
+ const lead = leadOf(raw)
135
+ if (LIST_ITEM.test(raw)) {
136
+ closeChild()
137
+ item.nested = true
138
+ child = {
139
+ line: line.number,
140
+ parts: [text.replace(LIST_ITEM, '')],
141
+ indent: lead,
142
+ }
143
+ } else if (child && lead > child.indent) {
144
+ if (!TABLE_ROW.test(raw)) child.parts.push(text)
145
+ } else {
146
+ closeChild()
147
+ if (!item.nested && !TABLE_ROW.test(raw)) item.parts.push(text)
148
+ }
118
149
  afterBlank = false
119
150
  continue
120
151
  }
@@ -163,6 +194,10 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
163
194
  return blocks
164
195
  }
165
196
 
197
+ function leadOf(raw: string): number {
198
+ return raw.length - raw.trimStart().length
199
+ }
200
+
166
201
  /**
167
202
  * Every breach of the six density limits, in line order.
168
203
  *
@@ -4,6 +4,7 @@ import {
4
4
  densityFindings,
5
5
  } from '@/markdown/density'
6
6
  import type { BodyLine } from '@/markdown/scan'
7
+ import { tableDensityFindings } from '@/markdown/tables'
7
8
 
8
9
  /** The surfaces the retired bullet-stacks stage held, per `ALWAYS_GATED`. */
9
10
  const BULLET_RUN_SCOPE = [
@@ -68,12 +69,16 @@ function keyPathsSection(lines: readonly BodyLine[]): readonly BodyLine[] {
68
69
  * The density findings a path takes, being every finding on a density-scope
69
70
  * file and the Key paths findings on a `CLAUDE.md`. The audit, the init probe,
70
71
  * and `canon claude sync` all read this, so the three agree on what counts.
72
+ * The table cell and trailing-list findings join on a density-scope path only,
73
+ * since `densityFindings` stays what its direct callers grade.
71
74
  */
72
75
  export function scopedDensityFindings(
73
76
  rel: string,
74
77
  lines: readonly BodyLine[],
75
78
  ): DensityFinding[] {
76
- if (inDensityScope(rel)) return densityFindings(lines)
79
+ if (inDensityScope(rel)) {
80
+ return [...densityFindings(lines), ...tableDensityFindings(lines)]
81
+ }
77
82
  if (!KEY_PATHS_SCOPE.test(rel)) return []
78
83
  return densityFindings(keyPathsSection(lines)).filter((finding) =>
79
84
  KEY_PATHS_KINDS.includes(finding.kind),
@@ -415,10 +415,10 @@ export function longestRun(
415
415
  }
416
416
 
417
417
  /**
418
- * Finds the top-level bullets carrying more than a decision.
418
+ * Finds the bullets carrying more than a decision, nested ones included.
419
419
  *
420
- * A nested item is left out rather than folded into its parent, since the
421
- * checkpoint asks what one bullet carries and a child carries its own. Lines
420
+ * A nested item is measured on its own and never folded into its parent, since
421
+ * the checkpoint asks what one bullet carries and a child carries its own. Lines
422
422
  * continuing a bullet do fold in, so a heavy bullet cannot fall under the
423
423
  * checkpoint by being wrapped across two source lines. A fenced line closes the
424
424
  * open bullet, which keeps a bullet from absorbing the example below it.
@@ -436,6 +436,8 @@ export function heavyBullets(
436
436
  interface FoldedBullet extends BulletFinding {
437
437
  /** The bullet's source lines joined by the space a wrap would have carried. */
438
438
  readonly text: string
439
+ /** The width of the indent before the marker, which a continuation must pass. */
440
+ readonly indent: number
439
441
  }
440
442
 
441
443
  /** The walk `heavyBullets` reports from. */
@@ -459,18 +461,18 @@ function foldBullets(
459
461
 
460
462
  const item = line.text.match(LIST_ITEM)
461
463
  const text = line.text.trim()
464
+ const lead = line.text.length - line.text.trimStart().length
462
465
 
463
466
  // Structure is read off the raw line and only the weight is masked. A line
464
467
  // carrying nothing but an autolink has no visible text at all, and reading
465
468
  // its masked form as blank would close the bullet it continues.
466
469
  if (item) {
467
470
  close()
468
- if (item[1].length === 0) {
469
- open = {
470
- line: line.number,
471
- characters: visibleText(text).length,
472
- text,
473
- }
471
+ open = {
472
+ line: line.number,
473
+ characters: visibleText(text).length,
474
+ text,
475
+ indent: item[1].length,
474
476
  }
475
477
  continue
476
478
  }
@@ -480,6 +482,13 @@ function foldBullets(
480
482
  continue
481
483
  }
482
484
 
485
+ // An indented line that does not pass the marker's indent belongs to a
486
+ // parent or to nothing, so it ends a nested item rather than extending it.
487
+ if (open && lead > 0 && lead <= open.indent) {
488
+ close()
489
+ continue
490
+ }
491
+
483
492
  // The joining space a wrapped line would have carried, so folding two
484
493
  // source lines measures what one unwrapped line would have.
485
494
  if (open)
@@ -1,3 +1,4 @@
1
+ import type { DensityFinding } from '@/markdown/density'
1
2
  import { type BodyLine, visibleText } from '@/markdown/scan'
2
3
  import { LIST_ITEM, TABLE_ROW, TABLE_SEPARATOR } from '@/markdown/structure'
3
4
 
@@ -10,19 +11,14 @@ export const TABLE_ROWS = 8
10
11
  /** The longest unbroken run a code span in a table cell may carry. */
11
12
  export const TABLE_TOKEN = 30
12
13
 
13
- export type TableKind =
14
- | 'table-cell'
15
- | 'table-rows'
16
- | 'table-then-list'
17
- | 'table-token'
14
+ export type TableKind = 'table-rows' | 'table-token'
18
15
 
19
16
  export interface TableFinding {
20
17
  readonly kind: TableKind
21
- /** The row holding the cell or span, the header for a row count, or the list's first item. */
18
+ /** The span's row, or the header for a row count. */
22
19
  readonly line: number
23
- /** What was measured, or null for a trailing list, which is a shape rather than a count. */
24
- readonly value: number | null
25
- readonly limit: number | null
20
+ readonly value: number
21
+ readonly limit: number
26
22
  }
27
23
 
28
24
  /**
@@ -55,16 +51,22 @@ function cellsOf(row: string): string[] {
55
51
  }
56
52
 
57
53
  /**
58
- * Every breach of the four table limits, in line order.
54
+ * Every breach of the four table limits, in line order, split by whether it
55
+ * gates.
59
56
  *
60
57
  * A table is a run of pipe rows whose second row is a delimiter, the shape a
61
58
  * renderer draws. Pipe lines with no delimiter render as paragraph text, and a
62
59
  * fenced line is an example, so neither is measured. The limits are a
63
60
  * 120-character cell, 8 body rows, a list as the next block, and a code span
64
- * holding a run past 30 characters. Every finding reports and none gates.
61
+ * holding a run past 30 characters. The cell and the trailing list are density
62
+ * findings, and the row count and the wide token report and never gate.
65
63
  */
66
- export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
67
- const found: TableFinding[] = []
64
+ function measureTables(lines: readonly BodyLine[]): {
65
+ reported: TableFinding[]
66
+ density: DensityFinding[]
67
+ } {
68
+ const reported: TableFinding[] = []
69
+ const density: DensityFinding[] = []
68
70
  let index = 0
69
71
 
70
72
  while (index < lines.length) {
@@ -96,7 +98,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
96
98
  for (const cell of cellsOf(row.text)) {
97
99
  const length = visibleText(cell).length
98
100
  if (length > TABLE_CELL) {
99
- found.push({
101
+ density.push({
100
102
  kind: 'table-cell',
101
103
  line: row.number,
102
104
  value: length,
@@ -106,7 +108,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
106
108
  for (const span of codeSpans(cell)) {
107
109
  const run = unbrokenRun(span)
108
110
  if (run > TABLE_TOKEN) {
109
- found.push({
111
+ reported.push({
110
112
  kind: 'table-token',
111
113
  line: row.number,
112
114
  value: run,
@@ -119,7 +121,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
119
121
 
120
122
  const bodyRows = tableRows.length - 1
121
123
  if (bodyRows > TABLE_ROWS) {
122
- found.push({
124
+ reported.push({
123
125
  kind: 'table-rows',
124
126
  line: header.number,
125
127
  value: bodyRows,
@@ -135,7 +137,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
135
137
  !following.fenced &&
136
138
  LIST_ITEM.test(following.text)
137
139
  ) {
138
- found.push({
140
+ density.push({
139
141
  kind: 'table-then-list',
140
142
  line: following.number,
141
143
  value: null,
@@ -146,5 +148,17 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
146
148
  index = end
147
149
  }
148
150
 
149
- return found.sort((a, b) => a.line - b.line)
151
+ return { reported, density }
152
+ }
153
+
154
+ /** The row count and wide token findings, which report and never gate. */
155
+ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
156
+ return measureTables(lines).reported.sort((a, b) => a.line - b.line)
157
+ }
158
+
159
+ /** The cell and trailing-list findings, which gate as density kinds do. */
160
+ export function tableDensityFindings(
161
+ lines: readonly BodyLine[],
162
+ ): DensityFinding[] {
163
+ return measureTables(lines).density.sort((a, b) => a.line - b.line)
150
164
  }
@@ -150,6 +150,7 @@ function coversIgnoreAddition(
150
150
  * path written short from a path written wrong.
151
151
  */
152
152
  function covers(claim: PathClaim, path: string): boolean {
153
+ if (claim.bare) return path === claim.path
153
154
  if (claim.directory) {
154
155
  return claim.anchored
155
156
  ? path.startsWith(claim.path)
@@ -212,6 +213,8 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
212
213
  continue
213
214
  }
214
215
 
216
+ if (claim.bare) continue
217
+
215
218
  if (claim.anchored && claim.leading && !evidenceUnread) unmet.push(claim)
216
219
  else unresolved.push(claim)
217
220
  }
@@ -240,9 +243,8 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
240
243
  * and an unanchored claim never accuses, so the first branch to open one would
241
244
  * silently lose the graded direction.
242
245
  *
243
- * A path with no folder above it contributes nothing, since a claim carrying no
244
- * slash never reaches the extractor's output and no root would ever be read
245
- * against it.
246
+ * A path with no folder above it contributes nothing, since a bare claim is
247
+ * never anchored and no root would ever be read against it.
246
248
  */
247
249
  export function treeRoots(
248
250
  tracked: readonly string[],
package/src/pr/paths.ts CHANGED
@@ -36,6 +36,16 @@ export interface PathClaim {
36
36
  * accuse with.
37
37
  */
38
38
  readonly leading: boolean
39
+ /**
40
+ * True when the span carried no slash, such as `README.md` or `bun`.
41
+ *
42
+ * A bare claim credits a changed file whose whole path is exactly that name
43
+ * and nothing else, so `package.json` never reaches `web/package.json`. It
44
+ * never accuses: nothing separates a bare file name from a word of prose, so
45
+ * one matching no changed file is reported nowhere. Same asymmetry as
46
+ * `anchored` and `leading`.
47
+ */
48
+ readonly bare: boolean
39
49
  /** The span exactly as the body wrote it, before the line suffix came off. */
40
50
  readonly span: string
41
51
  /** One-based index of the bullet inside the section. */
@@ -282,17 +292,22 @@ function splitBullets(section: string): string[] {
282
292
  interface ResolvedSpan {
283
293
  readonly path: string
284
294
  readonly directory: boolean
295
+ readonly bare: boolean
285
296
  }
286
297
 
287
298
  function resolveSpan(span: string): ResolvedSpan | undefined {
288
299
  if (span === '' || isNotRepositoryPath(span)) return undefined
289
300
 
290
- // A bare name is the fourth observed false-positive class and it drops
291
- // outright. Resolved as a sibling of a path earlier in the bullet it produced
292
- // seven wrong paths across this corpus against two right ones, because a
293
- // compound bullet names a sibling folder as often as a sibling file. Dropping
294
- // it under-reports in the unnamed direction and never fires in the other.
295
- if (!span.includes('/')) return undefined
301
+ // A bare name cannot be resolved as a sibling of a path earlier in the
302
+ // bullet: that produced seven wrong paths across this corpus against two right
303
+ // ones, because a compound bullet names a sibling folder as often as a sibling
304
+ // file. It resolves to itself as a root-level path and is marked bare, which
305
+ // keeps it from ever accusing.
306
+ if (!span.includes('/')) {
307
+ return isDottedNumber(span)
308
+ ? undefined
309
+ : { path: span, directory: false, bare: true }
310
+ }
296
311
 
297
312
  if (span.endsWith('/')) {
298
313
  // A single top-level folder is never a claim. Nobody reports having changed
@@ -300,10 +315,12 @@ function resolveSpan(span: string): ResolvedSpan | undefined {
300
315
  // something lives.
301
316
  return span.indexOf('/') === span.length - 1
302
317
  ? undefined
303
- : { path: span, directory: true }
318
+ : { path: span, directory: true, bare: false }
304
319
  }
305
320
 
306
- return isDottedNumber(span) ? undefined : { path: span, directory: false }
321
+ return isDottedNumber(span)
322
+ ? undefined
323
+ : { path: span, directory: false, bare: false }
307
324
  }
308
325
 
309
326
  /**
@@ -371,7 +388,9 @@ export function extractKeyChangePaths(
371
388
 
372
389
  const resolved = resolveSpan(bare)
373
390
  if (resolved === undefined) continue
374
- claimed = true
391
+ // A bare span is no evidence a path claim came first, so it leaves the
392
+ // citation guard above reading only the claims it was written for.
393
+ if (!resolved.bare) claimed = true
375
394
  // First occurrence wins, across bullets as well as inside one. A path
376
395
  // named trailing here and leading three bullets later keeps the weaker
377
396
  // reading, which loses an accusation and can never add one.
@@ -381,8 +400,11 @@ export function extractKeyChangePaths(
381
400
  claims.push({
382
401
  path: resolved.path,
383
402
  directory: resolved.directory,
384
- anchored: roots.has(resolved.path.slice(0, resolved.path.indexOf('/'))),
403
+ anchored:
404
+ !resolved.bare &&
405
+ roots.has(resolved.path.slice(0, resolved.path.indexOf('/'))),
385
406
  leading: match.start < accusesBefore,
407
+ bare: resolved.bare,
386
408
  span,
387
409
  bullet: index + 1,
388
410
  preview,