@erclx/canon 4.66.0 → 4.68.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 (156) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/{claude-autoship → auto-ship}/REQUIREMENT.md +3 -3
  3. package/claude/skills/{claude-autoship → auto-ship}/SKILL.md +36 -36
  4. package/claude/skills/canon-cli/REQUIREMENT.md +2 -2
  5. package/claude/skills/canon-cli/SKILL.md +2 -2
  6. package/claude/skills/canon-feedback-triage/REQUIREMENT.md +1 -1
  7. package/claude/skills/canon-feedback-triage/SKILL.md +3 -3
  8. package/claude/skills/canon-operator/REQUIREMENT.md +1 -1
  9. package/claude/skills/canon-operator/SKILL.md +4 -4
  10. package/claude/skills/canon-rollout/REQUIREMENT.md +6 -6
  11. package/claude/skills/canon-rollout/SKILL.md +7 -7
  12. package/claude/skills/{claude-design-extract → design-extract}/REQUIREMENT.md +4 -4
  13. package/claude/skills/{claude-design-extract → design-extract}/SKILL.md +1 -1
  14. package/claude/skills/{claude-docs → docs-fold}/REQUIREMENT.md +3 -3
  15. package/claude/skills/{claude-docs → docs-fold}/SKILL.md +14 -14
  16. package/claude/skills/{claude-docs → docs-fold}/references/anchor-sweep.md +1 -1
  17. package/claude/skills/{claude-docs → docs-fold}/references/wireframe-sweep.md +1 -1
  18. package/claude/skills/docs-sync/REQUIREMENT.md +3 -3
  19. package/claude/skills/docs-sync/SKILL.md +1 -1
  20. package/claude/skills/draft-and-pick/REQUIREMENT.md +4 -4
  21. package/claude/skills/draft-and-pick/SKILL.md +6 -6
  22. package/claude/skills/draft-context/REQUIREMENT.md +2 -2
  23. package/claude/skills/draft-context/SKILL.md +3 -3
  24. package/claude/skills/{claude-diagram → draft-diagram}/REQUIREMENT.md +4 -4
  25. package/claude/skills/{claude-diagram → draft-diagram}/SKILL.md +3 -3
  26. package/claude/skills/draft-docs/REQUIREMENT.md +1 -1
  27. package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
  28. package/claude/skills/draft-wireframes/SKILL.md +2 -2
  29. package/claude/skills/git-followup/REQUIREMENT.md +1 -1
  30. package/claude/skills/git-followup/SKILL.md +1 -1
  31. package/claude/skills/git-pr/SKILL.md +3 -3
  32. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  33. package/claude/skills/git-ship/SKILL.md +8 -8
  34. package/claude/skills/git-worktree/REQUIREMENT.md +2 -2
  35. package/claude/skills/git-worktree/SKILL.md +3 -3
  36. package/claude/skills/identity/REQUIREMENT.md +2 -2
  37. package/claude/skills/identity/SKILL.md +3 -3
  38. package/claude/skills/{claude-markdown-propose → markdown-propose}/REQUIREMENT.md +8 -8
  39. package/claude/skills/{claude-markdown-propose → markdown-propose}/SKILL.md +5 -5
  40. package/claude/skills/{claude-markdown-propose → markdown-propose}/references/format.md +1 -1
  41. package/claude/skills/{claude-memory-capture → memory-capture}/REQUIREMENT.md +5 -5
  42. package/claude/skills/{claude-memory-capture → memory-capture}/SKILL.md +13 -13
  43. package/claude/skills/{claude-memory-review → memory-review}/REQUIREMENT.md +4 -4
  44. package/claude/skills/{claude-memory-review → memory-review}/SKILL.md +10 -10
  45. package/claude/skills/migration-context/SKILL.md +1 -1
  46. package/claude/skills/migration-standards-drop/REQUIREMENT.md +1 -1
  47. package/claude/skills/migration-superseded/REQUIREMENT.md +1 -1
  48. package/claude/skills/{claude-feature → plan-feature}/REQUIREMENT.md +4 -4
  49. package/claude/skills/{claude-feature → plan-feature}/SKILL.md +4 -4
  50. package/claude/skills/{claude-groundwork → plan-groundwork}/REQUIREMENT.md +5 -5
  51. package/claude/skills/{claude-groundwork → plan-groundwork}/SKILL.md +8 -8
  52. package/claude/skills/{claude-intake → plan-intake}/REQUIREMENT.md +6 -6
  53. package/claude/skills/{claude-intake → plan-intake}/SKILL.md +10 -10
  54. package/claude/skills/{claude-intake-answer → plan-intake-answer}/REQUIREMENT.md +4 -4
  55. package/claude/skills/{claude-intake-answer → plan-intake-answer}/SKILL.md +5 -5
  56. package/claude/skills/{claude-address-review → review-address}/REQUIREMENT.md +5 -5
  57. package/claude/skills/{claude-address-review → review-address}/SKILL.md +6 -6
  58. package/claude/skills/{claude-address-review → review-address}/references/rebase-conflicts.md +1 -1
  59. package/claude/skills/{claude-review → review-branch}/REQUIREMENT.md +4 -4
  60. package/claude/skills/{claude-review → review-branch}/SKILL.md +4 -4
  61. package/claude/skills/{claude-pr-review → review-pr}/REQUIREMENT.md +5 -5
  62. package/claude/skills/{claude-pr-review → review-pr}/SKILL.md +11 -11
  63. package/claude/skills/{claude-orchestrate → role-orchestrator}/REQUIREMENT.md +5 -5
  64. package/claude/skills/{claude-orchestrate → role-orchestrator}/SKILL.md +20 -20
  65. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-dispatch.md +30 -30
  66. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-handoff.md +2 -2
  67. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-parked.md +8 -8
  68. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-poll.md +8 -8
  69. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-resume.md +1 -1
  70. package/claude/skills/{claude-orchestrate → role-orchestrator}/references/orchestrator-sweep.md +1 -1
  71. package/claude/skills/{claude-orchestrate → role-orchestrator}/scripts/poll.sh +6 -6
  72. package/claude/skills/{claude-planner → role-planner}/REQUIREMENT.md +12 -12
  73. package/claude/skills/{claude-planner → role-planner}/SKILL.md +4 -4
  74. package/claude/skills/{claude-worker → role-worker}/REQUIREMENT.md +8 -8
  75. package/claude/skills/{claude-worker → role-worker}/SKILL.md +6 -6
  76. package/claude/skills/{claude-seed-sync → seed-sync}/REQUIREMENT.md +2 -2
  77. package/claude/skills/{claude-seed-sync → seed-sync}/SKILL.md +3 -3
  78. package/claude/skills/session-map/REQUIREMENT.md +2 -2
  79. package/claude/skills/session-map/SKILL.md +2 -2
  80. package/claude/skills/session-resume/REQUIREMENT.md +2 -2
  81. package/claude/skills/session-resume/SKILL.md +2 -2
  82. package/claude/skills/{claude-worktree → session-worktree}/REQUIREMENT.md +3 -3
  83. package/claude/skills/{claude-worktree → session-worktree}/SKILL.md +6 -6
  84. package/claude/skills/setup-plugins/references/plugin-catalog.md +1 -1
  85. package/claude/skills/{claude-standards-audit → standards-audit}/REQUIREMENT.md +2 -2
  86. package/claude/skills/{claude-standards-audit → standards-audit}/SKILL.md +2 -2
  87. package/claude/skills/systematic-debugging/REQUIREMENT.md +1 -1
  88. package/claude/skills/{claude-tasks → task-board}/REQUIREMENT.md +3 -3
  89. package/claude/skills/{claude-tasks → task-board}/SKILL.md +7 -7
  90. package/claude/skills/{claude-teach → teach-workspace}/REQUIREMENT.md +2 -2
  91. package/claude/skills/{claude-teach → teach-workspace}/SKILL.md +4 -4
  92. package/claude/skills/test-first/REQUIREMENT.md +2 -2
  93. package/claude/skills/{claude-ui-test → ui-test}/REQUIREMENT.md +3 -3
  94. package/claude/skills/{claude-ui-test → ui-test}/SKILL.md +3 -3
  95. package/claude/skills/{claude-ux-audit → ux-audit}/REQUIREMENT.md +6 -6
  96. package/claude/skills/{claude-ux-audit → ux-audit}/SKILL.md +5 -5
  97. package/claude/skills/{claude-ux-measure → ux-measure}/REQUIREMENT.md +5 -5
  98. package/claude/skills/{claude-ux-measure → ux-measure}/SKILL.md +5 -5
  99. package/docs/agents/commands.md +4 -1
  100. package/docs/agents/install-and-sync.md +1 -1
  101. package/docs/agents/key-changes.md +3 -3
  102. package/docs/agents/markdown-audit.md +1 -1
  103. package/docs/agents/restated.md +1 -1
  104. package/docs/agents/review-classification.md +1 -1
  105. package/docs/agents/sessions.md +1 -1
  106. package/docs/agents/state-scoped-risk.md +1 -1
  107. package/docs/agents/targets.md +1 -1
  108. package/docs/agents/tasks.md +4 -4
  109. package/docs/agents/teach.md +1 -1
  110. package/docs/target-projects.md +28 -9
  111. package/docs/workflow/ai-workflow.md +84 -84
  112. package/docs/workflow/operating-model.md +17 -17
  113. package/docs/workflow/visual-design-workflow.md +6 -6
  114. package/governance/rules/core/045-memory.md +1 -1
  115. package/governance/rules/core/085-worktrees.md +1 -1
  116. package/package.json +1 -1
  117. package/scripts/core/regen-agent-fixture.sh +1 -1
  118. package/scripts/core/regen-hero.sh +7 -7
  119. package/snippets/claude/decision-memo.md +1 -1
  120. package/src/autoship/paths.ts +1 -1
  121. package/src/claude/cases/all.ts +2 -2
  122. package/src/claude/cases/{claude-workflow.ts → workflow.ts} +33 -33
  123. package/src/commands/migrate.ts +90 -13
  124. package/src/commands/sync.ts +3 -3
  125. package/src/design/components.ts +1157 -4
  126. package/src/design/css.ts +44 -17
  127. package/src/design/tokens.ts +1 -1
  128. package/src/gov/restated.ts +2 -2
  129. package/src/markdown/structure.ts +1 -1
  130. package/src/migrate/plan.ts +13 -5
  131. package/src/migrate/rename.ts +210 -94
  132. package/src/migrate/skill-names.ts +89 -0
  133. package/src/pr/bijection.ts +1 -1
  134. package/src/pr/paths.ts +2 -2
  135. package/src/shipped/references.ts +1 -1
  136. package/src/sync/seeds-report.ts +1 -1
  137. package/src/targets/pulls.ts +2 -2
  138. package/src/tasks/answers.ts +1 -1
  139. package/src/tasks/archive.ts +4 -4
  140. package/src/tasks/record.ts +2 -2
  141. package/src/tasks/validate.ts +1 -1
  142. package/src/teach/fonts.ts +28 -0
  143. package/src/teach/nav.ts +11 -1
  144. package/src/teach/workspace.ts +4 -1
  145. package/standards/groundwork.md +1 -1
  146. package/standards/snippets.md +1 -1
  147. package/standards/tasks.md +3 -3
  148. package/standards/teach.md +2 -2
  149. package/tooling/base/configs/.husky/post-merge +3 -3
  150. package/tooling/claude/reference.md +5 -5
  151. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
  152. /package/claude/skills/{claude-memory-review → memory-review}/references/receipt-format.md +0 -0
  153. /package/claude/skills/{claude-orchestrate → role-orchestrator}/scripts/watch.sh +0 -0
  154. /package/claude/skills/{claude-teach → teach-workspace}/references/lesson-craft.md +0 -0
  155. /package/claude/skills/{claude-teach → teach-workspace}/references/pedagogy.md +0 -0
  156. /package/claude/skills/{claude-teach → teach-workspace}/references/promotion.md +0 -0
package/src/design/css.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import type { Component } from '@/design/components'
1
2
  import { COMPONENTS } from '@/design/components'
3
+ import type { FontFace } from '@/design/fonts'
2
4
  import { FONT_FACES } from '@/design/fonts'
3
5
  import type { DesignTokens } from '@/design/tokens'
4
6
  import { TOKENS } from '@/design/tokens'
@@ -113,39 +115,52 @@ ${pairs}
113
115
  }`
114
116
  }
115
117
 
116
- function componentBlock(): string {
117
- return COMPONENTS.map(
118
- (component) => `/* ${component.name}
118
+ function componentBlock(components: readonly Component[]): string {
119
+ return components
120
+ .map(
121
+ (component) => `/* ${component.name}
119
122
  ${component.note} */
120
123
 
121
124
  ${component.rules}`,
122
- ).join('\n\n')
125
+ )
126
+ .join('\n\n')
123
127
  }
124
128
 
125
129
  /**
126
- * `@font-face` rules carrying the mono stack's primary family as base64, so a
127
- * stylesheet renders in the same typeface everywhere regardless of what the
128
- * reader's machine has installed. Only the teach stylesheet opts in today.
130
+ * `@font-face` rules carrying a font list as base64, so a stylesheet renders
131
+ * in the same typeface everywhere regardless of what the reader's machine has
132
+ * installed. Defaults to the mono stack's primary family; teach passes its own
133
+ * three faces instead of widening this default for every consumer.
129
134
  */
130
- function fontFaceBlock(): string {
131
- return FONT_FACES.map(
132
- (face) => `@font-face {
135
+ function fontFaceBlock(faces: readonly FontFace[]): string {
136
+ return faces
137
+ .map(
138
+ (face) => `@font-face {
133
139
  font-family: '${face.family}';
134
140
  font-weight: ${face.weight};
135
141
  font-style: normal;
136
142
  font-display: swap;
137
143
  src: url(data:font/woff2;base64,${face.base64}) format('woff2');
138
144
  }`,
139
- ).join('\n\n')
145
+ )
146
+ .join('\n\n')
140
147
  }
141
148
 
142
149
  export interface CssOptions {
143
150
  /** Prepended as a comment, naming what wrote the file and from where. */
144
151
  readonly banner?: string
145
- /** Component rules ride along by default; a token-only consumer opts out. */
146
- readonly components?: boolean
147
- /** Off by default. Embeds the mono stack's faces as base64 `@font-face` rules. */
148
- readonly embedFonts?: boolean
152
+ /**
153
+ * Component rules ride along by default; a token-only consumer opts out
154
+ * with `false`. Pass an explicit list, such as teach's own chrome set, to
155
+ * emit those instead of the generic default.
156
+ */
157
+ readonly components?: boolean | readonly Component[]
158
+ /**
159
+ * Off by default. `true` embeds the mono stack's faces as base64
160
+ * `@font-face` rules. Pass an explicit list, such as teach's three faces,
161
+ * to embed those instead.
162
+ */
163
+ readonly embedFonts?: boolean | readonly FontFace[]
149
164
  }
150
165
 
151
166
  export function buildDesignCss(
@@ -155,10 +170,22 @@ export function buildDesignCss(
155
170
  const banner =
156
171
  options.banner === undefined ? '' : `/* ${options.banner} */\n\n`
157
172
  const root = [':root {', ...tokenProperties(tokens), '}'].join('\n')
158
- const parts = options.embedFonts ? [fontFaceBlock(), root] : [root]
173
+ const faces =
174
+ options.embedFonts === true
175
+ ? FONT_FACES
176
+ : Array.isArray(options.embedFonts)
177
+ ? options.embedFonts
178
+ : undefined
179
+ const parts = faces ? [fontFaceBlock(faces), root] : [root]
159
180
  parts.push(lightBlock(tokens))
160
181
 
161
- if (options.components !== false) parts.push(componentBlock())
182
+ const components =
183
+ options.components === false
184
+ ? undefined
185
+ : Array.isArray(options.components)
186
+ ? options.components
187
+ : COMPONENTS
188
+ if (components) parts.push(componentBlock(components))
162
189
 
163
190
  return `${banner}${parts.join('\n\n')}\n`
164
191
  }
@@ -354,7 +354,7 @@ export const TOKENS: DesignTokens = {
354
354
  'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
355
355
 
356
356
  iconography:
357
- "No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing. The same mark also ships as a favicon on every rendered surface, as three independently-maintained copies that track different accents by design rather than by drift: `regen-hero.sh` derives one from the live SVG colored with whatever `--color-accent` (`#e0724b`) the fetched token CSS carries, `src/design/render.ts` carries the path data as a hardcoded literal colored via `colorValue('light-accent')` (`#a4471c`), since a data URI has no CSS context and that page renders on light chrome, and `claude-teach`'s `SKILL.md` names one in prose colored `rgb(224,114,75)`, the same value as the dark accent written as decimal rather than hex to clear the shipped-references gate's commit-sha check. Unifying the three or repairing the one that looks drifted would break the fit each was chosen for.",
357
+ "No icon library is installed. `assets/brand/mark.svg` is the one authored icon, embedded inline in the hero topbar, and the surfaces otherwise draw literal glyph characters: `│ ├ ✓ ! ✗ + - ◆ ◇ ❯` for the terminal framing. The same mark also ships as a favicon on every rendered surface, as three independently-maintained copies that track different accents by design rather than by drift: `regen-hero.sh` derives one from the live SVG colored with whatever `--color-accent` (`#e0724b`) the fetched token CSS carries, `src/design/render.ts` carries the path data as a hardcoded literal colored via `colorValue('light-accent')` (`#a4471c`), since a data URI has no CSS context and that page renders on light chrome, and `teach-workspace`'s `SKILL.md` names one in prose colored `rgb(224,114,75)`, the same value as the dark accent written as decimal rather than hex to clear the shipped-references gate's commit-sha check. Unifying the three or repairing the one that looks drifted would break the fit each was chosen for.",
358
358
  }
359
359
 
360
360
  /** A role's value, or `undefined` where the record declares no such role. */
@@ -24,7 +24,7 @@ export const RULES_REL = join('governance', 'rules')
24
24
  /**
25
25
  * Path pairs whose duplication is deliberate and already recorded.
26
26
  *
27
- * The seed is authored from the always-loaded file and `claude-seed-sync`
27
+ * The seed is authored from the always-loaded file and `seed-sync`
28
28
  * exists to reconcile the two, so a bullet appearing in both is the design
29
29
  * rather than a defect. Excluding by pair rather than by content is what the
30
30
  * plan settled on: the duplication is a location fact this repository already
@@ -574,7 +574,7 @@ function authorityFor(
574
574
  if (candidate.kind === 'seed') {
575
575
  return {
576
576
  authority: 'claude-md',
577
- reason: `${INSTRUCTIONS_REL} is authored first and the seed carries it to a target, so an edit starts there and reaches the seed through claude-seed-sync`,
577
+ reason: `${INSTRUCTIONS_REL} is authored first and the seed carries it to a target, so an edit starts there and reaches the seed through seed-sync`,
578
578
  }
579
579
  }
580
580
 
@@ -26,7 +26,7 @@ const HEADING = /^#{1,6}\s/
26
26
  * run and a false one shortens every run around it until the measure stops
27
27
  * reporting, which is the dearer of the two.
28
28
  *
29
- * A colon ends most markers and not all of them. `claude-pr-review` writes
29
+ * A colon ends most markers and not all of them. `review-pr` writes
30
30
  * three colon-less ones into every body it posts and a bold path heading for
31
31
  * each file it reviews, so requiring the colon held a real seam out. Two
32
32
  * signals stand in where the colon is absent, because the shape a marker has to
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  isExcludedPath,
3
3
  renamePath,
4
+ type RenameRules,
4
5
  renameText,
5
6
  scanText,
6
7
  } from '@/migrate/rename'
@@ -59,20 +60,27 @@ export function isToolkitOwned(path: string): boolean {
59
60
  * reported or applied. A file whose content and path both stay put is dropped
60
61
  * rather than carried as a no-op entry, which keeps the reported count equal
61
62
  * to the number of files the sweep actually changes.
63
+ *
64
+ * The rules arrive as an argument rather than being read from a module, since
65
+ * this planner serves every rename the engine compiles and the four calls
66
+ * below have no other way to say which one they mean.
62
67
  */
63
- export function planRename(sources: readonly RenameSource[]): RenamePlan {
68
+ export function planRename(
69
+ sources: readonly RenameSource[],
70
+ rules: RenameRules,
71
+ ): RenamePlan {
64
72
  const entries: RenameEntry[] = []
65
73
  const excluded: string[] = []
66
74
 
67
75
  for (const source of sources) {
68
- if (isExcludedPath(source.path)) {
76
+ if (isExcludedPath(source.path, rules)) {
69
77
  excluded.push(source.path)
70
78
  continue
71
79
  }
72
80
 
73
- const movesTo = renamePath(source.path)
74
- const rewritten = renameText(source.text)
75
- const counts = scanText(source.text)
81
+ const movesTo = renamePath(source.path, rules)
82
+ const rewritten = renameText(source.text, rules)
83
+ const counts = scanText(source.text, rules)
76
84
  const moved = movesTo !== source.path
77
85
  const changed = rewritten !== source.text
78
86
 
@@ -1,98 +1,154 @@
1
1
  /**
2
- * The token rewrite behind the `aitk` to `canon` rename.
2
+ * The token rewrite behind a mechanical rename.
3
3
  *
4
4
  * The sweep is mechanical and its danger is entirely in what it must not
5
5
  * touch, so the scanner is one pass with the protected forms tried first
6
6
  * rather than a chain of replacements. A chain reprocesses its own output,
7
7
  * which is how a protected form that contains the token gets rewritten by a
8
8
  * later rule that cannot see it was already decided.
9
+ *
10
+ * The token map, the protected forms, and the exclusion set are a parameter
11
+ * rather than module state, so one engine serves more than one rename. Two
12
+ * sweeps sharing a module constant would have to agree on a single map, and
13
+ * the second rename this repository needed shares nothing with the first
14
+ * except the scanning discipline above.
9
15
  */
10
16
 
11
17
  /**
12
- * Forms carrying the token that name something other than this tool, matched
13
- * ahead of the token itself so they pass through untouched.
18
+ * An article whose agreement the rename breaks.
14
19
  *
15
- * `aitk-sandbox` is a separate repository that is not being renamed. It has to
16
- * win against the bare token, and it also has to win against the owner-scoped
17
- * spelling, since `erclx/aitk-sandbox` would otherwise rewrite to
18
- * `erclx/canon-sandbox` and name a repository that does not exist.
20
+ * Stated as a pattern and a replacement rather than a function so a preset is
21
+ * data a test can read back. It runs against the already-rewritten line, which
22
+ * is what keeps it clear of the protected forms: a form the scanner passed
23
+ * through still spells the old token afterward, so it never matches here.
19
24
  */
20
- const PROTECTED = ['aitk-sandbox'] as const
25
+ export interface ArticleFixup {
26
+ readonly pattern: RegExp
27
+ readonly replacement: string
28
+ }
29
+
30
+ /** One rename's rules, as an author states them. */
31
+ export interface RenameRuleSpec {
32
+ /** Every spelling of the token, and what each becomes. */
33
+ readonly replacements: Readonly<Record<string, string>>
34
+ /** Marks a line that names a retired spelling on purpose. */
35
+ readonly keepMarker: string
36
+ /** Forms carrying a token that name something the rename leaves alone. */
37
+ readonly protectedForms?: readonly string[]
38
+ /** Files whose content is left alone entirely. */
39
+ readonly excludedPaths?: readonly string[]
40
+ /** Path prefixes whose files are left alone entirely. */
41
+ readonly excludedPrefixes?: readonly string[]
42
+ readonly articleFixups?: readonly ArticleFixup[]
43
+ /**
44
+ * Whether a token has to end where the word ends.
45
+ *
46
+ * A rename whose tokens are whole names wants this, and one whose tokens are
47
+ * word stems cannot have it. `aitk` is a stem that legitimately carries a
48
+ * suffix, as in `aitk-allow-superseded`, so requiring a boundary there would
49
+ * leave every hyphenated form behind. A skill name is not a stem, and the
50
+ * spelling below is the pre-rename one on purpose (canon-keep-retired):
51
+ * `claude-worktree` inside `claude-worktrees` is a different subject, the
52
+ * wiki page about the harness feature, which the rename must not move.
53
+ */
54
+ readonly wholeToken?: boolean
55
+ }
21
56
 
22
57
  /**
23
- * Every spelling of the token, and what each becomes. Case is carried in the
24
- * map rather than derived, because the uppercase form is an environment
25
- * variable prefix and the title-case form is a heading word, and a derived
26
- * transform would have to guess which convention it was looking at.
58
+ * A spec with its scanner compiled and its alternatives ordered.
59
+ *
60
+ * `tokenOrder` is reported rather than kept private because the ordering is a
61
+ * correctness property a caller has to be able to assert. A token containing a
62
+ * shorter token has to be tried first, and reading that off the rewritten
63
+ * string only works when the two happen to share a destination, which is
64
+ * correctness by accident rather than by rule.
27
65
  */
28
- const REPLACEMENT: Readonly<Record<string, string>> = {
29
- aitk: 'canon',
30
- AITK: 'CANON',
31
- Aitk: 'Canon',
66
+ export interface RenameRules {
67
+ readonly replacements: Readonly<Record<string, string>>
68
+ readonly keepMarker: string
69
+ readonly protectedForms: readonly string[]
70
+ readonly tokenOrder: readonly string[]
71
+ readonly excludedPaths: readonly string[]
72
+ readonly excludedPrefixes: readonly string[]
73
+ readonly articleFixups: readonly ArticleFixup[]
74
+ readonly scan: RegExp
32
75
  }
33
76
 
34
77
  /**
35
- * One alternation so the engine decides each position once. Group 1 is a
36
- * protected form and group 2 is a token to rewrite, and the protected branch
37
- * sits first because a regex alternation is ordered.
78
+ * A branch that can never take, standing in for an empty protected list.
79
+ *
80
+ * The scanner reads a protected match off capture group 1, so a preset that
81
+ * protects nothing still has to emit that group or every later group shifts by
82
+ * one. An empty alternation would match the empty string at every position
83
+ * instead, which reports a protected hit on every character.
38
84
  */
39
- const SCAN = new RegExp(
40
- `(${PROTECTED.join('|')})|(${Object.keys(REPLACEMENT).join('|')})`,
41
- 'g',
42
- )
85
+ const NEVER_MATCHES = '(?!)'
43
86
 
44
87
  /**
45
- * Files whose content is left alone entirely.
88
+ * What may not follow a token when a preset asks for whole tokens.
46
89
  *
47
- * The changelog is release history. Its entries record what shipped under the
48
- * old name, so rewriting them falsifies the record, and the pull request links
49
- * it carries keep resolving because GitHub redirects a renamed repository's
50
- * old URLs.
51
- *
52
- * The sweep's own source is the other member, and it is not a preference. This
53
- * module states the token map as literal keys, so rewriting it turns every key
54
- * into its own replacement and leaves a rewriter that maps `canon` to `canon`
55
- * and matches nothing. Its tests name both spellings on purpose for the same
56
- * reason, and the command's help text documents the old name a caller is
57
- * migrating off. Whatever these four files should say after the rename is
58
- * written by hand, because the sweep cannot be the thing that decides it.
90
+ * A plain word boundary rejects a following letter and accepts a following
91
+ * hyphen, since `\b` reads a hyphen as the end of a word. That leaves
92
+ * `plan-intake` matching inside `plan-intake-answer`, with the ordering of
93
+ * the alternation the only thing standing between them. Naming the characters
94
+ * that continue an identifier holds on both, so the ordering and the boundary
95
+ * each cover what the other could miss, and a slash, a dot, or a backtick
96
+ * still ends a token.
59
97
  */
98
+ const TOKEN_TAIL = '(?![A-Za-z0-9_-])'
99
+
100
+ function escapeForPattern(value: string): string {
101
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
102
+ }
103
+
60
104
  /**
61
- * An eval result is a transcript. It records the commands a session actually
62
- * ran and the paths it actually opened, under whatever name was current when
63
- * the run happened, so rewriting one makes it testify to a session that never
64
- * took place. The changelog is excluded for the same reason and differs only
65
- * in living at a fixed path.
105
+ * Longest first, so a token is never consumed by a shorter token it contains.
106
+ *
107
+ * The sort is stable, so alternatives of equal length keep the order the
108
+ * author wrote them in and a preset whose tokens are all one length compiles
109
+ * to the alternation it already had.
66
110
  */
67
- const EXCLUDED_PREFIXES: readonly string[] = [
68
- 'src/migrate/',
69
- 'scripts/eval/result-',
70
- ]
111
+ function longestFirst(values: readonly string[]): readonly string[] {
112
+ return [...values].sort((left, right) => right.length - left.length)
113
+ }
71
114
 
72
115
  /**
73
- * The four test files below exist to prove the retired spellings still
74
- * resolve, so both names appear in each on purpose. Rewriting one is worse
75
- * than a broken test: the retired-variable case would collapse into a copy of
76
- * the current-variable case beside it and keep passing, reporting coverage for
77
- * a fallback nothing exercises any more.
116
+ * One alternation so the engine decides each position once. Group 1 is a
117
+ * protected form and group 2 is a token to rewrite, and the protected branch
118
+ * sits first because a regex alternation is ordered.
78
119
  */
79
- const EXCLUDED_PATHS: readonly string[] = [
80
- 'CHANGELOG.md',
81
- 'src/commands/migrate.ts',
82
- 'src/sync/stamp.test.ts',
83
- 'src/targets/registry.test.ts',
84
- 'src/targets/sweep.test.ts',
85
- 'src/ui.test.ts',
86
- ]
120
+ function compileScan(
121
+ protectedForms: readonly string[],
122
+ tokenOrder: readonly string[],
123
+ wholeToken: boolean,
124
+ ): RegExp {
125
+ const guarded =
126
+ protectedForms.length > 0
127
+ ? protectedForms.map(escapeForPattern).join('|')
128
+ : NEVER_MATCHES
87
129
 
88
- export interface ScanCount {
89
- readonly renamed: number
90
- readonly protectedCount: number
130
+ const tail = wholeToken ? TOKEN_TAIL : ''
131
+
132
+ return new RegExp(
133
+ `(${guarded})|(${tokenOrder.map(escapeForPattern).join('|')})${tail}`,
134
+ 'g',
135
+ )
91
136
  }
92
137
 
93
- export function isExcludedPath(path: string): boolean {
94
- if (EXCLUDED_PATHS.includes(path)) return true
95
- return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))
138
+ export function defineRenameRules(spec: RenameRuleSpec): RenameRules {
139
+ const protectedForms = longestFirst(spec.protectedForms ?? [])
140
+ const tokenOrder = longestFirst(Object.keys(spec.replacements))
141
+
142
+ return {
143
+ replacements: spec.replacements,
144
+ keepMarker: spec.keepMarker,
145
+ protectedForms,
146
+ tokenOrder,
147
+ excludedPaths: spec.excludedPaths ?? [],
148
+ excludedPrefixes: spec.excludedPrefixes ?? [],
149
+ articleFixups: spec.articleFixups ?? [],
150
+ scan: compileScan(protectedForms, tokenOrder, spec.wholeToken === true),
151
+ }
96
152
  }
97
153
 
98
154
  /**
@@ -100,54 +156,114 @@ export function isExcludedPath(path: string): boolean {
100
156
  * agrees with the replacement.
101
157
  *
102
158
  * The old name opens on a vowel sound and the new one does not, so every
103
- * `an aitk` in the corpus reads wrong the moment the token moves. This matches
104
- * against the already-rewritten text rather than the source, which is what
105
- * keeps it clear of the protected forms: `an aitk-sandbox` still says
106
- * `aitk-sandbox` afterward, so it never matches here.
159
+ * `an aitk` in the corpus reads wrong the moment the token moves.
107
160
  *
108
161
  * The tail rejects a following letter rather than asking for a word boundary,
109
162
  * which is what separates `an canonical` from `an CANON_STATE_DIR`. A boundary
110
163
  * treats the underscore as part of the word and declines the environment
111
164
  * variable, where the whole identifier is the token continuing.
112
165
  */
113
- const ARTICLE = /\b([Aa])n(\s+`?)(canon|CANON|Canon)(?![A-Za-z])/g
166
+ const AITK_ARTICLE = /\b([Aa])n(\s+`?)(canon|CANON|Canon)(?![A-Za-z])/g
114
167
 
115
168
  /**
116
- * Marks a line that names the retired spelling on purpose.
169
+ * The `aitk` to `canon` rename.
117
170
  *
118
- * A fallback path, a retired environment variable, and a dictionary entry
119
- * covering the record corpora all have to keep saying the old name, and a
120
- * second run over an already-renamed tree would otherwise strip exactly the
121
- * compatibility this rename shipped. The marker sits on the line itself or on
122
- * the one above it, which is the same placement `canon-allow-superseded`
123
- * already uses in this repository.
171
+ * `aitk-sandbox` is a separate repository that is not being renamed. It has to
172
+ * win against the bare token, and it also has to win against the owner-scoped
173
+ * spelling, since `erclx/aitk-sandbox` would otherwise rewrite to
174
+ * `erclx/canon-sandbox` and name a repository that does not exist.
175
+ *
176
+ * Case is carried in the map rather than derived, because the uppercase form
177
+ * is an environment variable prefix and the title-case form is a heading word,
178
+ * and a derived transform would have to guess which convention it was looking
179
+ * at.
180
+ *
181
+ * The changelog is release history. Its entries record what shipped under the
182
+ * old name, so rewriting them falsifies the record, and the pull request links
183
+ * it carries keep resolving because GitHub redirects a renamed repository's
184
+ * old URLs. An eval result is a transcript on the same argument, recording the
185
+ * commands a session actually ran under whatever name was current then.
186
+ *
187
+ * The sweep's own source is the other excluded member, and it is not a
188
+ * preference. This module states the `aitk` map as literal keys, so rewriting
189
+ * it turns every key into its own replacement and leaves a rewriter that maps
190
+ * `canon` to `canon` and matches nothing.
191
+ *
192
+ * The four test files below exist to prove the retired spellings still
193
+ * resolve, so both names appear in each on purpose. Rewriting one is worse
194
+ * than a broken test: the retired-variable case would collapse into a copy of
195
+ * the current-variable case beside it and keep passing, reporting coverage for
196
+ * a fallback nothing exercises any more.
124
197
  */
125
- const KEEP_MARKER = 'canon-keep-retired'
198
+ export const AITK_RULES: RenameRules = defineRenameRules({
199
+ replacements: {
200
+ aitk: 'canon',
201
+ AITK: 'CANON',
202
+ Aitk: 'Canon',
203
+ },
204
+ keepMarker: 'canon-keep-retired',
205
+ protectedForms: ['aitk-sandbox'],
206
+ excludedPrefixes: ['src/migrate/', 'scripts/eval/result-'],
207
+ excludedPaths: [
208
+ 'CHANGELOG.md',
209
+ 'src/commands/migrate.ts',
210
+ 'src/sync/stamp.test.ts',
211
+ 'src/targets/registry.test.ts',
212
+ 'src/targets/sweep.test.ts',
213
+ 'src/ui.test.ts',
214
+ ],
215
+ articleFixups: [{ pattern: AITK_ARTICLE, replacement: '$1$2$3' }],
216
+ })
217
+
218
+ export interface ScanCount {
219
+ readonly renamed: number
220
+ readonly protectedCount: number
221
+ }
222
+
223
+ export function isExcludedPath(path: string, rules: RenameRules): boolean {
224
+ if (rules.excludedPaths.includes(path)) return true
225
+ return rules.excludedPrefixes.some((prefix) => path.startsWith(prefix))
226
+ }
126
227
 
127
228
  /** Rewrites every unprotected spelling of the token. */
128
- export function renameText(text: string): string {
229
+ export function renameText(text: string, rules: RenameRules): string {
129
230
  const lines = text.split('\n')
130
231
  const rewritten = lines.map((line, index) =>
131
- isKept(lines, index) ? line : renameLine(line),
232
+ isKept(lines, index, rules) ? line : renameLine(line, rules),
132
233
  )
133
234
 
134
235
  return rewritten.join('\n')
135
236
  }
136
237
 
137
- function isKept(lines: readonly string[], index: number): boolean {
138
- if (lines[index]?.includes(KEEP_MARKER)) return true
139
- return index > 0 && (lines[index - 1]?.includes(KEEP_MARKER) ?? false)
238
+ /**
239
+ * Whether a line names a retired spelling on purpose.
240
+ *
241
+ * A fallback path, a retired environment variable, and a dictionary entry
242
+ * covering the record corpora all have to keep saying the old name, and a
243
+ * second run over an already-renamed tree would otherwise strip exactly the
244
+ * compatibility a rename shipped. The marker sits on the line itself or on the
245
+ * one above it, which is the same placement `canon-allow-superseded` already
246
+ * uses in this repository.
247
+ */
248
+ function isKept(
249
+ lines: readonly string[],
250
+ index: number,
251
+ rules: RenameRules,
252
+ ): boolean {
253
+ if (lines[index]?.includes(rules.keepMarker)) return true
254
+ return index > 0 && (lines[index - 1]?.includes(rules.keepMarker) ?? false)
140
255
  }
141
256
 
142
- function renameLine(line: string): string {
143
- const replaced = line.replace(SCAN, (match, guarded: string | undefined) =>
144
- guarded === undefined ? REPLACEMENT[match] : guarded,
257
+ function renameLine(line: string, rules: RenameRules): string {
258
+ const replaced = line.replace(
259
+ rules.scan,
260
+ (match, guarded: string | undefined) =>
261
+ guarded === undefined ? rules.replacements[match] : guarded,
145
262
  )
146
263
 
147
- return replaced.replace(
148
- ARTICLE,
149
- (_match, article: string, gap: string, token: string) =>
150
- `${article}${gap}${token}`,
264
+ return rules.articleFixups.reduce(
265
+ (text, fixup) => text.replace(fixup.pattern, fixup.replacement),
266
+ replaced,
151
267
  )
152
268
  }
153
269
 
@@ -156,15 +272,15 @@ function renameLine(line: string): string {
156
272
  * from a diff so a run can say how much it protected, which is the number a
157
273
  * reader needs to trust that the exclusions fired at all.
158
274
  */
159
- export function scanText(text: string): ScanCount {
275
+ export function scanText(text: string, rules: RenameRules): ScanCount {
160
276
  let renamed = 0
161
277
  let protectedCount = 0
162
278
  const lines = text.split('\n')
163
279
 
164
280
  for (const [index, line] of lines.entries()) {
165
- if (isKept(lines, index)) continue
281
+ if (isKept(lines, index, rules)) continue
166
282
 
167
- for (const [, guarded] of line.matchAll(SCAN)) {
283
+ for (const [, guarded] of line.matchAll(rules.scan)) {
168
284
  if (guarded === undefined) renamed += 1
169
285
  else protectedCount += 1
170
286
  }
@@ -178,6 +294,6 @@ export function scanText(text: string): ScanCount {
178
294
  * a protected form appearing in a path is protected there too and the two
179
295
  * halves of the sweep cannot disagree about what the token means.
180
296
  */
181
- export function renamePath(path: string): string {
182
- return renameText(path)
297
+ export function renamePath(path: string, rules: RenameRules): string {
298
+ return renameText(path, rules)
183
299
  }