@erclx/canon 5.7.0 → 5.9.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 (77) hide show
  1. package/README.md +4 -2
  2. package/claude/.claude-plugin/plugin.json +2 -2
  3. package/claude/skills/auto-ship/SKILL.md +2 -0
  4. package/claude/skills/draft-and-pick/REQUIREMENT.md +2 -1
  5. package/claude/skills/draft-and-pick/SKILL.md +9 -6
  6. package/claude/skills/git-followup/SKILL.md +1 -1
  7. package/claude/skills/git-pr/references/evidence.md +1 -1
  8. package/claude/skills/git-ship/REQUIREMENT.md +3 -2
  9. package/claude/skills/git-ship/SKILL.md +25 -2
  10. package/claude/skills/review-address/SKILL.md +6 -4
  11. package/claude/skills/review-craft/REQUIREMENT.md +8 -2
  12. package/claude/skills/review-craft/SKILL.md +63 -7
  13. package/claude/skills/review-craft/references/adopted.md +49 -0
  14. package/docs/agents/commands.md +2 -0
  15. package/docs/agents/hooks.md +56 -0
  16. package/docs/agents/index.md +1 -0
  17. package/docs/agents/install-and-sync.md +1 -1
  18. package/docs/agents/markdown-audit.md +1 -1
  19. package/docs/agents/pr-evidence.md +33 -17
  20. package/docs/agents/records-push.md +1 -1
  21. package/docs/agents/records-validate.md +1 -1
  22. package/docs/agents/scripting.md +6 -0
  23. package/docs/agents/tasks-archive.md +4 -2
  24. package/docs/agents/tasks.md +1 -1
  25. package/docs/agents/worktrees.md +2 -2
  26. package/docs/target/scenarios.md +2 -2
  27. package/package.json +2 -2
  28. package/src/claude/skills-audit.ts +1 -0
  29. package/src/cli.ts +2 -0
  30. package/src/commands/claude.ts +1 -1
  31. package/src/commands/docs.ts +51 -11
  32. package/src/commands/hooks.ts +67 -0
  33. package/src/commands/pr.ts +146 -76
  34. package/src/commands/standards.ts +33 -2
  35. package/src/commands/tasks.ts +45 -0
  36. package/src/commands/tooling.ts +3 -3
  37. package/src/commands/upgrade.ts +3 -2
  38. package/src/commands/worktrees.ts +4 -3
  39. package/src/docs/list.ts +103 -0
  40. package/src/docs/read.ts +63 -19
  41. package/src/gov/stacks.ts +1 -1
  42. package/src/help.ts +2 -0
  43. package/src/hooks/post-merge.ts +331 -0
  44. package/src/pr/evidence.ts +21 -0
  45. package/src/records/validate.ts +1 -38
  46. package/src/standards/list.ts +33 -0
  47. package/src/standards/read.ts +13 -0
  48. package/src/standards/scope.ts +56 -0
  49. package/src/sync/check.ts +38 -4
  50. package/src/tasks/archive.ts +168 -3
  51. package/src/tooling/inject.ts +4 -3
  52. package/src/tooling/manifest.ts +62 -8
  53. package/src/tooling/scan.ts +4 -3
  54. package/src/tooling/stamp.ts +11 -2
  55. package/src/tooling/subfolder.ts +7 -3
  56. package/tooling/astro/reference.md +1 -1
  57. package/tooling/base/configs/scripts/verify.json +3 -0
  58. package/tooling/base/configs/scripts/verify.sh +1 -110
  59. package/tooling/base/configs/scripts/verify.ts +378 -0
  60. package/tooling/base/manifest.toml +1 -0
  61. package/tooling/base/reference.md +5 -3
  62. package/tooling/go/configs/scripts/verify.json +10 -0
  63. package/tooling/go/reference.md +5 -4
  64. package/tooling/php/configs/scripts/verify.json +7 -0
  65. package/tooling/php/reference.md +5 -4
  66. package/tooling/python/configs/scripts/verify.json +4 -0
  67. package/tooling/python/reference.md +3 -3
  68. package/tooling/web/configs/scripts/verify.json +3 -0
  69. package/tooling/web/reference.md +3 -3
  70. package/scripts/docs/list.sh +0 -184
  71. package/scripts/lib/frontmatter.sh +0 -30
  72. package/scripts/standards/list.sh +0 -115
  73. package/src/commands/pass-through.ts +0 -39
  74. package/tooling/go/configs/scripts/verify.sh +0 -142
  75. package/tooling/php/configs/scripts/verify.sh +0 -147
  76. package/tooling/python/configs/scripts/verify.sh +0 -141
  77. package/tooling/web/configs/scripts/verify.sh +0 -153
package/src/docs/read.ts CHANGED
@@ -12,10 +12,11 @@ const INDEX_TOPIC = 'index'
12
12
  * The context folder is read at every surface root, so a checkout the surface
13
13
  * move has not reached still resolves its own entries.
14
14
  */
15
- const ROOTS: readonly string[] = [
16
- 'docs',
17
- ...SURFACE_ROOTS.map((root) => join(root, 'context')),
18
- ]
15
+ export const DOCS_ROOT = 'docs'
16
+ export const CONTEXT_ROOTS: readonly string[] = SURFACE_ROOTS.map((root) =>
17
+ join(root, 'context'),
18
+ )
19
+ const ROOTS: readonly string[] = [DOCS_ROOT, ...CONTEXT_ROOTS]
19
20
 
20
21
  export interface ResolvedTopic {
21
22
  readonly path: string
@@ -96,14 +97,22 @@ function catalogedFolders(dir: string): string[] {
96
97
  * `resolveTopic` answers for and nothing else.
97
98
  */
98
99
  export function listTopics(root: string): string[] {
99
- const named = ROOTS.map((dir) => collectNamed(root, dir))
100
- const leaves = ROOTS.map((dir) => collectLeaves(root, dir))
100
+ const walked = ROOTS.map((dir) => walkEntries(root, dir))
101
101
 
102
- const taken = new Set(named.flat())
102
+ const taken = new Set(
103
+ walked.flatMap((entries) =>
104
+ entries.filter((entry) => entry.kind !== 'leaf').map((e) => e.name),
105
+ ),
106
+ )
103
107
  const claimed = new Set<string>()
104
108
 
105
- return ROOTS.flatMap((_, position) => {
106
- const here = leaves[position] as string[]
109
+ return walked.flatMap((entries) => {
110
+ const named = entries
111
+ .filter((entry) => entry.kind !== 'leaf')
112
+ .map((entry) => entry.name)
113
+ const here = entries
114
+ .filter((entry) => entry.kind === 'leaf')
115
+ .map((entry) => entry.name)
107
116
  const reachable = [...new Set(here)].filter(
108
117
  (name) =>
109
118
  !taken.has(name) &&
@@ -112,25 +121,60 @@ export function listTopics(root: string): string[] {
112
121
  )
113
122
  for (const name of here) claimed.add(name)
114
123
 
115
- return [...(named[position] as string[]), ...reachable].sort()
124
+ return [...named, ...reachable].sort()
116
125
  })
117
126
  }
118
127
 
119
- /** The sibling files and split folders of one root, which shadow every leaf. */
120
- function collectNamed(root: string, dir: string): string[] {
121
- const cwd = join(root, dir)
122
- if (!existsSync(cwd)) return []
128
+ /**
129
+ * How an entry is named. A `file` is a sibling `<name>.md`, a `folder` is a
130
+ * split domain named by its folder and carried by its `index.md`, and a `leaf`
131
+ * is a sub-area file one level down, named by its bare filename.
132
+ */
133
+ export type EntryKind = 'file' | 'folder' | 'leaf'
123
134
 
124
- return [...markdownNames(cwd), ...catalogedFolders(cwd)]
135
+ export interface WalkedEntry {
136
+ readonly name: string
137
+ readonly kind: EntryKind
138
+ /** The path under the project root, which is the catalog's `target`. */
139
+ readonly rel: string
125
140
  }
126
141
 
127
- /** One entry per occurrence, so a name carried twice is counted twice. */
128
- function collectLeaves(root: string, dir: string): string[] {
142
+ /**
143
+ * Every entry one root offers, in the three kinds above, with a leaf counted
144
+ * once per occurrence so a name a folder pair both carry stays visible as a
145
+ * collision. A root that is absent offers nothing. This is the one reading of
146
+ * the tree, so `listTopics` and the docs catalog cannot disagree on membership.
147
+ */
148
+ export function walkEntries(root: string, dir: string): WalkedEntry[] {
129
149
  const cwd = join(root, dir)
150
+ if (!existsSync(cwd)) return []
130
151
 
131
- return catalogedFolders(cwd).flatMap((folder) =>
132
- markdownNames(join(cwd, folder)),
152
+ const files = markdownNames(cwd).map(
153
+ (name): WalkedEntry => ({
154
+ name,
155
+ kind: 'file',
156
+ rel: join(dir, `${name}.md`),
157
+ }),
133
158
  )
159
+ const folders = catalogedFolders(cwd)
160
+ const split = folders.map(
161
+ (folder): WalkedEntry => ({
162
+ name: folder,
163
+ kind: 'folder',
164
+ rel: join(dir, folder, `${INDEX_TOPIC}.md`),
165
+ }),
166
+ )
167
+ const leaves = folders.flatMap((folder) =>
168
+ markdownNames(join(cwd, folder)).map(
169
+ (name): WalkedEntry => ({
170
+ name,
171
+ kind: 'leaf',
172
+ rel: join(dir, folder, `${name}.md`),
173
+ }),
174
+ ),
175
+ )
176
+
177
+ return [...files, ...split, ...leaves]
134
178
  }
135
179
 
136
180
  function markdownNames(dir: string): string[] {
package/src/gov/stacks.ts CHANGED
@@ -94,7 +94,7 @@ export function expandStackEntry(root: string, entry: string): string[] {
94
94
  /**
95
95
  * Walks `extends` ancestors first, then the stack's own rules, deduped by
96
96
  * first appearance. Tooling's `resolveChain` returns full manifests nearest
97
- * first and carries `skipStack` truncation, so the two walks stay separate
97
+ * first and carries `skipStack` restriction, so the two walks stay separate
98
98
  * rather than fitting one shape to both.
99
99
  *
100
100
  * Dedupe runs on expanded names rather than on the entries, so a stack naming
package/src/help.ts CHANGED
@@ -56,6 +56,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
56
56
  ['records [cmd]', 'Session records (validate, size, push, pull)'],
57
57
  ['sessions [cmd]', 'Resolve live sessions to worktree and branch'],
58
58
  ['worktrees [cmd]', 'Reclaim worktrees whose branches merged'],
59
+ ['hooks [cmd]', 'Run the steps a git hook drives (post-merge)'],
59
60
  ['autoship [cmd]', 'Decide whether a changed set needs review'],
60
61
  ['pr [cmd]', 'Read a pull request (key-changes, head, checks)'],
61
62
  ['feedback', 'Write toolkit feedback from stdin'],
@@ -122,6 +123,7 @@ const EXAMPLES = [
122
123
  'canon sessions list --json',
123
124
  'canon worktrees list --json',
124
125
  'canon worktrees reclaim --dry-run',
126
+ 'canon hooks post-merge',
125
127
  'canon secrets scan --json',
126
128
  'canon deps audit --json',
127
129
  'canon labels audit --json',
@@ -0,0 +1,331 @@
1
+ import { execa } from 'execa'
2
+ import { gitEnv } from '@/git-env'
3
+
4
+ /**
5
+ * One child `canon` call. Each step runs as its own process rather than
6
+ * in-process, so the plugin update after a reinstall runs the binary the
7
+ * upgrade just installed, and every verb keeps its own gates.
8
+ */
9
+ export interface StepCall {
10
+ readonly args: readonly string[]
11
+ readonly cwd: string
12
+ readonly env: Record<string, string>
13
+ }
14
+
15
+ export interface StepResult {
16
+ readonly stdout: string
17
+ readonly exitCode: number
18
+ }
19
+
20
+ export type StepRunner = (call: StepCall) => Promise<StepResult>
21
+
22
+ export interface PostMergeInput {
23
+ /** The main worktree root, where the board and the records live. */
24
+ readonly root: string
25
+ /** The worktree the pull happened in, which the reclaim refuses to remove. */
26
+ readonly cwd: string
27
+ readonly subjects: readonly string[]
28
+ /** Read for the `CANON_SKIP_*` switches. */
29
+ readonly env: Readonly<Record<string, string | undefined>>
30
+ readonly run: StepRunner
31
+ readonly write: (text: string) => void
32
+ }
33
+
34
+ /**
35
+ * The child verb each step runs, in run order. The command's help lists these
36
+ * and the landing page reads that help to say what a merge does, so the
37
+ * figure follows the steps this module actually runs.
38
+ */
39
+ export const STEP_VERBS = {
40
+ archive: ['tasks', 'archive'],
41
+ push: ['records', 'push'],
42
+ reclaim: ['worktrees', 'reclaim'],
43
+ upgrade: ['upgrade'],
44
+ pluginUpdate: ['claude', 'plugin-update'],
45
+ } as const satisfies Record<string, readonly string[]>
46
+
47
+ type Fields = Record<string, unknown>
48
+
49
+ const GIT_TIMEOUT_MS = 10_000
50
+
51
+ /**
52
+ * Every pull request number the merge brought in. A squash carries `(#NNN)` at
53
+ * the end of its subject, and a release or a merge naming none yields nothing,
54
+ * which is what keeps the archive quiet on runs it has nothing to say about.
55
+ */
56
+ export function pullRequestNumbers(subjects: readonly string[]): number[] {
57
+ return subjects.flatMap((subject) => {
58
+ const match = /\(#(\d+)\)\s*$/.exec(subject)
59
+ return match ? [Number(match[1])] : []
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Every subject the pull brought in, not just the tip. One pull routinely
65
+ * fast-forwards over several merges, and reading HEAD alone would archive the
66
+ * last one's task and strand the rest. `git pull` sets ORIG_HEAD to the
67
+ * pre-merge tip, so the range is exactly what arrived, and a merge leaving no
68
+ * ORIG_HEAD falls back to the tip.
69
+ *
70
+ * A hook exports `GIT_DIR` and friends, which beat `-C`, so every read goes
71
+ * through `gitEnv()` to answer for the checkout `cwd` names.
72
+ */
73
+ export async function readMergedSubjects(cwd: string): Promise<string[]> {
74
+ const options = {
75
+ reject: false,
76
+ timeout: GIT_TIMEOUT_MS,
77
+ env: gitEnv(),
78
+ extendEnv: false,
79
+ } as const
80
+
81
+ const origHead = await execa(
82
+ 'git',
83
+ ['-C', cwd, 'rev-parse', '--verify', '--quiet', 'ORIG_HEAD'],
84
+ options,
85
+ )
86
+ const range = origHead.exitCode === 0 ? ['ORIG_HEAD..HEAD'] : ['-1', 'HEAD']
87
+ const log = await execa(
88
+ 'git',
89
+ ['-C', cwd, 'log', ...range, '--pretty=%s', '--'],
90
+ options,
91
+ )
92
+ if (log.exitCode !== 0) return []
93
+
94
+ return log.stdout.split('\n').filter((line) => line.length > 0)
95
+ }
96
+
97
+ export const runCanon: StepRunner = async (call) => {
98
+ const result = await execa('canon', [...call.args], {
99
+ cwd: call.cwd,
100
+ env: call.env,
101
+ extendEnv: false,
102
+ reject: false,
103
+ })
104
+ return { stdout: result.stdout, exitCode: result.exitCode ?? 1 }
105
+ }
106
+
107
+ /**
108
+ * A child producing no parseable record is an older global binary carrying no
109
+ * such subcommand or flag, which stays quiet until a release lands.
110
+ */
111
+ function parseRecord(stdout: string): Fields | undefined {
112
+ try {
113
+ const parsed: unknown = JSON.parse(stdout.trim())
114
+ return parsed !== null && typeof parsed === 'object'
115
+ ? (parsed as Fields)
116
+ : undefined
117
+ } catch {
118
+ return undefined
119
+ }
120
+ }
121
+
122
+ function text(fields: Fields, key: string): string | undefined {
123
+ const value = fields[key]
124
+ return typeof value === 'string' && value.length > 0 ? value : undefined
125
+ }
126
+
127
+ function count(fields: Fields, key: string): number {
128
+ const value = fields[key]
129
+ return typeof value === 'number' ? value : 0
130
+ }
131
+
132
+ function archiveLines(
133
+ number: number,
134
+ result: StepResult,
135
+ fields: Fields,
136
+ ): string | undefined {
137
+ if (result.exitCode === 0) {
138
+ const task = text(fields, 'task')
139
+ if (task === undefined) return undefined
140
+
141
+ // A binary predating the relink pass carries no `relinkFailed`, which
142
+ // reads as empty.
143
+ const failed = Array.isArray(fields.relinkFailed)
144
+ ? fields.relinkFailed.flatMap((entry: unknown) =>
145
+ entry !== null &&
146
+ typeof entry === 'object' &&
147
+ typeof (entry as Fields).file === 'string'
148
+ ? [(entry as Fields).file as string]
149
+ : [],
150
+ )
151
+ : []
152
+ const relink =
153
+ failed.length > 0
154
+ ? `🔗 Links not repointed in: ${failed.join(',')}\n`
155
+ : ''
156
+ return `\n📦 Archived ${task}, closed by #${number}.\n${relink}\n`
157
+ }
158
+
159
+ // A refusal is ordinary here, since most merges close no task.
160
+ const reason = text(fields, 'reason')
161
+ if (reason === undefined || reason === 'no-match' || reason === 'no-board') {
162
+ return undefined
163
+ }
164
+ return (
165
+ `\n📋 Task not archived for #${number}: ${text(fields, 'message') ?? reason}\n` +
166
+ 'Run /task-board to resolve it by hand.\n\n'
167
+ )
168
+ }
169
+
170
+ function pushLines(result: StepResult, fields: Fields): string | undefined {
171
+ if (result.exitCode === 0) {
172
+ const changed = count(fields, 'changed')
173
+ return changed > 0
174
+ ? `\n🗄️ Backed up ${changed} record path(s).\n\n`
175
+ : undefined
176
+ }
177
+
178
+ // No records history on this machine, which is every checkout that never
179
+ // ran the one-time setup.
180
+ const reason = text(fields, 'reason') ?? 'unknown'
181
+ if (reason === 'no-repository') return undefined
182
+ const hint =
183
+ reason === 'unsafe-payload'
184
+ ? 'Run canon records push to see the blocked paths.'
185
+ : 'Run canon records push when the remote is reachable.'
186
+ return `\n🗄️ Records not backed up: ${reason}\n${hint}\n\n`
187
+ }
188
+
189
+ /**
190
+ * The exit decides nothing here and the fields decide everything. A run that
191
+ * removes one worktree and fails on another exits 1 with a directory already
192
+ * deleted, so a report keyed on the exit would never name the removal.
193
+ */
194
+ function reclaimLines(fields: Fields): string | undefined {
195
+ let lines = ''
196
+ const removed = count(fields, 'removed')
197
+ if (removed > 0) {
198
+ lines += `\n🧹 Reclaimed ${removed} worktree(s) whose pull request merged.\n\n`
199
+ }
200
+
201
+ // `gh-missing` answers on every merge forever on a machine without `gh`, so
202
+ // the hook header carries that gap rather than a line nobody acts on twice.
203
+ const reason = text(fields, 'reason')
204
+ const failed = count(fields, 'failed')
205
+ if (reason !== undefined && reason !== 'gh-missing') {
206
+ lines +=
207
+ `\n🧹 Worktrees not reclaimed: ${reason}\n` +
208
+ 'Run canon worktrees list to read the state by hand.\n\n'
209
+ } else if (failed > 0) {
210
+ lines +=
211
+ `\n🧹 ${failed} worktree(s) could not be removed.\n` +
212
+ 'Run canon worktrees reclaim to read which step failed.\n\n'
213
+ }
214
+ return lines.length > 0 ? lines : undefined
215
+ }
216
+
217
+ /**
218
+ * Shared by the binary reinstall and the plugin update. `current` is the
219
+ * ordinary outcome on most merges and stays quiet, and `quiet` names the
220
+ * refusal reasons that describe a permanent condition on the machine. `icon`
221
+ * carries its own trailing spacing, since the two glyphs render at different
222
+ * widths.
223
+ */
224
+ function moveLines(
225
+ result: StepResult,
226
+ fields: Fields,
227
+ icon: string,
228
+ failure: string,
229
+ quiet: readonly string[],
230
+ ): string | undefined {
231
+ const message = text(fields, 'message')
232
+ if (message === undefined) return undefined
233
+
234
+ if (result.exitCode === 0) {
235
+ return fields.state === 'current' ? undefined : `\n${icon}${message}\n\n`
236
+ }
237
+
238
+ const reason = text(fields, 'reason')
239
+ if (reason !== undefined && quiet.includes(reason)) return undefined
240
+ return `\n${icon}${failure}: ${message}\n\n`
241
+ }
242
+
243
+ async function step(
244
+ input: PostMergeInput,
245
+ call: StepCall,
246
+ render: (result: StepResult, fields: Fields) => string | undefined,
247
+ ): Promise<void> {
248
+ let result: StepResult
249
+ try {
250
+ result = await input.run(call)
251
+ } catch {
252
+ return
253
+ }
254
+
255
+ const fields = parseRecord(result.stdout)
256
+ if (fields === undefined) return
257
+
258
+ const lines = render(result, fields)
259
+ if (lines !== undefined) input.write(lines)
260
+ }
261
+
262
+ /**
263
+ * Runs the five post-merge steps in order: archive, records push, reclaim,
264
+ * upgrade, plugin update. The reclaim sits before the upgrade, since the
265
+ * upgrade reinstalls the binary every later child runs under, and the two
266
+ * network-bound moves sit last so a slow registry delays nothing else.
267
+ *
268
+ * Never throws and reports nothing on the ordinary merge, since nobody is
269
+ * watching and a hook failing aborts nothing useful.
270
+ */
271
+ export async function runPostMerge(input: PostMergeInput): Promise<void> {
272
+ const env = gitEnv()
273
+ const at = (args: string[], extra: Record<string, string> = {}) => ({
274
+ args,
275
+ cwd: input.cwd,
276
+ env: { ...env, ...extra },
277
+ })
278
+ const isSkipped = (name: string) => (input.env[name] ?? '').length > 0
279
+
280
+ for (const number of pullRequestNumbers(input.subjects)) {
281
+ await step(
282
+ input,
283
+ at([
284
+ ...STEP_VERBS.archive,
285
+ '--pull-request',
286
+ String(number),
287
+ '--root',
288
+ input.root,
289
+ '--json',
290
+ ]),
291
+ (result, fields) => archiveLines(number, result, fields),
292
+ )
293
+ }
294
+
295
+ await step(
296
+ input,
297
+ at([...STEP_VERBS.push, '--root', input.root, '--json']),
298
+ pushLines,
299
+ )
300
+
301
+ // No `--root` here, and that is load-bearing. The verb reads its own working
302
+ // directory to refuse the worktree it stands in, so a root would turn the
303
+ // running worktree into an ordinary candidate and let a pull inside a
304
+ // linked worktree delete the ground under itself.
305
+ if (!isSkipped('CANON_SKIP_RECLAIM')) {
306
+ await step(input, at([...STEP_VERBS.reclaim, '--json']), (_, fields) =>
307
+ reclaimLines(fields),
308
+ )
309
+ }
310
+
311
+ if (!isSkipped('CANON_SKIP_UPGRADE')) {
312
+ await step(
313
+ input,
314
+ at([...STEP_VERBS.upgrade, '--json'], { CANON_NON_INTERACTIVE: '1' }),
315
+ (result, fields) =>
316
+ moveLines(result, fields, '⬆️ ', 'CLI not upgraded', []),
317
+ )
318
+ }
319
+
320
+ if (!isSkipped('CANON_SKIP_PLUGIN_UPDATE')) {
321
+ await step(
322
+ input,
323
+ at([...STEP_VERBS.pluginUpdate, '--json']),
324
+ (result, fields) =>
325
+ moveLines(result, fields, '🔌 ', 'Plugin not updated', [
326
+ 'no-claude',
327
+ 'no-plugin',
328
+ ]),
329
+ )
330
+ }
331
+ }
@@ -189,6 +189,19 @@ export function renderEvidenceBody(
189
189
  ].join('\n')
190
190
  }
191
191
 
192
+ /**
193
+ * How many case rows a rendered body carries, read off the same row shape
194
+ * `renderEvidenceBody` writes. A row always ends in a head image, which a
195
+ * checklist line or a preview address never does.
196
+ */
197
+ export function countEvidenceCases(body: string): number {
198
+ return (
199
+ body.match(
200
+ /^\| .+ \| (?:\*\(new\)\*|!\[\]\([^)]*\)) \| !\[\]\([^)]*\) \|$/gm,
201
+ )?.length ?? 0
202
+ )
203
+ }
204
+
192
205
  export interface EvidenceComment {
193
206
  readonly url?: string
194
207
  readonly body: string
@@ -219,6 +232,14 @@ export function findEvidenceCommentId(
219
232
  return undefined
220
233
  }
221
234
 
235
+ /** How many cases the marked comment already shows, zero when none is marked. */
236
+ export function findEvidenceCaseCount(
237
+ comments: readonly EvidenceComment[],
238
+ ): number {
239
+ const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
240
+ return marked === undefined ? 0 : countEvidenceCases(marked.body)
241
+ }
242
+
222
243
  /**
223
244
  * The preview address the marked comment already opens with. A re-render
224
245
  * after a later push passes no address of its own, and carrying this one
@@ -7,6 +7,7 @@ import {
7
7
  recordDir as resolveRecordDir,
8
8
  recordDirs as resolveRecordDirs,
9
9
  } from '@/record-root'
10
+ import { ATTRIBUTE_MARKER, governedPaths, readScope } from '@/standards/scope'
10
11
  import {
11
12
  TEACH_GLOSSARY,
12
13
  TEACH_MISSION,
@@ -1044,45 +1045,7 @@ function checkMemoryBody(
1044
1045
  const STANDARD_INDEX = 'index.md'
1045
1046
  const STANDARD_FIELDS = ['title', 'description'] as const
1046
1047
 
1047
- const SCOPE_HEADING = /^##[ \t]+Scope[ \t]*$/
1048
- const ANY_HEADING = /^#{1,6}[ \t]+\S/
1049
1048
  const DOES_NOT_GOVERN = 'Does not govern:'
1050
- const ATTRIBUTE_MARKER = 'attribute standard'
1051
- const CODE_SPAN = /`([^`]+)`/g
1052
-
1053
- interface Scope {
1054
- /** The first non-blank line under the heading, which is the statement. */
1055
- readonly statement: string
1056
- readonly lines: readonly string[]
1057
- }
1058
-
1059
- export function readScope(text: string): Scope | undefined {
1060
- const lines = linesOutsideFences(text)
1061
- const opened = lines.findIndex((line) => SCOPE_HEADING.test(line.trim()))
1062
- if (opened === -1) return undefined
1063
-
1064
- const body: string[] = []
1065
-
1066
- for (const line of lines.slice(opened + 1)) {
1067
- if (ANY_HEADING.test(line.trim())) break
1068
- body.push(line)
1069
- }
1070
-
1071
- const statement = body.find((line) => line.trim().length > 0)
1072
-
1073
- return { statement: statement?.trim() ?? '', lines: body }
1074
- }
1075
-
1076
- /**
1077
- * The paths a scope statement declares, read the way `scripts/standards/list.sh`
1078
- * reads them for the catalog's `appliesTo` field: backticked spans in the first
1079
- * sentence alone. One sentence read two ways would let a standard pass here
1080
- * while publishing a different jurisdiction to every consumer of the catalog.
1081
- */
1082
- export function governedPaths(statement: string): string[] {
1083
- const [sentence] = statement.split('. ')
1084
- return [...sentence.matchAll(CODE_SPAN)].map((match) => match[1])
1085
- }
1086
1049
 
1087
1050
  /**
1088
1051
  * The words a governed path offers a filename. Each segment gives its own word
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
3
+ import { resolveAllStandards } from '@/standards/read'
4
+ import { appliesTo } from '@/standards/scope'
5
+
6
+ export interface StandardEntry {
7
+ readonly name: string
8
+ readonly description: string
9
+ readonly appliesTo: string[]
10
+ /** The whole file, frontmatter included. */
11
+ readonly content: string
12
+ /** The root-labeled path `canon standards <name>` would read. */
13
+ readonly source: string
14
+ }
15
+
16
+ /**
17
+ * Lists every standard a resolve reaches, the first root winning, so a project
18
+ * standard shows beside the toolkit's and a local override of a package name
19
+ * shows once, as the local copy.
20
+ */
21
+ export function listStandardEntries(root: string): StandardEntry[] {
22
+ return resolveAllStandards(root).map(({ name, path, source }) => {
23
+ const content = readFileSync(path, 'utf8')
24
+
25
+ return {
26
+ name,
27
+ description: readField(parseFrontmatter(content), 'description') ?? '',
28
+ appliesTo: appliesTo(content),
29
+ content,
30
+ source,
31
+ }
32
+ })
33
+ }
@@ -95,6 +95,19 @@ export function listStandards(root: string): string[] {
95
95
  return [...names].sort()
96
96
  }
97
97
 
98
+ /**
99
+ * Pairs each listed name with the copy a resolve would read, so a catalog shows
100
+ * what `canon standards <name>` prints rather than every file a root holds.
101
+ */
102
+ export function resolveAllStandards(
103
+ root: string,
104
+ ): Array<ResolvedStandard & { readonly name: string }> {
105
+ return listStandards(root).flatMap((name) => {
106
+ const resolved = resolveStandard(root, name)
107
+ return resolved ? [{ name, ...resolved }] : []
108
+ })
109
+ }
110
+
98
111
  export function readStandard(standard: ResolvedStandard): string {
99
112
  return stripFrontmatter(readFileSync(standard.path, 'utf8'))
100
113
  }
@@ -0,0 +1,56 @@
1
+ import { linesOutsideFences } from '@/markdown/scan'
2
+
3
+ const SCOPE_HEADING = /^##[ \t]+Scope[ \t]*$/
4
+ const ANY_HEADING = /^#{1,6}[ \t]+\S/
5
+ const CODE_SPAN = /`([^`]+)`/g
6
+
7
+ /** What an attribute standard says of itself, since it names no path. */
8
+ export const ATTRIBUTE_MARKER = 'attribute standard'
9
+
10
+ export interface Scope {
11
+ /** The first non-blank line under the heading, which is the statement. */
12
+ readonly statement: string
13
+ readonly lines: readonly string[]
14
+ }
15
+
16
+ export function readScope(text: string): Scope | undefined {
17
+ const lines = linesOutsideFences(text)
18
+ const opened = lines.findIndex((line) => SCOPE_HEADING.test(line.trim()))
19
+ if (opened === -1) return undefined
20
+
21
+ const body: string[] = []
22
+
23
+ for (const line of lines.slice(opened + 1)) {
24
+ if (ANY_HEADING.test(line.trim())) break
25
+ body.push(line)
26
+ }
27
+
28
+ const statement = body.find((line) => line.trim().length > 0)
29
+
30
+ return { statement: statement?.trim() ?? '', lines: body }
31
+ }
32
+
33
+ /**
34
+ * The paths a scope statement declares: backticked spans in the first sentence
35
+ * alone. The catalog's `appliesTo` field and the records gate both read the
36
+ * statement through this, since one sentence read two ways would let a standard
37
+ * pass the gate while publishing a different jurisdiction to every consumer.
38
+ */
39
+ export function governedPaths(statement: string): string[] {
40
+ const [sentence] = statement.split('. ')
41
+ return [...sentence.matchAll(CODE_SPAN)].map((match) => match[1])
42
+ }
43
+
44
+ /**
45
+ * The jurisdiction a standard publishes. The marker is read only where the
46
+ * first sentence backticks nothing, so a statement naming a path publishes that
47
+ * path however the rest of it describes itself. An empty array means the
48
+ * statement did not parse, which consumers report rather than skip.
49
+ */
50
+ export function appliesTo(text: string): string[] {
51
+ const statement = readScope(text)?.statement ?? ''
52
+ const paths = governedPaths(statement)
53
+
54
+ if (paths.length === 0 && statement.includes(ATTRIBUTE_MARKER)) return ['*']
55
+ return paths
56
+ }