@erclx/canon 5.45.0 → 5.46.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 (137) 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-issue/REQUIREMENT.md +9 -15
  41. package/claude/skills/git-pr/REQUIREMENT.md +13 -32
  42. package/claude/skills/git-pr/SKILL.md +10 -2
  43. package/claude/skills/git-pr/references/closing-issues.md +47 -0
  44. package/claude/skills/git-pr/references/pr.md +4 -0
  45. package/claude/skills/git-ship/REQUIREMENT.md +13 -26
  46. package/claude/skills/git-split/REQUIREMENT.md +10 -17
  47. package/claude/skills/git-stage/REQUIREMENT.md +12 -18
  48. package/claude/skills/git-worktree/REQUIREMENT.md +15 -19
  49. package/claude/skills/make-deck/REQUIREMENT.md +13 -21
  50. package/claude/skills/markdown-craft/REQUIREMENT.md +9 -3
  51. package/claude/skills/plan-feature/REQUIREMENT.md +15 -24
  52. package/claude/skills/plan-groundwork/REQUIREMENT.md +12 -28
  53. package/claude/skills/plan-groundwork/SKILL.md +5 -2
  54. package/claude/skills/plan-groundwork/references/output.md +47 -1
  55. package/claude/skills/plan-intake/REQUIREMENT.md +11 -26
  56. package/claude/skills/plan-intake/references/intake.md +2 -2
  57. package/claude/skills/plan-intake-answer/REQUIREMENT.md +14 -29
  58. package/claude/skills/plan-intake-answer/SKILL.md +7 -1
  59. package/claude/skills/plan-ready/REQUIREMENT.md +17 -36
  60. package/claude/skills/pr-evidence/REQUIREMENT.md +11 -17
  61. package/claude/skills/read-frames/REQUIREMENT.md +3 -8
  62. package/claude/skills/record-screencast/REQUIREMENT.md +14 -17
  63. package/claude/skills/review-address/REQUIREMENT.md +14 -38
  64. package/claude/skills/review-craft/REQUIREMENT.md +14 -32
  65. package/claude/skills/review-pr/REQUIREMENT.md +15 -34
  66. package/claude/skills/review-ui/REQUIREMENT.md +14 -39
  67. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
  68. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +5 -0
  69. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +9 -0
  70. package/claude/skills/role-reviewer/REQUIREMENT.md +15 -65
  71. package/claude/skills/role-reviewer/SKILL.md +5 -1
  72. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  73. package/claude/skills/role-worker/SKILL.md +2 -0
  74. package/claude/skills/script-screencast/REQUIREMENT.md +10 -16
  75. package/claude/skills/search-craft/REQUIREMENT.md +12 -36
  76. package/claude/skills/search-craft/SKILL.md +1 -1
  77. package/claude/skills/search-craft/references/adopted.md +2 -2
  78. package/claude/skills/session-compact/REQUIREMENT.md +13 -23
  79. package/claude/skills/session-map/REQUIREMENT.md +13 -29
  80. package/claude/skills/session-relay/REQUIREMENT.md +14 -58
  81. package/claude/skills/session-relay/SKILL.md +6 -0
  82. package/claude/skills/session-resume/REQUIREMENT.md +13 -27
  83. package/claude/skills/session-worktree/REQUIREMENT.md +14 -43
  84. package/claude/skills/systematic-debugging/REQUIREMENT.md +13 -34
  85. package/claude/skills/target-check/REQUIREMENT.md +16 -23
  86. package/claude/skills/target-setup/REQUIREMENT.md +11 -34
  87. package/claude/skills/task-board/REQUIREMENT.md +13 -22
  88. package/claude/skills/task-board/SKILL.md +7 -0
  89. package/claude/skills/task-board/references/tasks.md +1 -1
  90. package/claude/skills/task-complete/REQUIREMENT.md +11 -24
  91. package/claude/skills/task-complete/references/archive.md +1 -0
  92. package/claude/skills/teach-workspace/REQUIREMENT.md +14 -45
  93. package/claude/skills/test-craft/REQUIREMENT.md +15 -46
  94. package/claude/skills/test-first/REQUIREMENT.md +14 -20
  95. package/claude/skills/ui-checklist/REQUIREMENT.md +14 -29
  96. package/claude/skills/ux-audit/REQUIREMENT.md +3 -7
  97. package/claude/skills/ux-measure/REQUIREMENT.md +14 -28
  98. package/claude/skills/video-craft/REQUIREMENT.md +14 -27
  99. package/claude/skills/write-doc/REQUIREMENT.md +14 -24
  100. package/claude/skills/write-human/REQUIREMENT.md +15 -27
  101. package/claude/skills/youtube-transcripts/REQUIREMENT.md +9 -15
  102. package/docs/agents/context-audit.md +19 -12
  103. package/docs/agents/markdown-audit.md +15 -10
  104. package/docs/target/projects.md +2 -0
  105. package/docs/workflow/skill-map.md +6 -5
  106. package/governance/renames.toml +0 -2
  107. package/governance/rules/claude/output.md +5 -1
  108. package/governance/rules/code/routing.md +5 -1
  109. package/governance/stacks/base.toml +0 -1
  110. package/package.json +1 -1
  111. package/src/claude/cases/workflow.ts +12 -0
  112. package/src/commands/context.ts +57 -3
  113. package/src/commands/labels.ts +83 -3
  114. package/src/commands/markdown.ts +188 -21
  115. package/src/commands/sessions.ts +36 -3
  116. package/src/commands/tasks.ts +180 -0
  117. package/src/commands/worktrees.ts +2 -0
  118. package/src/context/coverage.ts +210 -0
  119. package/src/context/gate.ts +12 -3
  120. package/src/gate/stages.ts +1 -1
  121. package/src/init/steps.ts +45 -0
  122. package/src/intake/items.ts +14 -0
  123. package/src/labels/closing.ts +46 -0
  124. package/src/markdown/gated.ts +250 -0
  125. package/src/markdown/scope.ts +115 -2
  126. package/src/migrate/rename.ts +6 -0
  127. package/src/sessions/claim.ts +7 -0
  128. package/src/sessions/live.ts +53 -6
  129. package/src/sessions/registry.ts +11 -0
  130. package/src/sessions/resolve.ts +32 -5
  131. package/src/tasks/closes.ts +136 -0
  132. package/src/tasks/record.ts +1 -1
  133. package/src/worktrees/reclaim.ts +16 -1
  134. package/tooling/base/configs/scripts/verify.ts +1 -1
  135. package/tooling/claude/seeds/.claude/hooks/board-validate.sh +1 -1
  136. package/tooling/claude/seeds/CLAUDE.md +2 -2
  137. package/governance/rules/canon/task-scope.md +0 -12
@@ -0,0 +1,210 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import type { AuditedFolder } from '@/context/folders'
4
+
5
+ /** Where a project declares the code roots its context entries must cover. */
6
+ export const CONTEXT_CONFIG_REL = join('canon', 'config', 'context.toml')
7
+
8
+ /** The root assumed when a project declares none and carries the folder. */
9
+ export const DEFAULT_ROOT = 'src'
10
+
11
+ /**
12
+ * The roots a project asked the coverage check to measure.
13
+ *
14
+ * `none` says the project carries no declaration and no `src/`, which reports
15
+ * and never gates so a seed tree audited at its own root keeps passing.
16
+ * `opted-out` is an explicit `roots = []`. `invalid` is a declaration that
17
+ * exists and cannot be used, which gates rather than falling back to `src`,
18
+ * since a project that wrote one meant it.
19
+ */
20
+ export type RootDeclaration =
21
+ | { readonly kind: 'roots'; readonly patterns: readonly string[] }
22
+ | { readonly kind: 'opted-out' }
23
+ | { readonly kind: 'none' }
24
+ | { readonly kind: 'invalid'; readonly reason: string }
25
+
26
+ export interface DoubleClaim {
27
+ readonly folder: string
28
+ readonly rels: readonly string[]
29
+ }
30
+
31
+ export interface CoverageFinding {
32
+ /** Folders under a declared root that no entry's Layout names. */
33
+ readonly unnamed: readonly string[]
34
+ /** Folders taking a Layout H3 in more than one entry. */
35
+ readonly doubled: readonly DoubleClaim[]
36
+ /** Declared roots or globs that matched no folder on disk. */
37
+ readonly missingRoots: readonly string[]
38
+ }
39
+
40
+ export type CoverageReport =
41
+ | {
42
+ readonly kind: 'measured'
43
+ readonly roots: readonly string[]
44
+ readonly findings: CoverageFinding
45
+ }
46
+ | { readonly kind: 'opted-out' }
47
+ | { readonly kind: 'no-root' }
48
+ | { readonly kind: 'invalid'; readonly reason: string }
49
+
50
+ /** Reads the declaration, falling back to `src` when that folder exists. */
51
+ export function readRoots(root: string): RootDeclaration {
52
+ const path = join(root, CONTEXT_CONFIG_REL)
53
+
54
+ if (existsSync(path)) {
55
+ let parsed: Record<string, unknown>
56
+ try {
57
+ parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
58
+ string,
59
+ unknown
60
+ >
61
+ } catch {
62
+ return {
63
+ kind: 'invalid',
64
+ reason: `${CONTEXT_CONFIG_REL} is not valid TOML`,
65
+ }
66
+ }
67
+
68
+ const declared = parsed.roots
69
+ if (
70
+ !Array.isArray(declared) ||
71
+ !declared.every((entry) => typeof entry === 'string' && entry !== '')
72
+ ) {
73
+ return {
74
+ kind: 'invalid',
75
+ reason: `${CONTEXT_CONFIG_REL} needs roots as a list of folder or glob strings`,
76
+ }
77
+ }
78
+
79
+ return declared.length === 0
80
+ ? { kind: 'opted-out' }
81
+ : { kind: 'roots', patterns: declared as string[] }
82
+ }
83
+
84
+ return isDirectory(join(root, DEFAULT_ROOT))
85
+ ? { kind: 'roots', patterns: [DEFAULT_ROOT] }
86
+ : { kind: 'none' }
87
+ }
88
+
89
+ function isDirectory(path: string): boolean {
90
+ try {
91
+ return statSync(path).isDirectory()
92
+ } catch {
93
+ return false
94
+ }
95
+ }
96
+
97
+ /** Expands each pattern to the folders it matches, relative to `root`. */
98
+ function expandRoots(root: string, patterns: readonly string[]) {
99
+ const roots: string[] = []
100
+ const missing: string[] = []
101
+
102
+ for (const pattern of patterns) {
103
+ const matched = [
104
+ ...new Bun.Glob(pattern).scanSync({ cwd: root, onlyFiles: false }),
105
+ ]
106
+ .map((path) => path.replace(/\/$/, ''))
107
+ .filter((path) => isDirectory(join(root, path)))
108
+ .sort()
109
+
110
+ if (matched.length === 0) missing.push(pattern)
111
+ roots.push(...matched)
112
+ }
113
+
114
+ return { roots: [...new Set(roots)], missing }
115
+ }
116
+
117
+ /** Lists folders directly under a root, skipping hidden ones and `node_modules`. */
118
+ function listFolders(root: string, rel: string): string[] {
119
+ return readdirSync(join(root, rel), { withFileTypes: true })
120
+ .filter(
121
+ (entry) =>
122
+ entry.isDirectory() &&
123
+ !entry.name.startsWith('.') &&
124
+ entry.name !== 'node_modules',
125
+ )
126
+ .map((entry) => `${rel}/${entry.name}`)
127
+ .sort()
128
+ }
129
+
130
+ function layoutSection(source: string): string {
131
+ const lines = source.split('\n')
132
+ const start = lines.findIndex((line) => /^##\s+Layout\s*$/.test(line))
133
+ if (start === -1) return ''
134
+
135
+ const end = lines.findIndex(
136
+ (line, index) => index > start && /^##\s/.test(line),
137
+ )
138
+ return lines.slice(start + 1, end === -1 ? undefined : end).join('\n')
139
+ }
140
+
141
+ function backtickedPaths(text: string): string[] {
142
+ return [...text.matchAll(/`([^`\s]+)`/g)].map((match) =>
143
+ match[1].replace(/\/$/, ''),
144
+ )
145
+ }
146
+
147
+ /**
148
+ * Measures the folders under the declared roots against the Layout sections.
149
+ *
150
+ * A folder is named when any Layout text cites it in backticks, a bullet
151
+ * included, and claimed when it is an H3 title. Only the H3 counts toward
152
+ * uniqueness, since a bullet legitimately cites another domain's folder and a
153
+ * grep cannot tell a citation from a claim.
154
+ */
155
+ export function measureCoverage(
156
+ root: string,
157
+ declaration: RootDeclaration,
158
+ folders: readonly AuditedFolder[],
159
+ ): CoverageReport {
160
+ if (declaration.kind === 'opted-out') return { kind: 'opted-out' }
161
+ if (declaration.kind === 'none') return { kind: 'no-root' }
162
+ if (declaration.kind === 'invalid') {
163
+ return { kind: 'invalid', reason: declaration.reason }
164
+ }
165
+
166
+ const named = new Set<string>()
167
+ const claims = new Map<string, Set<string>>()
168
+
169
+ for (const folder of folders) {
170
+ for (const entry of folder.entries) {
171
+ const rel = relative(root, entry)
172
+ const layout = layoutSection(readFileSync(entry, 'utf8'))
173
+
174
+ for (const path of backtickedPaths(layout)) named.add(path)
175
+
176
+ for (const line of layout.split('\n')) {
177
+ const heading = line.match(/^###\s+`([^`\s]+)`\s*$/)
178
+ if (!heading) continue
179
+ const path = heading[1].replace(/\/$/, '')
180
+ claims.set(path, (claims.get(path) ?? new Set()).add(rel))
181
+ }
182
+ }
183
+ }
184
+
185
+ const { roots, missing } = expandRoots(root, declaration.patterns)
186
+ const onDisk = roots.flatMap((rel) => listFolders(root, rel))
187
+
188
+ return {
189
+ kind: 'measured',
190
+ roots,
191
+ findings: {
192
+ unnamed: onDisk.filter((path) => !named.has(path)),
193
+ doubled: onDisk
194
+ .filter((path) => (claims.get(path)?.size ?? 0) > 1)
195
+ .map((folder) => ({
196
+ folder,
197
+ rels: [...(claims.get(folder) ?? [])].sort(),
198
+ })),
199
+ missingRoots: missing,
200
+ },
201
+ }
202
+ }
203
+
204
+ /** Whether the report holds a finding that should fail a widened gate. */
205
+ export function hasCoverageFindings(report: CoverageReport): boolean {
206
+ if (report.kind === 'invalid') return true
207
+ if (report.kind !== 'measured') return false
208
+ const { unnamed, doubled, missingRoots } = report.findings
209
+ return unnamed.length > 0 || doubled.length > 0 || missingRoots.length > 0
210
+ }
@@ -3,6 +3,7 @@ import type {
3
3
  SectionFinding,
4
4
  ShapeFinding,
5
5
  } from '@/context/audit'
6
+ import { type CoverageReport, hasCoverageFindings } from '@/context/coverage'
6
7
  import type { FolderDrift } from '@/context/index-drift'
7
8
 
8
9
  export interface GateInput {
@@ -25,6 +26,12 @@ export interface GateInput {
25
26
  */
26
27
  readonly shape: readonly ShapeFinding[]
27
28
  readonly drift: readonly FolderDrift[]
29
+ /**
30
+ * Folders under the declared code roots that no Layout names or two entries
31
+ * claim, gating under `--gate` alone. A project with no code root reads as
32
+ * not measured and never gates.
33
+ */
34
+ readonly coverage: CoverageReport
28
35
  /**
29
36
  * Whether the caller asked for the widened gate. False leaves a missing
30
37
  * section and a drifted index advisory, which is what the project-root stage
@@ -48,8 +55,8 @@ export function hasDrift(drift: readonly FolderDrift[]): boolean {
48
55
  * for itself, which makes it a fact rather than a threshold a reader weighs.
49
56
  * The findings `--gate` adds are the ones answerable from the file itself: a
50
57
  * required section it does not declare, an H2 outside the closed set, a shape
51
- * break in a folder rebuilt to the shape, and an index disagreeing with its
52
- * folder. Entry length, depth, bullet, table, provenance, and the record's
58
+ * break in a folder rebuilt to the shape, an index disagreeing with its
59
+ * folder, and a code folder no entry names or two entries claim. Entry length, depth, bullet, table, provenance, and the record's
53
60
  * claim coverage are judgments, so they stay out under both modes.
54
61
  */
55
62
  export function isGating({
@@ -59,6 +66,7 @@ export function isGating({
59
66
  extraSections,
60
67
  shape,
61
68
  drift,
69
+ coverage,
62
70
  widened,
63
71
  }: GateInput): boolean {
64
72
  if (unresolvedCitations > 0) return true
@@ -69,6 +77,7 @@ export function isGating({
69
77
  sections.length > 0 ||
70
78
  extraSections.length > 0 ||
71
79
  shape.some((finding) => finding.gated) ||
72
- hasDrift(drift)
80
+ hasDrift(drift) ||
81
+ hasCoverageFindings(coverage)
73
82
  )
74
83
  }
@@ -334,7 +334,7 @@ export const STAGES: readonly Stage[] = [
334
334
  kind: 'cli',
335
335
  argv: ['context', 'audit', '--gate'],
336
336
  failure:
337
- 'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout, and Gotchas, an entry under a shape-gated folder breaks the section shape, or an index disagrees with its folder. Run bun src/cli.ts context audit --gate.',
337
+ 'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout, and Gotchas, an entry under a shape-gated folder breaks the section shape, an index disagrees with its folder, or a code folder is named in no Layout or claimed by two entries. Run bun src/cli.ts context audit --gate.',
338
338
  },
339
339
  ],
340
340
  success: 'Context citations resolve',
package/src/init/steps.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { logInfo, logWarn, plural } from '@/cli/ui'
1
2
  import { type InitFlags, resolveStack } from '@/init/plan'
2
3
  import type { DomainStep } from '@/init/run'
4
+ import { GATED_CONFIG_REL, gateScopeRoots } from '@/markdown/gated'
3
5
 
4
6
  /** Builds the child-process invocation for one domain. */
5
7
  export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
@@ -55,6 +57,16 @@ export function buildSteps(
55
57
  })
56
58
  }
57
59
 
60
+ // In-process rather than a child verb, since the audit reads the working
61
+ // directory and every child here is handed the target as an argument. It
62
+ // runs after every seeding domain so the roots it measures hold what they
63
+ // will ship with.
64
+ steps.push({
65
+ kind: 'run',
66
+ label: 'Markdown gating',
67
+ run: () => gateMarkdown(resolved),
68
+ })
69
+
58
70
  if (!flags.skip.skipped.has('records')) {
59
71
  // `skip` here means there is nothing to run non-interactively, not that
60
72
  // the caller opted out, unlike every other push of this kind above.
@@ -68,6 +80,39 @@ export function buildSteps(
68
80
  return steps
69
81
  }
70
82
 
83
+ /**
84
+ * Starts a new project with every density root gated that passes now. A root
85
+ * with a finding is named and left out, since gating it would fail the first
86
+ * push on prose a scaffolder wrote.
87
+ */
88
+ async function gateMarkdown(root: string): Promise<boolean> {
89
+ const result = await gateScopeRoots(root)
90
+
91
+ if (result.kind === 'exists') {
92
+ logInfo(`${GATED_CONFIG_REL} already exists, so its list is kept.`)
93
+ return true
94
+ }
95
+
96
+ // A scaffold such as `npm create vite` makes no repository, and nothing else
97
+ // in init needs one, so this is a skip rather than a failed domain.
98
+ if (result.kind === 'unavailable') {
99
+ logWarn(
100
+ "Skipped: not a git repository, so no root was measured or gated. Run 'canon markdown gated add <prefix>' once it is one.",
101
+ )
102
+ return true
103
+ }
104
+
105
+ logInfo(
106
+ `Gated ${result.gated.length === 0 ? 'nothing' : result.gated.join(', ')} in ${GATED_CONFIG_REL}.`,
107
+ )
108
+ for (const { prefix, findings } of result.left) {
109
+ logWarn(
110
+ `Left ${prefix} ungated with ${plural(findings, 'finding')}. Repair it, then run 'canon markdown gated add ${prefix}'.`,
111
+ )
112
+ }
113
+ return true
114
+ }
115
+
71
116
  /**
72
117
  * Builds the `gov install` argv. The run and the recovery command a skip prints
73
118
  * come from here both, so the command a caller is told to paste installs what
@@ -44,6 +44,10 @@ export interface IntakeItem {
44
44
  readonly answerLine: number | undefined
45
45
  /** Text in the slot, absent when the slot is empty and the item is unread. */
46
46
  readonly answer: string | undefined
47
+ /** What is wrong today, absent when the item carries no `Problem:` line. */
48
+ readonly problem: string | undefined
49
+ /** The one change proposed, absent when the item carries no `Fix:` line. */
50
+ readonly fix: string | undefined
47
51
  readonly open: string | undefined
48
52
  readonly suggested: string | undefined
49
53
  readonly worth: string | undefined
@@ -55,6 +59,8 @@ interface Draft {
55
59
  line: number
56
60
  answerLine: number | undefined
57
61
  answer: string | undefined
62
+ problem: string | undefined
63
+ fix: string | undefined
58
64
  open: string | undefined
59
65
  suggested: string | undefined
60
66
  worth: string | undefined
@@ -89,6 +95,8 @@ export function readItems(text: string): IntakeItem[] {
89
95
  line: line.number,
90
96
  answerLine: undefined,
91
97
  answer: undefined,
98
+ problem: undefined,
99
+ fix: undefined,
92
100
  open: undefined,
93
101
  suggested: undefined,
94
102
  worth: undefined,
@@ -114,6 +122,12 @@ export function readItems(text: string): IntakeItem[] {
114
122
  draft.answerLine = line.number
115
123
  draft.answer = value === '' ? undefined : value
116
124
  break
125
+ case 'problem':
126
+ draft.problem = value
127
+ break
128
+ case 'fix':
129
+ draft.fix = value
130
+ break
117
131
  case 'open':
118
132
  draft.open = value
119
133
  break
@@ -0,0 +1,46 @@
1
+ import { linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
2
+
3
+ /**
4
+ * The keywords GitHub reads as closing an issue on merge, each with its
5
+ * tenses. The keyword must start a word, so `disclosed #12` closes nothing,
6
+ * and GitHub reads only the one number that follows it, so `Closes #12, #13`
7
+ * closes #12 alone.
8
+ */
9
+ const CLOSING =
10
+ /(?<!\w)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?[ \t]+#(\d+)/gi
11
+
12
+ export interface ClosingCheck {
13
+ /** Expected issues the body carries no closing reference for. */
14
+ readonly missing: readonly number[]
15
+ /** Issues the body closes that the expectation did not name. */
16
+ readonly stray: readonly number[]
17
+ }
18
+
19
+ /**
20
+ * Reads the issue numbers a body closes, in order and deduped. A code span and
21
+ * a fence quote a keyword rather than use it, and GitHub ignores both.
22
+ */
23
+ export function readClosingReferences(body: string): readonly number[] {
24
+ const numbers = new Set<number>()
25
+
26
+ for (const line of linesOutsideFences(body)) {
27
+ for (const match of maskCodeSpans(line).matchAll(CLOSING)) {
28
+ numbers.add(Number(match[1]))
29
+ }
30
+ }
31
+
32
+ return [...numbers]
33
+ }
34
+
35
+ /** Compares the issues a body closes to the issues it should. */
36
+ export function checkClosing(
37
+ body: string,
38
+ expected: readonly number[],
39
+ ): ClosingCheck {
40
+ const found = readClosingReferences(body)
41
+
42
+ return {
43
+ missing: expected.filter((number) => !found.includes(number)),
44
+ stray: found.filter((number) => !expected.includes(number)),
45
+ }
46
+ }
@@ -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
+ }