@erclx/canon 5.59.0 → 5.61.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 (39) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/design-record/references/design.md +19 -0
  4. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  5. package/claude/skills/document-health/SKILL.md +23 -3
  6. package/claude/skills/document-health/references/axes.md +21 -1
  7. package/claude/skills/plan-feature/SKILL.md +1 -1
  8. package/claude/skills/review-pr/SKILL.md +1 -1
  9. package/claude/skills/review-pr/references/scope.md +10 -0
  10. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  11. package/claude/skills/review-ui/SKILL.md +3 -2
  12. package/claude/skills/review-ui/references/post.md +2 -2
  13. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  14. package/claude/skills/ui-checklist/SKILL.md +12 -0
  15. package/docs/agents/records.md +1 -1
  16. package/docs/target/migrations.md +1 -1
  17. package/docs/workflow/parallel-features.md +1 -1
  18. package/package.json +2 -1
  19. package/src/commands/design.ts +57 -1
  20. package/src/commands/driver.ts +29 -3
  21. package/src/commands/migrate.ts +7 -0
  22. package/src/commands/records.ts +3 -2
  23. package/src/commands/tooling.ts +17 -2
  24. package/src/design/audit.ts +133 -0
  25. package/src/design/budget.ts +2 -1
  26. package/src/design/css.ts +1 -5
  27. package/src/design/document.ts +5 -1
  28. package/src/design/themes.ts +176 -0
  29. package/src/design/tokens.ts +3 -0
  30. package/src/driver/drive.ts +17 -1
  31. package/src/driver/probes/axe.ts +195 -0
  32. package/src/driver/steps.ts +20 -1
  33. package/src/gate/measures.ts +9 -3
  34. package/src/markdown/structure.ts +4 -1
  35. package/src/migrate/records.ts +46 -7
  36. package/src/records/stale-canonical.ts +2 -2
  37. package/src/sync/stamp.ts +24 -4
  38. package/src/tooling/inject.ts +6 -3
  39. package/src/tooling/install.ts +55 -0
@@ -590,10 +590,16 @@ export const recordIdempotence: Measure = async (ctx) => {
590
590
  }
591
591
 
592
592
  const record = parseJson(run.stdout) as
593
- | { moves?: unknown; rewritten?: unknown; paths?: unknown }
593
+ | {
594
+ moves?: unknown
595
+ rewritten?: unknown
596
+ dropped?: unknown
597
+ paths?: unknown
598
+ }
594
599
  | undefined
595
600
  const moves = record?.moves
596
601
  const rewritten = record?.rewritten
602
+ const dropped = typeof record?.dropped === 'number' ? record.dropped : 0
597
603
 
598
604
  if (!Array.isArray(moves) || typeof rewritten !== 'number') {
599
605
  return {
@@ -613,14 +619,14 @@ export const recordIdempotence: Measure = async (ctx) => {
613
619
  }
614
620
  }
615
621
 
616
- if (rewritten > 0) {
622
+ if (rewritten + dropped > 0) {
617
623
  return {
618
624
  // The payload already names every file, so listing them here is what
619
625
  // separates a count a reader has to go and reproduce from a remedy they
620
626
  // can act on. A malformed `paths` costs the list and not the finding,
621
627
  // since the count above it was read from a field of its own.
622
628
  emissions: citedPaths(record).map((path) => warn(path)),
623
- 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.`,
629
+ failure: `The records are at .canon/ and ${rewritten + dropped} citation(s) still name the old root, so a second run of canon migrate records would rewrite or drop them. Repoint each one, or mark it canon-keep-record-root where the sentence has to keep the old spelling.`,
624
630
  }
625
631
  }
626
632
 
@@ -422,6 +422,9 @@ export function longestRun(
422
422
  * continuing a bullet do fold in, so a heavy bullet cannot fall under the
423
423
  * checkpoint by being wrapped across two source lines. A fenced line closes the
424
424
  * open bullet, which keeps a bullet from absorbing the example below it.
425
+ *
426
+ * A bullet is measured without its list marker, the way the `bullet-length`
427
+ * gate measures it, so the count and the gate agree at the checkpoint.
425
428
  */
426
429
  export function heavyBullets(
427
430
  lines: readonly BodyLine[],
@@ -470,7 +473,7 @@ function foldBullets(
470
473
  close()
471
474
  open = {
472
475
  line: line.number,
473
- characters: visibleText(text).length,
476
+ characters: visibleText(text.replace(LIST_ITEM, '')).length,
474
477
  text,
475
478
  indent: item[1].length,
476
479
  }
@@ -56,14 +56,21 @@ export function destinationPath(entry: string): string {
56
56
  * shorter entry can claim its prefix, and each entry is escaped because two of
57
57
  * them carry a dot.
58
58
  */
59
+ const ENTRY_ALTERNATION = [...MOVED_ENTRIES]
60
+ .sort((left, right) => right.length - left.length)
61
+ .map(escape)
62
+ .join('|')
63
+
59
64
  const CITATION = new RegExp(
60
- `${escape(FROM_ROOT)}/(${[...MOVED_ENTRIES]
61
- .sort((left, right) => right.length - left.length)
62
- .map(escape)
63
- .join('|')})(?![A-Za-z0-9._-])`,
65
+ `${escape(FROM_ROOT)}/(${ENTRY_ALTERNATION})(?![A-Za-z0-9._-])`,
64
66
  'g',
65
67
  )
66
68
 
69
+ /** A gitignore line that is wholly an entry for one moved folder or file. */
70
+ const IGNORE_ENTRY = new RegExp(
71
+ `^/?${escape(FROM_ROOT)}/(?:${ENTRY_ALTERNATION})/?$`,
72
+ )
73
+
67
74
  function escape(value: string): string {
68
75
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
69
76
  }
@@ -386,6 +393,30 @@ export function scanText(text: string): {
386
393
  return { rewritten, kept, globs, crossRepo, dated }
387
394
  }
388
395
 
396
+ /**
397
+ * Deletes each live line that is wholly an entry for a moved folder, counting
398
+ * them. Only a root `.gitignore` that already ignores `.canon/` may be handed
399
+ * here, since the dropped line is redundant only under that entry. Splitting on
400
+ * `\n` and matching the trimmed line leaves CRLF endings and a missing final
401
+ * newline on the lines that stay.
402
+ */
403
+ export function dropCoveredIgnoreLines(text: string): {
404
+ readonly text: string
405
+ readonly dropped: number
406
+ } {
407
+ const lines = text.split('\n')
408
+ const frontmatterEndIndex = frontmatterEnd(lines)
409
+ const kept = lines.filter(
410
+ (line, index) =>
411
+ !(
412
+ IGNORE_ENTRY.test(line.trim()) &&
413
+ classifyLine(lines, index, frontmatterEndIndex) === 'live'
414
+ ),
415
+ )
416
+
417
+ return { text: kept.join('\n'), dropped: lines.length - kept.length }
418
+ }
419
+
389
420
  /** Where a citation sits, so a reader can judge it before `--write` runs. */
390
421
  export interface CitationLine {
391
422
  readonly line: number
@@ -503,6 +534,7 @@ export interface CitationEntry {
503
534
  readonly text: string
504
535
  readonly rewritten: number
505
536
  readonly kept: number
537
+ readonly dropped: number
506
538
  }
507
539
 
508
540
  /** One file whose frontmatter `paths:` glob names a moved root, held rather than rewritten. */
@@ -533,6 +565,7 @@ export interface RecordsPlan {
533
565
  readonly crossRepoCitations: readonly CrossRepoCitationEntry[]
534
566
  readonly datedCitations: readonly DatedCitationEntry[]
535
567
  readonly rewritten: number
568
+ readonly dropped: number
536
569
  readonly kept: number
537
570
  readonly globs: number
538
571
  readonly crossRepo: number
@@ -578,7 +611,11 @@ export function planRecordsMove(
578
611
  continue
579
612
  }
580
613
 
581
- const counts = scanText(source.text)
614
+ const swept =
615
+ source.path === '.gitignore' && ignoresDestination(source.text)
616
+ ? dropCoveredIgnoreLines(source.text)
617
+ : { text: source.text, dropped: 0 }
618
+ const counts = scanText(swept.text)
582
619
  kept += counts.kept
583
620
  globs += counts.globs
584
621
  crossRepo += counts.crossRepo
@@ -599,15 +636,16 @@ export function planRecordsMove(
599
636
  if (dates.length > 0)
600
637
  datedCitations.push({ path: source.path, lines: dates })
601
638
 
602
- if (counts.rewritten === 0) continue
639
+ if (counts.rewritten === 0 && swept.dropped === 0) continue
603
640
 
604
641
  if (referencesExcluded(source.text)) coupled.push(source.path)
605
642
 
606
643
  entries.push({
607
644
  path: source.path,
608
- text: rewriteText(source.text),
645
+ text: rewriteText(swept.text),
609
646
  rewritten: counts.rewritten,
610
647
  kept: counts.kept,
648
+ dropped: swept.dropped,
611
649
  })
612
650
  }
613
651
 
@@ -621,6 +659,7 @@ export function planRecordsMove(
621
659
  crossRepoCitations,
622
660
  datedCitations,
623
661
  rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
662
+ dropped: entries.reduce((sum, entry) => sum + entry.dropped, 0),
624
663
  kept,
625
664
  globs,
626
665
  crossRepo,
@@ -5,8 +5,8 @@ import { relative } from 'node:path'
5
5
  import { gitEnv } from '@/git/env'
6
6
  import { surfaceDir } from '@/roots/surface'
7
7
 
8
- /** The record whose drift is identity rather than a stale path. */
9
- const CANONICAL_DOCS = ['REQUIREMENTS.md'] as const
8
+ /** The records carrying a `reviewed:` review point, whose drift is how far the project moved since they were read whole. */
9
+ const CANONICAL_DOCS = ['REQUIREMENTS.md', 'DESIGN.md'] as const
10
10
 
11
11
  export interface CanonicalDoc {
12
12
  /** Relative to the root, at whichever surface root carries the doc. */
package/src/sync/stamp.ts CHANGED
@@ -16,9 +16,10 @@ import { readInstalled } from '@/version/installed'
16
16
  *
17
17
  * A stamp written before the standards or snippets install channel closed
18
18
  * still carries a `standards` or `snippets` record, and one written before the
19
- * design install retired carries a `design` record. `isStamp` ignores the key
20
- * and `sortDomains` drops it on the next write, so the target loses a domain
21
- * nothing can refresh rather than losing the whole file.
19
+ * design install retired carries a `design` record. `isStamp` ignores the key,
20
+ * `readStamp` drops it from what it returns, and `sortDomains` drops it from
21
+ * the file on the next write, so the target loses a domain nothing can refresh
22
+ * rather than losing the whole file.
22
23
  */
23
24
  export const STAMP_DOMAINS = ['governance', 'tooling'] as const
24
25
 
@@ -183,12 +184,31 @@ function readStampFile(path: string): Stamp | undefined {
183
184
 
184
185
  try {
185
186
  const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
186
- return isStamp(parsed) ? parsed : undefined
187
+ return isStamp(parsed) ? withoutRetired(parsed) : undefined
187
188
  } catch {
188
189
  return undefined
189
190
  }
190
191
  }
191
192
 
193
+ /**
194
+ * A stamp written before a domain retired still names it until the next write.
195
+ * Filtering here keeps a report on a target that has not synced since from
196
+ * listing a domain nothing can refresh. The file itself is left as it is.
197
+ */
198
+ function withoutRetired(stamp: Stamp): Stamp {
199
+ const domains: Partial<Record<StampDomain, DomainStamp>> = {}
200
+ for (const domain of STAMP_DOMAINS) {
201
+ const record = stamp.domains[domain]
202
+ if (record !== undefined) domains[domain] = record
203
+ }
204
+
205
+ const covers = Array.isArray(stamp.covers)
206
+ ? stamp.covers.filter((domain) => STAMP_DOMAINS.includes(domain))
207
+ : []
208
+
209
+ return { covers, domains }
210
+ }
211
+
192
212
  /** The newest canon release that has written this domain, when one was recorded. */
193
213
  export function stampedVersion(
194
214
  stamp: Stamp | undefined,
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { dirname, join, relative } from 'node:path'
4
- import { $ } from 'bun'
5
4
  import { copyPreservingMode } from '@/sync/copy'
5
+ import { type InstallResult, installDevDependencies } from '@/tooling/install'
6
6
  import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
7
7
  import { resolveSurfacePath } from '@/roots/surface'
8
8
  import { mergeSections, pruneSections } from '@/tooling/gitignore'
@@ -165,7 +165,7 @@ export async function pruneGitignore(
165
165
  export async function injectManifest(
166
166
  chain: readonly Manifest[],
167
167
  target: string,
168
- ): Promise<void> {
168
+ ): Promise<InstallResult> {
169
169
  const packagePath = join(target, 'package.json')
170
170
 
171
171
  if (!existsSync(packagePath) && declaresInstalls(chain)) {
@@ -183,7 +183,8 @@ export async function injectManifest(
183
183
 
184
184
  if (missing.length > 0) {
185
185
  logStep('Installing missing dependencies')
186
- await $`bun add -D ${missing}`.cwd(target)
186
+ const installed = await installDevDependencies(missing, target)
187
+ if (!installed.ok) return installed
187
188
  for (const spec of missing) logAdd(spec)
188
189
  }
189
190
 
@@ -202,6 +203,8 @@ export async function injectManifest(
202
203
  await injectGitignore(chain, target)
203
204
 
204
205
  if (!pkg) warnUnreadablePackage(chain, target)
206
+
207
+ return { ok: true }
205
208
  }
206
209
 
207
210
  /**
@@ -0,0 +1,55 @@
1
+ import { execa } from 'execa'
2
+
3
+ export const INSTALL_TIMEOUT_MS = 120_000
4
+ const TAIL_LINES = 20
5
+
6
+ export type InstallResult =
7
+ | { ok: true }
8
+ | {
9
+ ok: false
10
+ reason: 'install-timeout' | 'install-failed'
11
+ message: string
12
+ }
13
+
14
+ /**
15
+ * Runs `bun add -D` for the specs and bounds it, since a registry that accepts
16
+ * a connection and never answers would otherwise hang the caller for good. A
17
+ * stall or a failure comes back as a refusal naming the packages and the tail
18
+ * of the output, never as a thrown error.
19
+ */
20
+ export async function installDevDependencies(
21
+ specs: readonly string[],
22
+ cwd: string,
23
+ timeoutMs: number = INSTALL_TIMEOUT_MS,
24
+ ): Promise<InstallResult> {
25
+ const tried = specs.join(', ')
26
+ const result = await execa('bun', ['add', '-D', ...specs], {
27
+ cwd,
28
+ all: true,
29
+ reject: false,
30
+ stdin: 'ignore',
31
+ timeout: timeoutMs,
32
+ })
33
+
34
+ if (!result.failed) return { ok: true }
35
+
36
+ if (result.timedOut) {
37
+ return {
38
+ ok: false,
39
+ reason: 'install-timeout',
40
+ message: `Install timed out after ${Math.round(timeoutMs / 1000)}s. Tried: ${tried}`,
41
+ }
42
+ }
43
+
44
+ const detail = tail(result.all) || String(result.shortMessage ?? '')
45
+ return {
46
+ ok: false,
47
+ reason: 'install-failed',
48
+ message: `Install failed. Tried: ${tried}${detail === '' ? '' : `\n${detail}`}`,
49
+ }
50
+ }
51
+
52
+ function tail(output: unknown): string {
53
+ if (typeof output !== 'string') return ''
54
+ return output.replace(/\n$/, '').split('\n').slice(-TAIL_LINES).join('\n')
55
+ }