@erclx/canon 4.46.0 → 4.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.
@@ -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.46.0",
4
+ "version": "4.47.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -16,7 +16,7 @@ Read these in parallel from the project root, skipping any that do not exist:
16
16
 
17
17
  - `CLAUDE.md`: project type and conventions
18
18
  - `.claude/DESIGN.md`: visual intent, tokens, typography, spacing rules
19
- - `.claude/wireframes/index.md` + every `.claude/wireframes/<surface>.md`: intended UI layout, UI copy, interaction rules. Surfaces are loaded one file at a time during Step 3. Per `${CLAUDE_SKILL_DIR}/../../standards/wireframes.md`, these carry layout and intent, not implementation detail.
19
+ - `.claude/wireframes/index.md` + every `.claude/wireframes/<surface>.md`, each a flat file or a grouped surface's own `.claude/wireframes/<surface>/index.md` and the siblings it lists: intended UI layout, UI copy, interaction rules. Surfaces are loaded one file at a time during Step 3. Per `${CLAUDE_SKILL_DIR}/../../standards/wireframes.md`, these carry layout and intent, not implementation detail.
20
20
  - `.claude/REQUIREMENTS.md`: feature scope and non-goals
21
21
 
22
22
  ## Step 2: identify surfaces
@@ -32,7 +32,7 @@ For each surface, look for:
32
32
  3. **Inconsistencies**: spacing, tone of voice, interaction patterns, icon use, keyboard affordances
33
33
  4. **Roughness in daily use**: friction, redundant steps, unclear affordances, ambiguous labels
34
34
 
35
- Use `.claude/DESIGN.md` and the per-surface `.claude/wireframes/<surface>.md` files as ground truth for intent. For each implementation surface, read the matching wireframe file before flagging drift. Observations only, no implementation suggestions or fixes.
35
+ Use `.claude/DESIGN.md` and the per-surface `.claude/wireframes/<surface>.md` files, each a flat file or a grouped surface's own `.claude/wireframes/<surface>/index.md` and the siblings it lists, as ground truth for intent. For each implementation surface, read the matching wireframe file before flagging drift. Observations only, no implementation suggestions or fixes.
36
36
 
37
37
  ## Step 4: report and persist
38
38
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.46.0",
4
+ "version": "4.47.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -295,6 +295,31 @@ function reportRecords(plan: RecordsPlan, records: number): void {
295
295
  }
296
296
  }
297
297
  }
298
+
299
+ // Named individually, same reasoning as the glob report above: an ownership
300
+ // boundary and a tense judgment stop being visible the moment either is
301
+ // rewritten, so the reader gets the file and line rather than a count.
302
+ if (plan.crossRepoCitations.length > 0) {
303
+ logInfo(
304
+ `${plural(plan.crossRepoCitations.length, 'file')} carry a citation into another repository, left alone:`,
305
+ )
306
+ for (const entry of plan.crossRepoCitations) {
307
+ for (const line of entry.lines) {
308
+ logInfo(` ${entry.path}:${line.line} ${excerpt(line.text)}`)
309
+ }
310
+ }
311
+ }
312
+
313
+ if (plan.datedCitations.length > 0) {
314
+ logInfo(
315
+ `${plural(plan.datedCitations.length, 'file')} carry a dated citation, left alone:`,
316
+ )
317
+ for (const entry of plan.datedCitations) {
318
+ for (const line of entry.lines) {
319
+ logInfo(` ${entry.path}:${line.line} ${excerpt(line.text)}`)
320
+ }
321
+ }
322
+ }
298
323
  }
299
324
 
300
325
  function toRecordsRecord(
@@ -313,7 +338,11 @@ function toRecordsRecord(
313
338
  excluded: plan.excluded.length,
314
339
  coupled: plan.coupled,
315
340
  frontmatterGlobs: plan.frontmatterGlobs,
341
+ crossRepoCitations: plan.crossRepoCitations,
342
+ datedCitations: plan.datedCitations,
316
343
  globs: plan.globs,
344
+ crossRepo: plan.crossRepo,
345
+ dated: plan.dated,
317
346
  records,
318
347
  paths: plan.entries.map((entry) => ({
319
348
  path: entry.path,
@@ -575,6 +604,11 @@ export function register(program: Command): void {
575
604
  'alone rather than rewritten, since a rewritten glob stops matching',
576
605
  'silently. No marker is needed; the YAML shape is enough.',
577
606
  '',
607
+ 'A citation shaped like a path into another repository, or one sitting',
608
+ 'inside a dated paragraph, is reported and left alone rather than',
609
+ 'rewritten. The first is an ownership judgment and the second a tense',
610
+ 'one, neither of which a regular expression makes reliably.',
611
+ '',
578
612
  'The records themselves are never swept. Everything under .canon/ and',
579
613
  'every .claude/ record folder is left alone and reported as a count, so',
580
614
  'a run after the ignore entries collapse touches the same files as one',
@@ -257,7 +257,60 @@ function isFrontmatterPathsLine(
257
257
  return lines[top]?.trim().startsWith('paths:') ?? false
258
258
  }
259
259
 
260
- type LineClass = 'live' | 'kept' | 'glob'
260
+ /**
261
+ * A path segment immediately before a citation match, shaped like another
262
+ * repository's own name: it carries a dot (a domain-shaped token, matching
263
+ * the measured `erclx.dev`) or it is itself preceded by a further `/` (two
264
+ * path segments deep, also matching the measured `public/erclx.dev/...`).
265
+ *
266
+ * Bounded by `[\w.-]`, which is what keeps a shell-glob prefix such as
267
+ * `*` before the slash and a variable-substitution prefix such as
268
+ * `$project` out: neither `*` nor `$` is in the class, so a segment built
269
+ * from either never reaches the dot or the second slash this looks for.
270
+ */
271
+ const CROSS_REPO_PREFIX = /(?:[\w.-]+\.[\w.-]+|[\w.-]+\/[\w.-]+)\/$/
272
+
273
+ /**
274
+ * Whether the line's citation sits right after a prefix `CROSS_REPO_PREFIX`
275
+ * reads as another repository's own path. Per-line rather than per-match,
276
+ * matching `isKept`'s and `isFrontmatterPathsLine`'s granularity: a line
277
+ * carrying more than one citation is judged by the first.
278
+ */
279
+ function isCrossRepoPrefix(lines: readonly string[], index: number): boolean {
280
+ const line = lines[index] ?? ''
281
+ const match = [...line.matchAll(CITATION)][0]
282
+ if (match === undefined) return false
283
+
284
+ return CROSS_REPO_PREFIX.test(line.slice(0, match.index ?? 0))
285
+ }
286
+
287
+ /** An ISO date, marking a paragraph as recording what was true on that day. */
288
+ const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/
289
+
290
+ /**
291
+ * Whether the line's citation sits inside a blank-line-delimited paragraph
292
+ * that also carries an ISO date, read as dated prose rather than a live path.
293
+ *
294
+ * Walks both directions from `index` to the paragraph's edges, unlike
295
+ * `isKept`'s upward-only walk to a marker: the date can close a paragraph the
296
+ * citation opens, as `.claude/ARCHITECTURE.md`'s own `Measured at ... on
297
+ * <date>` sentences do.
298
+ */
299
+ function isDatedParagraph(lines: readonly string[], index: number): boolean {
300
+ let start = index
301
+ while (start > 0 && lines[start - 1]?.trim() !== '') start -= 1
302
+
303
+ let end = index
304
+ while (end < lines.length - 1 && lines[end + 1]?.trim() !== '') end += 1
305
+
306
+ for (let cursor = start; cursor <= end; cursor += 1) {
307
+ if (ISO_DATE.test(lines[cursor] ?? '')) return true
308
+ }
309
+
310
+ return false
311
+ }
312
+
313
+ type LineClass = 'live' | 'kept' | 'glob' | 'crossRepo' | 'dated'
261
314
 
262
315
  function classifyLine(
263
316
  lines: readonly string[],
@@ -266,6 +319,8 @@ function classifyLine(
266
319
  ): LineClass {
267
320
  if (isFrontmatterPathsLine(lines, index, frontmatterEndIndex)) return 'glob'
268
321
  if (isKept(lines, index)) return 'kept'
322
+ if (isCrossRepoPrefix(lines, index)) return 'crossRepo'
323
+ if (isDatedParagraph(lines, index)) return 'dated'
269
324
  return 'live'
270
325
  }
271
326
 
@@ -287,8 +342,9 @@ export function rewriteText(text: string): string {
287
342
 
288
343
  /**
289
344
  * How many citations `rewriteText` would rewrite, how many marked lines it
290
- * left alone, and how many sat inside a frontmatter `paths:` glob. The second
291
- * and third numbers are what say the markers and the glob boundary fired at
345
+ * left alone, how many sat inside a frontmatter `paths:` glob, how many sat
346
+ * behind a cross-repository-shaped prefix, and how many sat inside a dated
347
+ * paragraph. Every number past the first is what says its boundary fired at
292
348
  * all, which a diff cannot show because a protected line does not appear in
293
349
  * one.
294
350
  */
@@ -296,12 +352,16 @@ export function scanText(text: string): {
296
352
  readonly rewritten: number
297
353
  readonly kept: number
298
354
  readonly globs: number
355
+ readonly crossRepo: number
356
+ readonly dated: number
299
357
  } {
300
358
  const lines = text.split('\n')
301
359
  const frontmatterEndIndex = frontmatterEnd(lines)
302
360
  let rewritten = 0
303
361
  let kept = 0
304
362
  let globs = 0
363
+ let crossRepo = 0
364
+ let dated = 0
305
365
 
306
366
  for (const [index, line] of lines.entries()) {
307
367
  const matches = [...line.matchAll(CITATION)].length
@@ -310,10 +370,12 @@ export function scanText(text: string): {
310
370
  const kind = classifyLine(lines, index, frontmatterEndIndex)
311
371
  if (kind === 'glob') globs += matches
312
372
  else if (kind === 'kept') kept += matches
373
+ else if (kind === 'crossRepo') crossRepo += matches
374
+ else if (kind === 'dated') dated += matches
313
375
  else rewritten += matches
314
376
  }
315
377
 
316
- return { rewritten, kept, globs }
378
+ return { rewritten, kept, globs, crossRepo, dated }
317
379
  }
318
380
 
319
381
  /** Where a citation sits, so a reader can judge it before `--write` runs. */
@@ -338,6 +400,45 @@ function frontmatterGlobLines(text: string): CitationLine[] {
338
400
  return held
339
401
  }
340
402
 
403
+ /**
404
+ * Every line in `text` whose citation `classifyLine` reads as `crossRepo`.
405
+ *
406
+ * Reads through `classifyLine` rather than `isCrossRepoPrefix` alone, so a
407
+ * line a glob or a keep marker already claims is not reported twice under a
408
+ * second boundary.
409
+ */
410
+ function crossRepoLines(text: string): CitationLine[] {
411
+ const lines = text.split('\n')
412
+ const frontmatterEndIndex = frontmatterEnd(lines)
413
+ const held: CitationLine[] = []
414
+
415
+ for (const [index, line] of lines.entries()) {
416
+ if ([...line.matchAll(CITATION)].length === 0) continue
417
+ if (classifyLine(lines, index, frontmatterEndIndex) !== 'crossRepo')
418
+ continue
419
+
420
+ held.push({ line: index + 1, text: line.trim() })
421
+ }
422
+
423
+ return held
424
+ }
425
+
426
+ /** Every line in `text` whose citation `classifyLine` reads as `dated`. */
427
+ function datedLines(text: string): CitationLine[] {
428
+ const lines = text.split('\n')
429
+ const frontmatterEndIndex = frontmatterEnd(lines)
430
+ const held: CitationLine[] = []
431
+
432
+ for (const [index, line] of lines.entries()) {
433
+ if ([...line.matchAll(CITATION)].length === 0) continue
434
+ if (classifyLine(lines, index, frontmatterEndIndex) !== 'dated') continue
435
+
436
+ held.push({ line: index + 1, text: line.trim() })
437
+ }
438
+
439
+ return held
440
+ }
441
+
341
442
  export interface FolderMove {
342
443
  readonly from: string
343
444
  readonly to: string
@@ -402,6 +503,18 @@ export interface FrontmatterGlobEntry {
402
503
  readonly lines: readonly CitationLine[]
403
504
  }
404
505
 
506
+ /** One file whose citation resolves outside this project, held rather than rewritten. */
507
+ export interface CrossRepoCitationEntry {
508
+ readonly path: string
509
+ readonly lines: readonly CitationLine[]
510
+ }
511
+
512
+ /** One file whose citation sits inside a dated paragraph, held rather than rewritten. */
513
+ export interface DatedCitationEntry {
514
+ readonly path: string
515
+ readonly lines: readonly CitationLine[]
516
+ }
517
+
405
518
  export interface RecordsPlan {
406
519
  readonly moves: readonly FolderMove[]
407
520
  readonly collisions: readonly string[]
@@ -409,9 +522,13 @@ export interface RecordsPlan {
409
522
  readonly excluded: readonly string[]
410
523
  readonly coupled: readonly string[]
411
524
  readonly frontmatterGlobs: readonly FrontmatterGlobEntry[]
525
+ readonly crossRepoCitations: readonly CrossRepoCitationEntry[]
526
+ readonly datedCitations: readonly DatedCitationEntry[]
412
527
  readonly rewritten: number
413
528
  readonly kept: number
414
529
  readonly globs: number
530
+ readonly crossRepo: number
531
+ readonly dated: number
415
532
  }
416
533
 
417
534
  /**
@@ -431,8 +548,12 @@ export function planRecordsMove(
431
548
  const excluded: string[] = []
432
549
  const coupled: string[] = []
433
550
  const frontmatterGlobs: FrontmatterGlobEntry[] = []
551
+ const crossRepoCitations: CrossRepoCitationEntry[] = []
552
+ const datedCitations: DatedCitationEntry[] = []
434
553
  let kept = 0
435
554
  let globs = 0
555
+ let crossRepo = 0
556
+ let dated = 0
436
557
 
437
558
  for (const source of sources) {
438
559
  // Silently, and ahead of the exclusion test. The command boundary filters
@@ -452,13 +573,24 @@ export function planRecordsMove(
452
573
  const counts = scanText(source.text)
453
574
  kept += counts.kept
454
575
  globs += counts.globs
576
+ crossRepo += counts.crossRepo
577
+ dated += counts.dated
455
578
 
456
579
  // Ahead of the rewritten === 0 continue below: a file whose only citation
457
- // sits in its paths: glob still needs to be reported.
580
+ // sits in its paths: glob, a cross-repo prefix, or a dated paragraph still
581
+ // needs to be reported.
458
582
  const held = frontmatterGlobLines(source.text)
459
583
  if (held.length > 0)
460
584
  frontmatterGlobs.push({ path: source.path, lines: held })
461
585
 
586
+ const foreign = crossRepoLines(source.text)
587
+ if (foreign.length > 0)
588
+ crossRepoCitations.push({ path: source.path, lines: foreign })
589
+
590
+ const dates = datedLines(source.text)
591
+ if (dates.length > 0)
592
+ datedCitations.push({ path: source.path, lines: dates })
593
+
462
594
  if (counts.rewritten === 0) continue
463
595
 
464
596
  if (referencesExcluded(source.text)) coupled.push(source.path)
@@ -478,8 +610,12 @@ export function planRecordsMove(
478
610
  excluded,
479
611
  coupled,
480
612
  frontmatterGlobs,
613
+ crossRepoCitations,
614
+ datedCitations,
481
615
  rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
482
616
  kept,
483
617
  globs,
618
+ crossRepo,
619
+ dated,
484
620
  }
485
621
  }
@@ -18,6 +18,7 @@ Does not govern:
18
18
  - Commit subject, branch name, and pull request title format: `commit.md`, `branch.md`, and `pr.md`
19
19
  - Voice, rhythm, and sentence construction in any text carrying a label: the `write-human` skill
20
20
  - Punctuation, formatting, and word choice in any text carrying a label: `markdown.md`
21
+ - How a quoted-alone version-shaped token or a record path is caught as a board reference, which sits outside the phase-label/semver split this file owns: `publish.md`
21
22
 
22
23
  ## Phase labels
23
24
 
@@ -59,6 +60,10 @@ External release identity used in git tags and release notes. Independent of pha
59
60
  - Git tags use semver only. Phase labels never become tags.
60
61
  - A PR that cuts a release may reference its semver tag in the title or body. Phase labels still do not appear.
61
62
  - PR bodies, review comments, and issue text name the change itself, never the internal stream that scheduled it. Describe the work rather than the label it was planned under.
63
+ - A phase label written alone inside its own code span reads as shown rather than asserted. `canon labels scan` masks a code span before checking for a bare token, so a backticked label on its own does not count as an appearance for the phase-label check.
64
+ - That masking clears one check and not the gate. A span whose whole content is the label still resolves as a board reference under `publish.md`'s own board-identifier check, run by the same scan, so a title or body quoting a label alone in a span still fails on that check and does not clear every surface the table above marks `no`.
65
+ - The one shape neither check reaches is a version-shaped token folded into a longer quoted phrase, such as a fixture name or a file path. A backticked span quoting a test fixture's own version-shaped name is the corpus case that forced this carve-out, and a real phase label folded the same way inside a review comment is the counterexample where it read as clean on both checks.
66
+ - The operator decided to keep the carve-out rather than narrow it to fenced blocks alone or drop it outright, leaving an author responsible for writing a live reference in plain text instead of folding it into a quoted phrase.
62
67
  - A phase label in a context entry is permitted only when its substance is restated inline beside it. A bare label carrying nothing beside it is forbidden, since a reader without the board has nothing to resolve it against.
63
68
  - A phase label carries no exception for a surface this repository authors and ships to a target it does not control: a standard, a skill body, and a governance rule are all forbidden outright, substance restated or not. The line is ownership rather than reachability. A shipped file is read by a project that never edits it, so the label names a board that reader can never reach. A context entry stays permitted above because it is this project's own tracked surface, never shipped.
64
69
  - The shipped-surface rule governs a reference to a real row, never a token shown to illustrate the label's own format. A phase label naming no decision, written only to show the pattern's shape, is not a citation, which is what lets a standard defining the format still show what one looks like.