@erclx/canon 5.45.0 → 5.47.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 (158) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +15 -31
  3. package/claude/skills/auto-ship/REQUIREMENT.md +14 -25
  4. package/claude/skills/backlog-triage/REQUIREMENT.md +12 -23
  5. package/claude/skills/build-in-slices/REQUIREMENT.md +15 -25
  6. package/claude/skills/canon-cli/REQUIREMENT.md +15 -27
  7. package/claude/skills/canon-feedback/REQUIREMENT.md +10 -18
  8. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +10 -15
  9. package/claude/skills/canvas-board/REQUIREMENT.md +14 -28
  10. package/claude/skills/ci-workflow/REQUIREMENT.md +12 -21
  11. package/claude/skills/code-craft/REQUIREMENT.md +14 -28
  12. package/claude/skills/codebase-layout/REQUIREMENT.md +14 -31
  13. package/claude/skills/context-fold/REQUIREMENT.md +15 -39
  14. package/claude/skills/context-fold/references/context.md +3 -3
  15. package/claude/skills/create-rule/REQUIREMENT.md +10 -24
  16. package/claude/skills/create-rule/SKILL.md +3 -0
  17. package/claude/skills/create-skill/REQUIREMENT.md +10 -21
  18. package/claude/skills/decision-escalate/REQUIREMENT.md +12 -24
  19. package/claude/skills/deploy-app/REQUIREMENT.md +12 -22
  20. package/claude/skills/deprecation-migration/REQUIREMENT.md +14 -42
  21. package/claude/skills/design-identity/REQUIREMENT.md +14 -20
  22. package/claude/skills/design-taste/REQUIREMENT.md +16 -57
  23. package/claude/skills/docs-sync/REQUIREMENT.md +14 -23
  24. package/claude/skills/document-health/REQUIREMENT.md +15 -29
  25. package/claude/skills/document-health/SKILL.md +2 -2
  26. package/claude/skills/document-repair/REQUIREMENT.md +32 -0
  27. package/claude/skills/document-repair/SKILL.md +76 -0
  28. package/claude/skills/document-repair/references/batches.md +44 -0
  29. package/claude/skills/draft-and-pick/REQUIREMENT.md +4 -3
  30. package/claude/skills/draft-doc/REQUIREMENT.md +4 -3
  31. package/claude/skills/draft-figure/REQUIREMENT.md +4 -3
  32. package/claude/skills/draft-identity/REQUIREMENT.md +4 -3
  33. package/claude/skills/draft-ready/REQUIREMENT.md +4 -3
  34. package/claude/skills/draft-screencast/REQUIREMENT.md +4 -3
  35. package/claude/skills/draft-slides/REQUIREMENT.md +4 -3
  36. package/claude/skills/draw-figure/REQUIREMENT.md +15 -39
  37. package/claude/skills/git-branch/REQUIREMENT.md +12 -26
  38. package/claude/skills/git-commit/REQUIREMENT.md +11 -15
  39. package/claude/skills/git-followup/REQUIREMENT.md +11 -22
  40. package/claude/skills/git-followup/SKILL.md +1 -0
  41. package/claude/skills/git-issue/REQUIREMENT.md +9 -15
  42. package/claude/skills/git-pr/REQUIREMENT.md +13 -32
  43. package/claude/skills/git-pr/SKILL.md +11 -2
  44. package/claude/skills/git-pr/references/closing-issues.md +47 -0
  45. package/claude/skills/git-pr/references/pr.md +62 -7
  46. package/claude/skills/git-ship/REQUIREMENT.md +13 -26
  47. package/claude/skills/git-split/REQUIREMENT.md +10 -17
  48. package/claude/skills/git-stage/REQUIREMENT.md +12 -18
  49. package/claude/skills/git-worktree/REQUIREMENT.md +15 -19
  50. package/claude/skills/make-deck/REQUIREMENT.md +13 -21
  51. package/claude/skills/markdown-craft/REQUIREMENT.md +9 -3
  52. package/claude/skills/plan-feature/REQUIREMENT.md +15 -24
  53. package/claude/skills/plan-groundwork/REQUIREMENT.md +12 -28
  54. package/claude/skills/plan-groundwork/SKILL.md +5 -2
  55. package/claude/skills/plan-groundwork/references/output.md +47 -1
  56. package/claude/skills/plan-intake/REQUIREMENT.md +11 -26
  57. package/claude/skills/plan-intake/references/intake.md +2 -2
  58. package/claude/skills/plan-intake-answer/REQUIREMENT.md +14 -29
  59. package/claude/skills/plan-intake-answer/SKILL.md +7 -1
  60. package/claude/skills/plan-ready/REQUIREMENT.md +17 -36
  61. package/claude/skills/pr-evidence/REQUIREMENT.md +11 -17
  62. package/claude/skills/read-frames/REQUIREMENT.md +3 -8
  63. package/claude/skills/record-screencast/REQUIREMENT.md +14 -17
  64. package/claude/skills/review-address/REQUIREMENT.md +14 -38
  65. package/claude/skills/review-address/SKILL.md +18 -6
  66. package/claude/skills/review-address/references/post-review-findings.md +4 -1
  67. package/claude/skills/review-address/references/rebase-only.md +14 -2
  68. package/claude/skills/review-craft/REQUIREMENT.md +14 -32
  69. package/claude/skills/review-pr/REQUIREMENT.md +15 -34
  70. package/claude/skills/review-pr/SKILL.md +9 -2
  71. package/claude/skills/review-pr/references/body-variants.md +29 -8
  72. package/claude/skills/review-pr/references/post.md +29 -10
  73. package/claude/skills/review-ui/REQUIREMENT.md +14 -39
  74. package/claude/skills/review-ui/SKILL.md +1 -0
  75. package/claude/skills/review-ui/references/post.md +2 -0
  76. package/claude/skills/review-ui/references/tick.md +3 -0
  77. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
  78. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +5 -0
  79. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +9 -0
  80. package/claude/skills/role-reviewer/REQUIREMENT.md +15 -65
  81. package/claude/skills/role-reviewer/SKILL.md +8 -1
  82. package/claude/skills/role-worker/REQUIREMENT.md +2 -2
  83. package/claude/skills/role-worker/SKILL.md +3 -0
  84. package/claude/skills/script-screencast/REQUIREMENT.md +10 -16
  85. package/claude/skills/search-craft/REQUIREMENT.md +12 -36
  86. package/claude/skills/search-craft/SKILL.md +1 -1
  87. package/claude/skills/search-craft/references/adopted.md +2 -2
  88. package/claude/skills/session-compact/REQUIREMENT.md +13 -23
  89. package/claude/skills/session-map/REQUIREMENT.md +13 -29
  90. package/claude/skills/session-relay/REQUIREMENT.md +14 -58
  91. package/claude/skills/session-relay/SKILL.md +6 -0
  92. package/claude/skills/session-resume/REQUIREMENT.md +13 -27
  93. package/claude/skills/session-worktree/REQUIREMENT.md +14 -43
  94. package/claude/skills/systematic-debugging/REQUIREMENT.md +13 -34
  95. package/claude/skills/target-check/REQUIREMENT.md +16 -23
  96. package/claude/skills/target-setup/REQUIREMENT.md +11 -34
  97. package/claude/skills/task-board/REQUIREMENT.md +13 -22
  98. package/claude/skills/task-board/SKILL.md +7 -0
  99. package/claude/skills/task-board/references/decline.md +2 -2
  100. package/claude/skills/task-board/references/tasks.md +1 -1
  101. package/claude/skills/task-complete/REQUIREMENT.md +11 -24
  102. package/claude/skills/task-complete/references/archive.md +4 -2
  103. package/claude/skills/teach-workspace/REQUIREMENT.md +14 -45
  104. package/claude/skills/test-craft/REQUIREMENT.md +15 -46
  105. package/claude/skills/test-first/REQUIREMENT.md +14 -20
  106. package/claude/skills/ui-checklist/REQUIREMENT.md +14 -29
  107. package/claude/skills/ui-checklist/SKILL.md +3 -3
  108. package/claude/skills/ux-audit/REQUIREMENT.md +3 -7
  109. package/claude/skills/ux-measure/REQUIREMENT.md +14 -28
  110. package/claude/skills/video-craft/REQUIREMENT.md +14 -27
  111. package/claude/skills/write-doc/REQUIREMENT.md +14 -24
  112. package/claude/skills/write-human/REQUIREMENT.md +15 -27
  113. package/claude/skills/youtube-transcripts/REQUIREMENT.md +9 -15
  114. package/docs/agents/context-audit.md +19 -12
  115. package/docs/agents/markdown-audit.md +15 -10
  116. package/docs/target/projects.md +2 -0
  117. package/docs/workflow/skill-map.md +6 -5
  118. package/docs/workflow/zshrc-aliases.md +3 -0
  119. package/governance/renames.toml +0 -2
  120. package/governance/rules/claude/output.md +5 -1
  121. package/governance/rules/code/routing.md +5 -1
  122. package/governance/stacks/base.toml +0 -1
  123. package/package.json +1 -1
  124. package/scripts/core/bootstrap.sh +1 -0
  125. package/src/claude/cases/workflow.ts +12 -0
  126. package/src/commands/context.ts +57 -3
  127. package/src/commands/labels.ts +149 -4
  128. package/src/commands/markdown.ts +188 -21
  129. package/src/commands/pr.ts +113 -4
  130. package/src/commands/sessions.ts +58 -11
  131. package/src/commands/tasks.ts +182 -0
  132. package/src/commands/worktrees.ts +2 -0
  133. package/src/context/coverage.ts +210 -0
  134. package/src/context/gate.ts +12 -3
  135. package/src/gate/stages.ts +1 -1
  136. package/src/init/steps.ts +45 -0
  137. package/src/intake/items.ts +14 -0
  138. package/src/labels/closing.ts +46 -0
  139. package/src/labels/density.ts +363 -0
  140. package/src/markdown/gated.ts +250 -0
  141. package/src/markdown/scope.ts +115 -2
  142. package/src/migrate/rename.ts +6 -0
  143. package/src/pr/evidence.ts +1 -1
  144. package/src/pr/rest.ts +2 -0
  145. package/src/pr/tick.ts +89 -0
  146. package/src/sessions/claim.ts +7 -0
  147. package/src/sessions/live.ts +79 -6
  148. package/src/sessions/registry.ts +11 -0
  149. package/src/sessions/resolve.ts +85 -7
  150. package/src/tasks/archive.ts +15 -4
  151. package/src/tasks/closes.ts +136 -0
  152. package/src/tasks/record.ts +1 -1
  153. package/src/worktrees/reclaim.ts +16 -1
  154. package/tooling/base/configs/scripts/verify.ts +1 -1
  155. package/tooling/base/seeds/.github/pull_request_template.md +1 -1
  156. package/tooling/claude/seeds/.claude/hooks/board-validate.sh +1 -1
  157. package/tooling/claude/seeds/CLAUDE.md +2 -2
  158. package/governance/rules/canon/task-scope.md +0 -12
@@ -0,0 +1,363 @@
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'
8
+
9
+ /**
10
+ * The four counted limits a pull request section carries. The heading limits
11
+ * of `densityFindings()` are left out, since a body takes no subheading at all
12
+ * and `subheading` reports that once.
13
+ */
14
+ const COUNTED = [
15
+ 'paragraph-length',
16
+ 'paragraph-run',
17
+ 'bullet-length',
18
+ 'bullet-run',
19
+ ] as const satisfies readonly DensityKind[]
20
+
21
+ type CountedKind = (typeof COUNTED)[number]
22
+
23
+ export type BodyDensityKind = CountedKind | 'subheading' | 'mixed-form'
24
+
25
+ function isCounted(kind: DensityKind): kind is CountedKind {
26
+ return (COUNTED as readonly DensityKind[]).includes(kind)
27
+ }
28
+
29
+ export interface BodyDensityFinding {
30
+ /** The H2 the finding sits under, without its hashes, or null before the first. */
31
+ readonly section: string | null
32
+ readonly kind: BodyDensityKind
33
+ /** The line in the whole body, not in its section. */
34
+ readonly line: number
35
+ readonly value: number | null
36
+ readonly limit: number | null
37
+ }
38
+
39
+ const H2 = /^##\s+(.*?)\s*$/
40
+ const SUBHEADING = /^(#{3,6})\s/
41
+ const BOLD_ONLY = /^(\*\*|__)[^*_].*\1\s*$/
42
+ const BOLD_LED = /^(\*\*|__)\S/
43
+
44
+ interface Section {
45
+ readonly name: string | null
46
+ readonly lines: readonly BodyLine[]
47
+ }
48
+
49
+ /**
50
+ * Every density breach in a pull request body, graded per H2 section after
51
+ * the shape `claude/skills/git-pr/references/pr.md` states.
52
+ *
53
+ * `densityFindings()` stays the docs measure, so the PR exemptions live here
54
+ * as splits around it rather than as options on it. Testing takes no counted
55
+ * limit, since its checkboxes count as a bullet run. Key changes splits at
56
+ * each bold label alone on its own block, whatever its length, and Technical context
57
+ * at each bold-led paragraph, so a lead-in starts a new run. Technical
58
+ * context also reports mixing bullets with paragraphs.
59
+ */
60
+ export function bodyDensity(body: string): BodyDensityFinding[] {
61
+ const found: BodyDensityFinding[] = []
62
+
63
+ for (const section of sectionsOf(bodyLines(body))) {
64
+ found.push(...subheadings(section))
65
+ if (section.name === 'Testing') continue
66
+
67
+ const chunks =
68
+ section.name === 'Key changes'
69
+ ? splitAt(section.lines, opensBlock(section.lines, BOLD_ONLY), true)
70
+ : section.name === 'Technical context'
71
+ ? splitAt(section.lines, opensBlock(section.lines, BOLD_LED), false)
72
+ : [section.lines]
73
+
74
+ for (const chunk of chunks) {
75
+ for (const finding of densityFindings(chunk)) {
76
+ const { kind } = finding
77
+ if (!isCounted(kind)) continue
78
+ found.push({ section: section.name, ...finding, kind })
79
+ }
80
+ }
81
+
82
+ if (section.name === 'Technical context') {
83
+ const mixed = mixedForm(section.lines)
84
+ if (mixed !== undefined) {
85
+ found.push({
86
+ section: section.name,
87
+ kind: 'mixed-form',
88
+ line: mixed,
89
+ value: null,
90
+ limit: null,
91
+ })
92
+ }
93
+ }
94
+ }
95
+
96
+ return found.sort((a, b) => a.line - b.line)
97
+ }
98
+
99
+ /**
100
+ * The opening headings of the posts a canon skill writes to a pull request,
101
+ * after `.canon/groundwork/170-github-comment-density/04-post-catalog.md`. A
102
+ * body opening on any other heading is not a post this reader grades.
103
+ */
104
+ export const POST_HEADINGS = [
105
+ 'Evidence',
106
+ 'Review',
107
+ 'Review closed',
108
+ 'UI review',
109
+ 'UI review closed',
110
+ 'Review response',
111
+ 'Post-review findings',
112
+ 'Rebase',
113
+ ] as const
114
+
115
+ export type PostDensityKind = CountedKind | 'heading-depth'
116
+
117
+ export interface PostDensityFinding extends Omit<BodyDensityFinding, 'kind'> {
118
+ readonly kind: PostDensityKind
119
+ }
120
+
121
+ const LENGTHS: readonly DensityKind[] = ['paragraph-length', 'bullet-length']
122
+ const NESTED_ITEM = /^(\s+)(?:[-*+]|\d+\.)\s+(.*)$/
123
+ const CHECKBOX = /^\s*[-*+]\s+\[[ xX]\]/
124
+ const FOOTER = /^🤖/
125
+
126
+ /**
127
+ * Every density breach in a post a skill writes to a pull request, keyed on
128
+ * its opening H2, or none when that heading is not in `POST_HEADINGS`.
129
+ *
130
+ * A post allows H3s, which the UI review groups its verdicts under, so only a
131
+ * heading past H3 reports. The `🤖` footer and the marker never report, and a
132
+ * table never does. Inside `<details>` a block takes the length limits alone,
133
+ * since a reader opens it on purpose. A bold-led paragraph starts a new
134
+ * paragraph run, and a checklist box under `What to look at` takes no length
135
+ * limit, since a driver needs its route, action, and result.
136
+ *
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.
139
+ */
140
+ export function postDensity(body: string): PostDensityFinding[] {
141
+ const lines = bodyLines(body).map((line) =>
142
+ !line.fenced && FOOTER.test(line.text) ? { ...line, text: '' } : line,
143
+ )
144
+ if (!opensOnPostHeading(lines)) return []
145
+
146
+ const found: PostDensityFinding[] = []
147
+ const text = new Map(lines.map((line) => [line.number, line.text]))
148
+
149
+ for (const section of sectionsOf(lines)) {
150
+ const { outside, inside } = foldedApart(section.lines)
151
+ const measured = [
152
+ ...splitAt(outside, opensBlock(outside, BOLD_LED), false).flatMap(
153
+ (chunk) => densityFindings(chunk),
154
+ ),
155
+ ...densityFindings(inside).filter(({ kind }) => LENGTHS.includes(kind)),
156
+ ...nestedLengths(section.lines),
157
+ ]
158
+
159
+ for (const finding of measured) {
160
+ const { kind } = finding
161
+ if (!isCounted(kind) && kind !== 'heading-depth') continue
162
+ if (
163
+ section.name === 'What to look at' &&
164
+ kind === 'bullet-length' &&
165
+ CHECKBOX.test(text.get(finding.line) ?? '')
166
+ ) {
167
+ continue
168
+ }
169
+ found.push({ section: section.name, ...finding, kind })
170
+ }
171
+ }
172
+
173
+ return found.sort((a, b) => a.line - b.line)
174
+ }
175
+
176
+ /** Whether the body's first H2 outside a fence is one of `POST_HEADINGS`. */
177
+ export function isPost(body: string): boolean {
178
+ return opensOnPostHeading(bodyLines(body))
179
+ }
180
+
181
+ function opensOnPostHeading(lines: readonly BodyLine[]): boolean {
182
+ const opening = lines.find((line) => !line.fenced && H2.test(line.text))
183
+ const heading = opening && H2.exec(opening.text)?.[1]
184
+ return (POST_HEADINGS as readonly string[]).includes(heading ?? '')
185
+ }
186
+
187
+ /**
188
+ * Parts a section into the lines outside `<details>` and the lines inside it.
189
+ * The tags stay outside, where each breaks a paragraph run as raw HTML does.
190
+ */
191
+ function foldedApart(lines: readonly BodyLine[]): {
192
+ outside: BodyLine[]
193
+ inside: BodyLine[]
194
+ } {
195
+ const outside: BodyLine[] = []
196
+ const inside: BodyLine[] = []
197
+ let depth = 0
198
+
199
+ for (const line of lines) {
200
+ const tag = line.fenced ? '' : line.text.trim()
201
+ if (tag.startsWith('<details')) {
202
+ depth++
203
+ outside.push(line)
204
+ } else if (tag.startsWith('</details')) {
205
+ depth = Math.max(0, depth - 1)
206
+ outside.push(line)
207
+ } else if (depth > 0) {
208
+ inside.push(line)
209
+ } else {
210
+ outside.push(line)
211
+ }
212
+ }
213
+
214
+ return { outside, inside }
215
+ }
216
+
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
+ function sectionsOf(lines: readonly BodyLine[]): Section[] {
253
+ const sections: Section[] = []
254
+ let current: { name: string | null; lines: BodyLine[] } = {
255
+ name: null,
256
+ lines: [],
257
+ }
258
+
259
+ for (const line of lines) {
260
+ const heading = line.fenced ? null : H2.exec(line.text)
261
+ if (heading) {
262
+ sections.push(current)
263
+ current = { name: heading[1], lines: [] }
264
+ } else {
265
+ current.lines.push(line)
266
+ }
267
+ }
268
+ sections.push(current)
269
+
270
+ return sections.filter((section) => section.lines.length > 0)
271
+ }
272
+
273
+ function subheadings(section: Section): BodyDensityFinding[] {
274
+ return section.lines.flatMap((line) => {
275
+ const match = line.fenced ? null : SUBHEADING.exec(line.text)
276
+ if (!match) return []
277
+ return [
278
+ {
279
+ section: section.name,
280
+ kind: 'subheading' as const,
281
+ line: line.number,
282
+ value: match[1].length,
283
+ limit: 2,
284
+ },
285
+ ]
286
+ })
287
+ }
288
+
289
+ /**
290
+ * Cuts a section into chunks at each line the test picks, never inside a
291
+ * fence. `drop` leaves the picked line out of both chunks, as a bold label
292
+ * that only names the group below it.
293
+ */
294
+ function splitAt(
295
+ lines: readonly BodyLine[],
296
+ breaks: (line: BodyLine, index: number) => boolean,
297
+ drop: boolean,
298
+ ): BodyLine[][] {
299
+ const chunks: BodyLine[][] = [[]]
300
+
301
+ for (const [index, line] of lines.entries()) {
302
+ if (!line.fenced && breaks(line, index)) {
303
+ chunks.push(drop ? [] : [line])
304
+ } else {
305
+ chunks[chunks.length - 1].push(line)
306
+ }
307
+ }
308
+
309
+ return chunks
310
+ }
311
+
312
+ /**
313
+ * Picks a line matching the pattern that opens a block, after a blank line or
314
+ * at the section start. A line directly under a bullet or a paragraph renders
315
+ * as its lazy continuation, so it breaks nothing.
316
+ */
317
+ function opensBlock(
318
+ lines: readonly BodyLine[],
319
+ pattern: RegExp,
320
+ ): (line: BodyLine, index: number) => boolean {
321
+ return (line, index) =>
322
+ pattern.test(line.text) &&
323
+ (index === 0 || lines[index - 1].text.trim() === '')
324
+ }
325
+
326
+ /**
327
+ * The line of the first block of whichever form appears second, when a
328
+ * section carries both bullets and paragraphs, else undefined.
329
+ *
330
+ * A column-zero line directly under an item with no blank between is a lazy
331
+ * continuation of that item, not a paragraph.
332
+ */
333
+ function mixedForm(lines: readonly BodyLine[]): number | undefined {
334
+ let firstItem: number | undefined
335
+ let firstParagraph: number | undefined
336
+ let previous: 'blank' | 'item' | 'paragraph' | 'other' = 'blank'
337
+
338
+ for (const line of lines) {
339
+ const text = line.text
340
+ if (line.fenced) {
341
+ previous = 'other'
342
+ continue
343
+ }
344
+ if (text.trim() === '') {
345
+ previous = 'blank'
346
+ continue
347
+ }
348
+ if (/^\s/.test(text)) continue
349
+
350
+ if (LIST_ITEM.test(text)) {
351
+ firstItem ??= line.number
352
+ previous = 'item'
353
+ } else if (/^(#|\||>|<)/.test(text)) {
354
+ previous = 'other'
355
+ } else if (previous === 'blank' || previous === 'other') {
356
+ firstParagraph ??= line.number
357
+ previous = 'paragraph'
358
+ }
359
+ }
360
+
361
+ if (firstItem === undefined || firstParagraph === undefined) return undefined
362
+ return Math.max(firstItem, firstParagraph)
363
+ }
@@ -0,0 +1,250 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
9
+ import { readFile } from 'node:fs/promises'
10
+ import { dirname, join } from 'node:path'
11
+ import { listRepositoryFiles } from '@/git/files'
12
+ import { densityFindings, type DensityKind } from '@/markdown/density'
13
+ import { referenceShapeGates } from '@/markdown/gate'
14
+ import {
15
+ type ReferenceShapeKind,
16
+ referenceShapeFindings,
17
+ } from '@/markdown/reference-shape'
18
+ import { bodyLines } from '@/markdown/scan'
19
+ import {
20
+ densityGates,
21
+ GATED_PATHS,
22
+ GATED_SCOPE_ROOTS,
23
+ inDensityScope,
24
+ } from '@/markdown/scope'
25
+ import { isOwnCheckout } from '@/roots/project'
26
+
27
+ /** Where a target keeps the prefixes its density findings gate under. */
28
+ export const GATED_CONFIG_REL = join('canon', 'config', 'markdown.toml')
29
+
30
+ /**
31
+ * An absent file is today's target default and gates nothing. A malformed one
32
+ * refuses rather than reading as empty, since an empty list would silently
33
+ * drop every gate a project had turned on.
34
+ */
35
+ export type GatedConfig =
36
+ | { readonly kind: 'absent' }
37
+ | { readonly kind: 'listed'; readonly prefixes: readonly string[] }
38
+ | { readonly kind: 'malformed'; readonly message: string }
39
+
40
+ export function readGatedConfig(root: string): GatedConfig {
41
+ const path = join(root, GATED_CONFIG_REL)
42
+ if (!existsSync(path)) return { kind: 'absent' }
43
+
44
+ let parsed: Record<string, unknown>
45
+ try {
46
+ parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
47
+ string,
48
+ unknown
49
+ >
50
+ } catch (error) {
51
+ return {
52
+ kind: 'malformed',
53
+ message: `${GATED_CONFIG_REL} is not valid TOML: ${error instanceof Error ? error.message : String(error)}`,
54
+ }
55
+ }
56
+
57
+ const table = parsed.markdown
58
+ if (table === undefined) return { kind: 'listed', prefixes: [] }
59
+ if (typeof table !== 'object' || table === null || Array.isArray(table)) {
60
+ return {
61
+ kind: 'malformed',
62
+ message: `${GATED_CONFIG_REL}: [markdown] is not a table`,
63
+ }
64
+ }
65
+
66
+ const gated = (table as Record<string, unknown>).gated
67
+ if (gated === undefined) return { kind: 'listed', prefixes: [] }
68
+ if (
69
+ !Array.isArray(gated) ||
70
+ !gated.every((each): each is string => typeof each === 'string')
71
+ ) {
72
+ return {
73
+ kind: 'malformed',
74
+ message: `${GATED_CONFIG_REL}: markdown.gated is not a list of strings`,
75
+ }
76
+ }
77
+
78
+ return { kind: 'listed', prefixes: gated }
79
+ }
80
+
81
+ /**
82
+ * Whether `prefix` can be listed. A folder ends in a slash, since a bare
83
+ * `docs` would also gate `docs-old/`. A path without one has to name an
84
+ * existing file, which is how a requirement gates by its own path.
85
+ */
86
+ export function prefixShapeError(
87
+ root: string,
88
+ prefix: string,
89
+ ): string | undefined {
90
+ if (prefix === '' || prefix.startsWith('/') || prefix.includes('..')) {
91
+ return `${prefix || '(empty)'} is not a repository-relative path`
92
+ }
93
+ // A listed prefix is a `startsWith` match against repository paths, so either
94
+ // shape below matches no file and would gate nothing while reading as clean.
95
+ if (prefix.includes('//')) {
96
+ return `${prefix} carries a doubled slash, so it matches no file`
97
+ }
98
+ const path = join(root, prefix.replace(/\/$/, ''))
99
+ const isFile = existsSync(path) && !statSync(path).isDirectory()
100
+ if (prefix.endsWith('/')) {
101
+ return isFile
102
+ ? `${prefix} names a file, so drop the trailing slash`
103
+ : undefined
104
+ }
105
+ if (isFile) return undefined
106
+ return `${prefix} names no file. End a folder prefix with a slash.`
107
+ }
108
+
109
+ /**
110
+ * Appends `prefix` to the target's list, creating the file when absent. A
111
+ * prefix already listed writes nothing. Preserves an existing file's mode.
112
+ */
113
+ export function appendGatedPrefix(
114
+ root: string,
115
+ current: readonly string[],
116
+ prefix: string,
117
+ ): 'appended' | 'present' {
118
+ if (current.includes(prefix)) return 'present'
119
+ writeGatedList(root, [...current, prefix])
120
+ return 'appended'
121
+ }
122
+
123
+ /** Written by hand rather than serialized, the way `classifier.toml` is. */
124
+ function writeGatedList(root: string, prefixes: readonly string[]): void {
125
+ const path = join(root, GATED_CONFIG_REL)
126
+ const mode = existsSync(path) ? statSync(path).mode : undefined
127
+ const lines = prefixes.map((each) => ` ${JSON.stringify(each)},`)
128
+
129
+ mkdirSync(dirname(path), { recursive: true })
130
+ writeFileSync(path, `[markdown]\ngated = [\n${lines.join('\n')}\n]\n`)
131
+ if (mode !== undefined) chmodSync(path, mode)
132
+ }
133
+
134
+ /** The prefixes a root gates under, and whether a bullet run gates unlisted. */
135
+ export interface GatedList {
136
+ readonly prefixes: readonly string[]
137
+ readonly alwaysGated: boolean
138
+ }
139
+
140
+ export type GatedResolution =
141
+ | {
142
+ readonly kind: 'resolved'
143
+ readonly source: 'canon' | 'config' | 'absent'
144
+ readonly list: GatedList
145
+ }
146
+ | { readonly kind: 'malformed'; readonly message: string }
147
+
148
+ /**
149
+ * The list a root gates under: `GATED_PATHS` in canon's own checkout, which
150
+ * tracks its rewrite progress, and the target's config everywhere else.
151
+ */
152
+ export function resolveGatedList(
153
+ root: string,
154
+ own: boolean = isOwnCheckout(root),
155
+ ): GatedResolution {
156
+ if (own) {
157
+ return {
158
+ kind: 'resolved',
159
+ source: 'canon',
160
+ list: { prefixes: GATED_PATHS, alwaysGated: true },
161
+ }
162
+ }
163
+
164
+ const config = readGatedConfig(root)
165
+ if (config.kind === 'malformed') return config
166
+ return {
167
+ kind: 'resolved',
168
+ source: config.kind === 'absent' ? 'absent' : 'config',
169
+ list: {
170
+ prefixes: config.kind === 'listed' ? config.prefixes : [],
171
+ alwaysGated: false,
172
+ },
173
+ }
174
+ }
175
+
176
+ export interface BlockingFinding {
177
+ readonly path: string
178
+ readonly kind: DensityKind | ReferenceShapeKind
179
+ readonly line: number
180
+ }
181
+
182
+ /**
183
+ * Every density and reference shape finding that would gate once `prefix` is
184
+ * listed, read from the files the audit reads. An absent folder carries none.
185
+ * Undefined when the tree cannot be listed, since an empty answer there would
186
+ * gate a corpus nobody measured.
187
+ */
188
+ export async function findingsUnder(
189
+ root: string,
190
+ prefix: string,
191
+ ): Promise<BlockingFinding[] | undefined> {
192
+ const listed = await listRepositoryFiles(root)
193
+ if (!listed) return undefined
194
+
195
+ const under = listed.filter(
196
+ (rel) => rel.endsWith('.md') && rel.startsWith(prefix),
197
+ )
198
+ const found = await Promise.all(
199
+ under.map(async (rel) => {
200
+ const source = await readFile(join(root, rel), 'utf8')
201
+ const density = inDensityScope(rel)
202
+ ? densityFindings(bodyLines(source)).filter((finding) =>
203
+ densityGates(rel, finding.kind, [prefix], false),
204
+ )
205
+ : []
206
+ const shape = referenceShapeGates(rel, [prefix])
207
+ ? referenceShapeFindings(source)
208
+ : []
209
+ return [...density, ...shape].map((finding) => ({
210
+ path: rel,
211
+ kind: finding.kind,
212
+ line: finding.line,
213
+ }))
214
+ }),
215
+ )
216
+
217
+ return found.flat()
218
+ }
219
+
220
+ export type ScopeRootsResult =
221
+ | { readonly kind: 'exists' }
222
+ | { readonly kind: 'unavailable' }
223
+ | {
224
+ readonly kind: 'gated'
225
+ readonly gated: readonly string[]
226
+ readonly left: readonly { prefix: string; findings: number }[]
227
+ }
228
+
229
+ /**
230
+ * Gates each of `GATED_SCOPE_ROOTS` that carries no finding, for a new
231
+ * project. A root with one is left out and named, since gating it would fail
232
+ * the first push on prose a scaffolder wrote. An existing config is a list
233
+ * someone chose, so it is never touched.
234
+ */
235
+ export async function gateScopeRoots(root: string): Promise<ScopeRootsResult> {
236
+ if (existsSync(join(root, GATED_CONFIG_REL))) return { kind: 'exists' }
237
+
238
+ const gated: string[] = []
239
+ const left: { prefix: string; findings: number }[] = []
240
+
241
+ for (const prefix of GATED_SCOPE_ROOTS) {
242
+ const findings = await findingsUnder(root, prefix)
243
+ if (!findings) return { kind: 'unavailable' }
244
+ if (findings.length === 0) gated.push(prefix)
245
+ else left.push({ prefix, findings: findings.length })
246
+ }
247
+
248
+ writeGatedList(root, gated)
249
+ return { kind: 'gated', gated, left }
250
+ }