@erclx/canon 4.15.1 → 4.17.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.15.1",
4
+ "version": "4.17.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -68,7 +68,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
68
68
  | `canon secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
69
69
  | `canon deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
70
70
  | `canon labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
71
- | `canon labels scan` | Fail a pull request whose title or body carries a phase label, sorting a release pull request's tokens as semver rather than as a leak (`--event`, `--json`) |
71
+ | `canon labels scan` | Fail a pull request whose title or body names the board, by a phase label, a label a code span quotes, or a gitignored record path (`--event`, `--json`) |
72
72
  | `canon autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
73
73
  | `canon pr key-changes` | Compare the files a pull request body's Key Changes names against its own diff, in both directions (`--body`, `--base`, `--json`) |
74
74
  | `canon repo metadata propose` | Compare a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing (`--root`, `--json`) |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.15.1",
4
+ "version": "4.17.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -90,7 +90,7 @@ export function register(program: Command): void {
90
90
  labels
91
91
  .command('scan')
92
92
  .description(
93
- 'Fail a pull request whose title or body carries a phase label',
93
+ 'Fail a pull request whose title or body carries a phase label or a board identifier',
94
94
  )
95
95
  .helpOption('-h, --help', 'Show this help message')
96
96
  .option(
@@ -112,10 +112,16 @@ export function register(program: Command): void {
112
112
  'other pull request may carry neither, so any token found there is a',
113
113
  'leaked phase label.',
114
114
  '',
115
+ 'It reports a board identifier beside that, being text naming the task',
116
+ 'board rather than the change: a version token a code span quotes, and a',
117
+ 'path under a record root, both of which a reader on the remote holds no',
118
+ 'copy of. A path under a tracked folder is left alone, so a rule or a',
119
+ 'skill any clone resolves is not reported.',
120
+ '',
115
121
  'Exit codes:',
116
- ' 0 no phase label found',
122
+ ' 0 no phase label and no board identifier found',
117
123
  ' 1 refused, with the reason on stderr or in the JSON record',
118
- ' 2 the title or body carries a phase label',
124
+ ' 2 the title or body carries a phase label or a board identifier',
119
125
  '',
120
126
  'Examples:',
121
127
  ' canon labels scan --event "$GITHUB_EVENT_PATH"',
@@ -345,6 +351,18 @@ async function runScan(opts: ScanOptions): Promise<number> {
345
351
  for (const label of result.phaseLabels) logWarn(label)
346
352
  }
347
353
 
354
+ logStep(
355
+ result.boardReferences.length === 0 ? 'Clean' : 'Board identifier found',
356
+ )
357
+ if (result.boardReferences.length === 0) {
358
+ logInfo('no quoted label or record path in the title or body')
359
+ } else {
360
+ logWarn(
361
+ `${plural(result.boardReferences.length, 'board identifier')} in the title or body. Name what a reader on the remote can open, since a record path is gitignored there and a quoted label reads as one only from the board.`,
362
+ )
363
+ for (const reference of result.boardReferences) logWarn(reference)
364
+ }
365
+
348
366
  outro()
349
367
 
350
368
  if (emitJson) {
@@ -353,9 +371,12 @@ async function runScan(opts: ScanOptions): Promise<number> {
353
371
  cutsRelease: result.cutsRelease,
354
372
  phaseLabels: result.phaseLabels,
355
373
  semverTags: result.semverTags,
374
+ boardReferences: result.boardReferences,
356
375
  })}\n`,
357
376
  )
358
377
  }
359
378
 
360
- return result.phaseLabels.length === 0 ? 0 : 2
379
+ return result.phaseLabels.length === 0 && result.boardReferences.length === 0
380
+ ? 0
381
+ : 2
361
382
  }
@@ -164,6 +164,94 @@ export const unreferencedRules: Measure = async (ctx) => {
164
164
  }
165
165
  }
166
166
 
167
+ /**
168
+ * The files the sweep would rewrite, each with its own count, taken from the
169
+ * `paths` array the record carries beside the total.
170
+ *
171
+ * Every field is tested rather than trusted. The record reaches here as parsed
172
+ * JSON rather than as a type the compiler checked, so a shape that moved
173
+ * upstream drops the entries it can no longer read and leaves the count that
174
+ * was read from a field of its own standing.
175
+ */
176
+ function citedPaths(record: { paths?: unknown } | undefined): string[] {
177
+ if (!Array.isArray(record?.paths)) return []
178
+
179
+ return record.paths.flatMap((entry) => {
180
+ const cited = entry as { path?: unknown; rewritten?: unknown }
181
+ if (typeof cited.path !== 'string') return []
182
+ return typeof cited.rewritten === 'number'
183
+ ? [`${cited.path} (${cited.rewritten})`]
184
+ : [cited.path]
185
+ })
186
+ }
187
+
188
+ /**
189
+ * A second run of the records move should rewrite nothing, and the count is
190
+ * only knowable once the folders themselves have landed.
191
+ *
192
+ * `moves` empty means every record folder already sits at `.canon/`, so any
193
+ * citation the sweep would still rewrite is one the move left stale, which is
194
+ * the defect this stage exists to catch. Where `moves` is nonempty the tree has
195
+ * not migrated at all and a nonzero rewrite count is the verb describing its
196
+ * own first pass, so the reading is reported and never failed on.
197
+ *
198
+ * Exit `0` is a tree with nothing to do and exit `2` is a plan drawn without
199
+ * `--write`, so both read a tree and both carry a record. Every other exit is a
200
+ * refusal that planned nothing, which is unmeasured for the reason the markdown
201
+ * stage treats its own refusal exit so.
202
+ */
203
+ export const recordIdempotence: Measure = async (ctx) => {
204
+ const run = await ctx.cli(['migrate', 'records', '--json'])
205
+
206
+ if (run.exitCode !== 0 && run.exitCode !== 2) {
207
+ return {
208
+ emissions: [],
209
+ unmeasured: `The records sweep refused (exit ${run.exitCode}) and planned nothing.`,
210
+ }
211
+ }
212
+
213
+ const record = parseJson(run.stdout) as
214
+ | { moves?: unknown; rewritten?: unknown; paths?: unknown }
215
+ | undefined
216
+ const moves = record?.moves
217
+ const rewritten = record?.rewritten
218
+
219
+ if (!Array.isArray(moves) || typeof rewritten !== 'number') {
220
+ return {
221
+ emissions: [],
222
+ unmeasured:
223
+ 'The records sweep carried no plan, so the stage read no count. Run bun src/cli.ts migrate records --json.',
224
+ }
225
+ }
226
+
227
+ if (moves.length > 0) {
228
+ return {
229
+ emissions: [
230
+ info(
231
+ `${moves.length} record folder(s) still at the old root, with ${rewritten} citation(s) that move with them`,
232
+ ),
233
+ ],
234
+ }
235
+ }
236
+
237
+ if (rewritten > 0) {
238
+ return {
239
+ // The payload already names every file, so listing them here is what
240
+ // separates a count a reader has to go and reproduce from a remedy they
241
+ // can act on. A malformed `paths` costs the list and not the finding,
242
+ // since the count above it was read from a field of its own.
243
+ emissions: citedPaths(record).map((path) => warn(path)),
244
+ failure: `The records are at .canon/ and ${rewritten} citation(s) still name the old root, so a second run of canon migrate records would rewrite them. Repoint each one, or mark it canon-keep-record-root where the sentence has to keep the old spelling.`,
245
+ }
246
+ }
247
+
248
+ return {
249
+ emissions: [
250
+ info('Records at .canon/, and a re-run of the move rewrites nothing'),
251
+ ],
252
+ }
253
+ }
254
+
167
255
  /**
168
256
  * A banned character, word, or spelling is a fact rather than a threshold, so
169
257
  * it fails the push while bullet, paragraph, and depth weight stay advisory.
@@ -4,6 +4,7 @@ import {
4
4
  markdownBans,
5
5
  type Measure,
6
6
  pluginManifests,
7
+ recordIdempotence,
7
8
  sandboxCoverage,
8
9
  seedStandards,
9
10
  standardCriteria,
@@ -337,6 +338,17 @@ export const STAGES: readonly Stage[] = [
337
338
  ],
338
339
  success: 'Rule citations resolve',
339
340
  },
341
+ {
342
+ // A citation naming the old record root resolves to nothing once the
343
+ // folders have moved, and the sweep that would repoint it only runs when a
344
+ // person calls it. Reading its plan back is what turns a silent stale
345
+ // citation into a stopped push, and it sits with the two citation stages
346
+ // above rather than at the end of the file because it answers their
347
+ // question about a root rather than a path.
348
+ id: 'record-idempotence',
349
+ label: 'Record idempotence',
350
+ checks: [{ kind: 'measure', measure: recordIdempotence }],
351
+ },
340
352
  {
341
353
  id: 'markdown-bans',
342
354
  label: 'Markdown bans',
@@ -1,4 +1,5 @@
1
1
  import { linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
2
+ import { RECORD_ENTRIES, RECORD_ROOTS, spell } from '@/record-root'
2
3
 
3
4
  /**
4
5
  * The two version namespaces `standards/versioning.md` keeps apart, and why a
@@ -22,10 +23,81 @@ export interface PhaseScanResult {
22
23
  readonly cutsRelease: boolean
23
24
  readonly phaseLabels: readonly string[]
24
25
  readonly semverTags: readonly string[]
26
+ /**
27
+ * Text naming the board rather than the change: a version token a code span
28
+ * quotes, and a path under a record root.
29
+ *
30
+ * It sits beside the two namespaces rather than inside either, because a
31
+ * record path is version-shaped in neither and a quoted token is one the
32
+ * reading below has already declined to sort. Both are one defect at the
33
+ * destination, which is a reader on a remote holding neither the task board
34
+ * nor the gitignored folder a path names.
35
+ */
36
+ readonly boardReferences: readonly string[]
25
37
  }
26
38
 
27
39
  const VERSION_TOKEN = /\bv\d+(?:\.\d+){1,2}\b/g
28
40
 
41
+ /**
42
+ * A code span holding a version token and nothing else.
43
+ *
44
+ * The closing delimiter refers back to the opening one, so a span opened on two
45
+ * backticks closes on two, which is the rule `maskCodeSpans` reads a span by.
46
+ * Content is the token alone rather than a token found inside longer content,
47
+ * which is the whole of what separates a quoted phase label from `#1208`.
48
+ */
49
+ const VERSION_SPAN = /(`+)(v\d+(?:\.\d+){1,2})\1/g
50
+
51
+ /** Sentence punctuation a path picks up at the end of a clause. */
52
+ const TRAILING_PUNCTUATION = /[.,;]+$/
53
+
54
+ /** Escapes a literal so it can sit inside a constructed pattern. */
55
+ function escapeLiteral(text: string): string {
56
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57
+ }
58
+
59
+ /**
60
+ * What is ignored under a root beyond the entries the record move relocated.
61
+ *
62
+ * `RECORD_ENTRIES` answers which folders that move carried across, and this
63
+ * check asks which paths a reader on a remote cannot open. The two questions
64
+ * differ by exactly one entry: the worktrees folder is ignored and stays out of
65
+ * that list deliberately, since the harness creates a worktree there and
66
+ * requires its target to sit there, so adding it upstream would tell the
67
+ * migration to relocate a folder the harness pins.
68
+ *
69
+ * It is also the entry a worker announcement names most often, which is what
70
+ * makes the gap a live class rather than a theoretical one.
71
+ */
72
+ const IGNORED_BEYOND_RECORDS: readonly string[] = ['worktrees']
73
+
74
+ /**
75
+ * A path a reader on a remote cannot open, which is a root plus one of the
76
+ * entries that root ignores rather than the root alone.
77
+ *
78
+ * `.claude/` is tracked and holds `rules`, `skills`, `hooks`, and `context`, so
79
+ * a rule path resolves in any clone and is not a board reference. The scratch
80
+ * folder goes through `spell` because it is the one entry whose name differs by
81
+ * root. Reading the roots and the relocated entries from `src/record-root.ts`
82
+ * is what makes a folder added there matched here without an edit, and the list
83
+ * above is what covers the one thing that module deliberately does not carry.
84
+ *
85
+ * The tail runs to the first whitespace or closing delimiter, so a report names
86
+ * the whole path an author has to remove rather than the prefix that matched.
87
+ */
88
+ const RECORD_PATH = new RegExp(
89
+ `(?<![\\w./-])(?:${RECORD_ROOTS.map(
90
+ (root) =>
91
+ `${escapeLiteral(root)}/(?:${[
92
+ ...RECORD_ENTRIES,
93
+ ...IGNORED_BEYOND_RECORDS,
94
+ ]
95
+ .map((entry) => escapeLiteral(spell(root, entry)))
96
+ .join('|')})`,
97
+ ).join('|')})(?![\\w-])[^\\s\`)\\]]*`,
98
+ 'g',
99
+ )
100
+
29
101
  /**
30
102
  * The head branch release-please opens every release pull request under.
31
103
  *
@@ -53,6 +125,15 @@ function isReleasePullRequest(input: PhaseScanInput): boolean {
53
125
  )
54
126
  }
55
127
 
128
+ /** Replaces a version span's delimiters with spaces, holding the line's width. */
129
+ function unquoteVersionSpans(line: string): string {
130
+ return line.replace(
131
+ VERSION_SPAN,
132
+ (_span, ticks: string, token: string) =>
133
+ `${' '.repeat(ticks.length)}${token}${' '.repeat(ticks.length)}`,
134
+ )
135
+ }
136
+
56
137
  /**
57
138
  * Drops a fenced block outright and blanks a code span inside what remains,
58
139
  * so a token quoted rather than written is read the way a reader reads it:
@@ -69,9 +150,34 @@ function isReleasePullRequest(input: PhaseScanInput): boolean {
69
150
  * compare link's URL, and masking it would empty `semverTags` on the one
70
151
  * pull request this check exists to pass, trading the corpus's one code-span
71
152
  * leak for a hole in every release.
153
+ *
154
+ * `keepVersionSpans` widens that reading for the board-reference pass alone. A
155
+ * span whose whole content is a version token survives it, which is the shape
156
+ * the leak reached the remote through, and longer content stays blanked, which
157
+ * is what holds `#1208` closed. Two passes over one source rather than one pass
158
+ * sorting its own output, because a token is a board reference by virtue of the
159
+ * span it came out of and nothing downstream of the match can see that.
72
160
  */
73
- function readable(text: string): string {
74
- return linesOutsideFences(text).map(maskCodeSpans).join('\n')
161
+ function readable(text: string, keepVersionSpans = false): string {
162
+ return linesOutsideFences(text)
163
+ .map((line) =>
164
+ maskCodeSpans(keepVersionSpans ? unquoteVersionSpans(line) : line),
165
+ )
166
+ .join('\n')
167
+ }
168
+
169
+ function versionTokens(text: string): string[] {
170
+ return [...new Set(text.match(VERSION_TOKEN) ?? [])]
171
+ }
172
+
173
+ function recordPaths(text: string): string[] {
174
+ return [
175
+ ...new Set(
176
+ (text.match(RECORD_PATH) ?? []).map((path) =>
177
+ path.replace(TRAILING_PUNCTUATION, ''),
178
+ ),
179
+ ),
180
+ ]
75
181
  }
76
182
 
77
183
  /**
@@ -83,13 +189,42 @@ function readable(text: string): string {
83
189
  * legitimately carries, and every other pull request's tokens are read as
84
190
  * leaked phase labels, which is what `standards/versioning.md` names the
85
191
  * defect this exists to catch.
192
+ *
193
+ * A release pull request reports no board reference either, and the ground is
194
+ * coverage rather than exemption. Release-please generates that body from
195
+ * merged history, and every commit in that history came through a pull request
196
+ * this same check already scanned, so a board reference cannot reach a release
197
+ * body without passing the gate on its own. That its author has nothing to
198
+ * rewrite is true as well and is the weaker half, since it would leave the
199
+ * reference standing and unresolvable.
86
200
  */
87
201
  export function scanPhaseLabels(input: PhaseScanInput): PhaseScanResult {
88
- const text = readable(`${input.title}\n${input.body}`)
89
- const tokens = [...new Set(text.match(VERSION_TOKEN) ?? [])]
202
+ const source = `${input.title}\n${input.body}`
203
+ const tokens = versionTokens(readable(source))
90
204
  const cutsRelease = isReleasePullRequest(input)
91
205
 
92
- return cutsRelease
93
- ? { cutsRelease, phaseLabels: [], semverTags: tokens }
94
- : { cutsRelease, phaseLabels: tokens, semverTags: [] }
206
+ if (cutsRelease) {
207
+ return {
208
+ cutsRelease,
209
+ phaseLabels: [],
210
+ semverTags: tokens,
211
+ boardReferences: [],
212
+ }
213
+ }
214
+
215
+ // Dropped where the same token is also written bare, since the phase-label
216
+ // half already names it and reporting it twice asks for one removal twice.
217
+ const quoted = versionTokens(readable(source, true)).filter(
218
+ (token) => !tokens.includes(token),
219
+ )
220
+
221
+ return {
222
+ cutsRelease,
223
+ phaseLabels: tokens,
224
+ semverTags: [],
225
+ boardReferences: [
226
+ ...quoted,
227
+ ...recordPaths(linesOutsideFences(source).join('\n')),
228
+ ],
229
+ }
95
230
  }
package/standards/pr.md CHANGED
@@ -14,6 +14,7 @@ Does not govern:
14
14
  - Commit subject format, which shares the title form: `commit.md`
15
15
  - Branch naming: `branch.md`
16
16
  - Whether a phase label or a semver tag may appear in a title or body: `versioning.md`
17
+ - Whether a quoted label or a gitignored record path may appear in a title or body, which `canon labels scan` fails on: `publish.md`
17
18
  - Voice, rhythm, and sentence construction in pull request prose: the `write-human` skill
18
19
  - Punctuation, formatting, and banned words in pull request prose: `markdown.md`
19
20
 
@@ -22,6 +22,8 @@ Wherever text leaves through a channel no automated check covers, the author is
22
22
 
23
23
  Run the scan as an explicit step against the finished text. Having read the underlying rules before drafting does not cover it, because the check has to happen after the text exists.
24
24
 
25
+ Scope every check below by destination. Text published to a remote takes all of them. Text staying in the repository takes the character bans alone, since those hold wherever the text lands, and skips whichever check depends on the reader holding something only this checkout carries. The two checks below that depend on it say so under their own headings.
26
+
25
27
  ## Banned characters
26
28
 
27
29
  `markdown.md` holds the character bans and the banned words alike. Read it at scan time rather than working the sets from memory, then scan the drafted text and rewrite each occurrence.
@@ -32,7 +34,17 @@ Restructure the sentence rather than substituting the character. A semicolon swa
32
34
 
33
35
  `versioning.md` holds the label rule and the table of surfaces. Read it at scan time rather than working the format from memory.
34
36
 
35
- Scope this check by destination. Text published to a remote takes it. Text scanned on its way into the repository, where the reader has the task board, skips it.
37
+ This check is one of the two the destination rule above scopes. The reader inside the repository has the task board and the reader on a remote does not.
38
+
39
+ ## Board identifiers
40
+
41
+ A phase label is one way text names the board, and a path under a record root is the other. Both resolve for a reader holding this checkout and neither resolves for anyone else, so this check is the second one the destination rule scopes.
42
+
43
+ Two shapes get past a reader scanning for a bare label. A code span quoting a label is still the label, so read a span whose whole content is one as a hit and leave a longer token inside a span alone, which is a fixture name rather than a reference. The second shape is a path under a record root, gitignored and therefore absent from every clone, so `.canon/review/feedback/` names a folder the remote's reader cannot open. Under a tracked folder there is no hit, since `.claude/rules/core/005-behavior.md` resolves everywhere.
44
+
45
+ Rewrite a hit to name what the reader can reach rather than deleting it. A row's subject stated plainly replaces its label, and what a record folder holds, said in a sentence, replaces its path.
46
+
47
+ `canon labels scan` runs this check and the phase-label one over a pull request title and body. It reads that pair alone, so every other channel is the author's own scan.
36
48
 
37
49
  ## Cross-reference form
38
50