@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.
- package/CHANGELOG.md +22 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +8 -10
- package/claude/skills/auto-ship/SKILL.md +4 -5
- package/claude/skills/auto-ship/references/failure-recovery.md +37 -17
- package/claude/skills/auto-ship/references/tier-failures.md +9 -7
- package/claude/skills/auto-ship/references/worktree-name.md +8 -5
- package/claude/skills/backlog-triage/SKILL.md +10 -10
- package/claude/skills/backlog-triage/references/apply.md +6 -6
- package/claude/skills/canon-feedback-triage/SKILL.md +7 -5
- package/claude/skills/context-fold/references/classify.md +17 -4
- package/claude/skills/create-rule/references/rule.md +4 -0
- package/claude/skills/create-skill/SKILL.md +2 -2
- package/claude/skills/deploy-app/SKILL.md +2 -0
- package/claude/skills/deprecation-migration/SKILL.md +7 -9
- package/claude/skills/document-health/references/axes.md +1 -1
- package/claude/skills/git-followup/REQUIREMENT.md +4 -4
- package/claude/skills/git-followup/SKILL.md +24 -7
- package/claude/skills/git-issue/references/issue.md +9 -11
- package/claude/skills/git-pr/references/closing-issues.md +8 -0
- package/claude/skills/git-pr/references/publish.md +4 -5
- package/claude/skills/git-split/SKILL.md +11 -4
- package/claude/skills/markdown-craft/SKILL.md +1 -1
- package/claude/skills/markdown-craft/references/markdown.md +2 -2
- package/claude/skills/markdown-craft/references/tables.md +9 -7
- package/claude/skills/plan-feature/REQUIREMENT.md +2 -1
- package/claude/skills/plan-feature/SKILL.md +19 -1
- package/claude/skills/plan-feature/references/onboarding.md +51 -0
- package/claude/skills/plan-feature/references/plan.md +18 -0
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/plan-intake/REQUIREMENT.md +3 -3
- package/claude/skills/plan-intake/SKILL.md +4 -0
- package/claude/skills/plan-intake/references/intake.md +10 -0
- package/claude/skills/review-address/SKILL.md +10 -8
- package/claude/skills/review-craft/SKILL.md +7 -9
- package/claude/skills/review-craft/references/adopted.md +10 -8
- package/claude/skills/review-pr/SKILL.md +2 -0
- package/claude/skills/review-pr/references/key-changes.md +8 -6
- package/claude/skills/review-ui/references/driving.md +10 -8
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +6 -4
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +9 -3
- package/claude/skills/role-reviewer/SKILL.md +5 -5
- package/claude/skills/search-craft/SKILL.md +5 -7
- package/claude/skills/session-worktree/SKILL.md +2 -0
- package/claude/skills/session-worktree/references/main-root-writes.md +2 -0
- package/claude/skills/systematic-debugging/SKILL.md +9 -9
- package/claude/skills/systematic-debugging/references/adopted.md +1 -1
- package/claude/skills/target-update/REQUIREMENT.md +1 -0
- package/claude/skills/target-update/SKILL.md +22 -0
- package/claude/skills/task-board/SKILL.md +2 -4
- package/claude/skills/task-board/references/tasks.md +5 -5
- package/claude/skills/task-complete/references/archive.md +5 -2
- package/claude/skills/test-craft/SKILL.md +2 -0
- package/claude/skills/test-craft/references/adopted.md +2 -0
- package/claude/skills/ui-checklist/SKILL.md +8 -9
- package/claude/skills/write-doc/SKILL.md +8 -8
- package/claude/skills/write-doc/references/readme.md +2 -0
- package/claude/skills/write-doc/references/wiki-kind.md +11 -4
- package/docs/agents/context-audit.md +16 -14
- package/docs/agents/markdown-audit.md +11 -4
- package/docs/agents/migrate.md +15 -13
- package/docs/agents/output-shape.md +2 -0
- package/docs/target/migrations.md +3 -3
- package/docs/workflow/operating-model.md +2 -0
- package/package.json +1 -1
- package/src/commands/markdown.ts +25 -29
- package/src/commands/tasks.ts +10 -4
- package/src/labels/density.ts +5 -46
- package/src/markdown/density.ts +41 -6
- package/src/markdown/scope.ts +6 -1
- package/src/markdown/structure.ts +18 -9
- package/src/markdown/tables.ts +32 -18
- package/src/pr/bijection.ts +5 -3
- package/src/pr/paths.ts +32 -10
- package/src/process/harness.ts +8 -0
- package/src/process/seeded-package.ts +21 -0
- package/src/records/validate.ts +1 -0
- package/src/serve/static.ts +2 -60
- package/src/tasks/branch.ts +6 -1
- package/src/tasks/record.ts +44 -0
- package/tooling/base/reference.md +7 -6
- package/tooling/claude/reference.md +7 -5
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +7 -6
- package/src/serve/live.ts +0 -113
package/src/commands/markdown.ts
CHANGED
|
@@ -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
|
|
145
|
-
'a row, a 200-character bullet, 5 plain bullets in a row, no H4,
|
|
146
|
-
'paragraph or table opening every H3
|
|
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
|
|
158
|
-
'
|
|
159
|
-
'
|
|
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 === '
|
|
647
|
-
?
|
|
648
|
-
:
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
737
|
-
*
|
|
738
|
-
*
|
|
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
|
-
`
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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)
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
package/src/labels/density.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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[] } = {
|
package/src/markdown/density.ts
CHANGED
|
@@ -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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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 (
|
|
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
|
-
|
|
117
|
-
|
|
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
|
*
|
package/src/markdown/scope.ts
CHANGED
|
@@ -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))
|
|
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
|
|
418
|
+
* Finds the bullets carrying more than a decision, nested ones included.
|
|
419
419
|
*
|
|
420
|
-
* A nested item is
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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)
|
package/src/markdown/tables.ts
CHANGED
|
@@ -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
|
|
18
|
+
/** The span's row, or the header for a row count. */
|
|
22
19
|
readonly line: number
|
|
23
|
-
|
|
24
|
-
readonly
|
|
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.
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/pr/bijection.ts
CHANGED
|
@@ -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
|
|
244
|
-
*
|
|
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
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
// it
|
|
295
|
-
if (!span.includes('/'))
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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,
|