@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +15 -31
- package/claude/skills/auto-ship/REQUIREMENT.md +14 -25
- package/claude/skills/backlog-triage/REQUIREMENT.md +12 -23
- package/claude/skills/build-in-slices/REQUIREMENT.md +15 -25
- package/claude/skills/canon-cli/REQUIREMENT.md +15 -27
- package/claude/skills/canon-feedback/REQUIREMENT.md +10 -18
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +10 -15
- package/claude/skills/canvas-board/REQUIREMENT.md +14 -28
- package/claude/skills/ci-workflow/REQUIREMENT.md +12 -21
- package/claude/skills/code-craft/REQUIREMENT.md +14 -28
- package/claude/skills/codebase-layout/REQUIREMENT.md +14 -31
- package/claude/skills/context-fold/REQUIREMENT.md +15 -39
- package/claude/skills/context-fold/references/context.md +3 -3
- package/claude/skills/create-rule/REQUIREMENT.md +10 -24
- package/claude/skills/create-rule/SKILL.md +3 -0
- package/claude/skills/create-skill/REQUIREMENT.md +10 -21
- package/claude/skills/decision-escalate/REQUIREMENT.md +12 -24
- package/claude/skills/deploy-app/REQUIREMENT.md +12 -22
- package/claude/skills/deprecation-migration/REQUIREMENT.md +14 -42
- package/claude/skills/design-identity/REQUIREMENT.md +14 -20
- package/claude/skills/design-taste/REQUIREMENT.md +16 -57
- package/claude/skills/docs-sync/REQUIREMENT.md +14 -23
- package/claude/skills/document-health/REQUIREMENT.md +15 -29
- package/claude/skills/document-health/SKILL.md +2 -2
- package/claude/skills/document-repair/REQUIREMENT.md +32 -0
- package/claude/skills/document-repair/SKILL.md +76 -0
- package/claude/skills/document-repair/references/batches.md +44 -0
- package/claude/skills/draft-and-pick/REQUIREMENT.md +4 -3
- package/claude/skills/draft-doc/REQUIREMENT.md +4 -3
- package/claude/skills/draft-figure/REQUIREMENT.md +4 -3
- package/claude/skills/draft-identity/REQUIREMENT.md +4 -3
- package/claude/skills/draft-ready/REQUIREMENT.md +4 -3
- package/claude/skills/draft-screencast/REQUIREMENT.md +4 -3
- package/claude/skills/draft-slides/REQUIREMENT.md +4 -3
- package/claude/skills/draw-figure/REQUIREMENT.md +15 -39
- package/claude/skills/git-branch/REQUIREMENT.md +12 -26
- package/claude/skills/git-commit/REQUIREMENT.md +11 -15
- package/claude/skills/git-followup/REQUIREMENT.md +11 -22
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-issue/REQUIREMENT.md +9 -15
- package/claude/skills/git-pr/REQUIREMENT.md +13 -32
- package/claude/skills/git-pr/SKILL.md +11 -2
- package/claude/skills/git-pr/references/closing-issues.md +47 -0
- package/claude/skills/git-pr/references/pr.md +62 -7
- package/claude/skills/git-ship/REQUIREMENT.md +13 -26
- package/claude/skills/git-split/REQUIREMENT.md +10 -17
- package/claude/skills/git-stage/REQUIREMENT.md +12 -18
- package/claude/skills/git-worktree/REQUIREMENT.md +15 -19
- package/claude/skills/make-deck/REQUIREMENT.md +13 -21
- package/claude/skills/markdown-craft/REQUIREMENT.md +9 -3
- package/claude/skills/plan-feature/REQUIREMENT.md +15 -24
- package/claude/skills/plan-groundwork/REQUIREMENT.md +12 -28
- package/claude/skills/plan-groundwork/SKILL.md +5 -2
- package/claude/skills/plan-groundwork/references/output.md +47 -1
- package/claude/skills/plan-intake/REQUIREMENT.md +11 -26
- package/claude/skills/plan-intake/references/intake.md +2 -2
- package/claude/skills/plan-intake-answer/REQUIREMENT.md +14 -29
- package/claude/skills/plan-intake-answer/SKILL.md +7 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +17 -36
- package/claude/skills/pr-evidence/REQUIREMENT.md +11 -17
- package/claude/skills/read-frames/REQUIREMENT.md +3 -8
- package/claude/skills/record-screencast/REQUIREMENT.md +14 -17
- package/claude/skills/review-address/REQUIREMENT.md +14 -38
- package/claude/skills/review-address/SKILL.md +18 -6
- package/claude/skills/review-address/references/post-review-findings.md +4 -1
- package/claude/skills/review-address/references/rebase-only.md +14 -2
- package/claude/skills/review-craft/REQUIREMENT.md +14 -32
- package/claude/skills/review-pr/REQUIREMENT.md +15 -34
- package/claude/skills/review-pr/SKILL.md +9 -2
- package/claude/skills/review-pr/references/body-variants.md +29 -8
- package/claude/skills/review-pr/references/post.md +29 -10
- package/claude/skills/review-ui/REQUIREMENT.md +14 -39
- package/claude/skills/review-ui/SKILL.md +1 -0
- package/claude/skills/review-ui/references/post.md +2 -0
- package/claude/skills/review-ui/references/tick.md +3 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +5 -0
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +9 -0
- package/claude/skills/role-reviewer/REQUIREMENT.md +15 -65
- package/claude/skills/role-reviewer/SKILL.md +8 -1
- package/claude/skills/role-worker/REQUIREMENT.md +2 -2
- package/claude/skills/role-worker/SKILL.md +3 -0
- package/claude/skills/script-screencast/REQUIREMENT.md +10 -16
- package/claude/skills/search-craft/REQUIREMENT.md +12 -36
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/claude/skills/search-craft/references/adopted.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +13 -23
- package/claude/skills/session-map/REQUIREMENT.md +13 -29
- package/claude/skills/session-relay/REQUIREMENT.md +14 -58
- package/claude/skills/session-relay/SKILL.md +6 -0
- package/claude/skills/session-resume/REQUIREMENT.md +13 -27
- package/claude/skills/session-worktree/REQUIREMENT.md +14 -43
- package/claude/skills/systematic-debugging/REQUIREMENT.md +13 -34
- package/claude/skills/target-check/REQUIREMENT.md +16 -23
- package/claude/skills/target-setup/REQUIREMENT.md +11 -34
- package/claude/skills/task-board/REQUIREMENT.md +13 -22
- package/claude/skills/task-board/SKILL.md +7 -0
- package/claude/skills/task-board/references/decline.md +2 -2
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/task-complete/REQUIREMENT.md +11 -24
- package/claude/skills/task-complete/references/archive.md +4 -2
- package/claude/skills/teach-workspace/REQUIREMENT.md +14 -45
- package/claude/skills/test-craft/REQUIREMENT.md +15 -46
- package/claude/skills/test-first/REQUIREMENT.md +14 -20
- package/claude/skills/ui-checklist/REQUIREMENT.md +14 -29
- package/claude/skills/ui-checklist/SKILL.md +3 -3
- package/claude/skills/ux-audit/REQUIREMENT.md +3 -7
- package/claude/skills/ux-measure/REQUIREMENT.md +14 -28
- package/claude/skills/video-craft/REQUIREMENT.md +14 -27
- package/claude/skills/write-doc/REQUIREMENT.md +14 -24
- package/claude/skills/write-human/REQUIREMENT.md +15 -27
- package/claude/skills/youtube-transcripts/REQUIREMENT.md +9 -15
- package/docs/agents/context-audit.md +19 -12
- package/docs/agents/markdown-audit.md +15 -10
- package/docs/target/projects.md +2 -0
- package/docs/workflow/skill-map.md +6 -5
- package/docs/workflow/zshrc-aliases.md +3 -0
- package/governance/renames.toml +0 -2
- package/governance/rules/claude/output.md +5 -1
- package/governance/rules/code/routing.md +5 -1
- package/governance/stacks/base.toml +0 -1
- package/package.json +1 -1
- package/scripts/core/bootstrap.sh +1 -0
- package/src/claude/cases/workflow.ts +12 -0
- package/src/commands/context.ts +57 -3
- package/src/commands/labels.ts +149 -4
- package/src/commands/markdown.ts +188 -21
- package/src/commands/pr.ts +113 -4
- package/src/commands/sessions.ts +58 -11
- package/src/commands/tasks.ts +182 -0
- package/src/commands/worktrees.ts +2 -0
- package/src/context/coverage.ts +210 -0
- package/src/context/gate.ts +12 -3
- package/src/gate/stages.ts +1 -1
- package/src/init/steps.ts +45 -0
- package/src/intake/items.ts +14 -0
- package/src/labels/closing.ts +46 -0
- package/src/labels/density.ts +363 -0
- package/src/markdown/gated.ts +250 -0
- package/src/markdown/scope.ts +115 -2
- package/src/migrate/rename.ts +6 -0
- package/src/pr/evidence.ts +1 -1
- package/src/pr/rest.ts +2 -0
- package/src/pr/tick.ts +89 -0
- package/src/sessions/claim.ts +7 -0
- package/src/sessions/live.ts +79 -6
- package/src/sessions/registry.ts +11 -0
- package/src/sessions/resolve.ts +85 -7
- package/src/tasks/archive.ts +15 -4
- package/src/tasks/closes.ts +136 -0
- package/src/tasks/record.ts +1 -1
- package/src/worktrees/reclaim.ts +16 -1
- package/tooling/base/configs/scripts/verify.ts +1 -1
- package/tooling/base/seeds/.github/pull_request_template.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/board-validate.sh +1 -1
- package/tooling/claude/seeds/CLAUDE.md +2 -2
- 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
|
+
}
|