@erclx/canon 4.43.0 → 4.45.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.
package/README.md CHANGED
@@ -59,19 +59,19 @@ Each domain has a canonical source in this repo and a thin install or sync CLI o
59
59
 
60
60
  Those domains split on one line: some are copied into your project and become yours to edit, and some are never copied at all.
61
61
 
62
- ![canon tooling list and canon standards list side by side, the first showing five stacks with their dependency and script counts, the second showing standards against the artifact each governs](assets/showcase-install-surface.png)
62
+ ![canon tooling list and canon standards list side by side, the first showing five stacks with their dependency and script counts, the second showing standards against the artifact each governs](assets/install-surface.png)
63
63
 
64
64
  A tooling stack lands as real files under version control, because a config is something your build reads and your project owns. A standard stays here and is opened by name, so there is no copy in your repo to drift from this one.
65
65
 
66
66
  Governance is the third shape, and it is worth seeing rather than reading about, because the glob beside each rule is what decides whether it reaches a session at all.
67
67
 
68
- ![canon gov list, showing seven stacks with the rules each carries and a sample of rules beside the path glob that loads each one](assets/showcase-governance.png)
68
+ ![canon gov list, showing seven stacks with the rules each carries and a sample of rules beside the path glob that loads each one](assets/governance.png)
69
69
 
70
70
  Stacks compose, so a react project inherits node and node inherits base. A rule with a glob loads only when a matching path is edited, and a rule with none loads every session. Both counts and every row above are read from the catalogs when the image is built.
71
71
 
72
72
  The toolkit tracks its own work the same way, in a task board no catalog can read back.
73
73
 
74
- ![the task board's Run now table, one row per task in flight, each naming the files it touches and the plan it runs under](assets/showcase-task-board.png)
74
+ ![the task board's Run now table, one row per task in flight, each naming the files it touches and the plan it runs under](assets/task-board.png)
75
75
 
76
76
  `.canon/tasks/` is gitignored session scratch, so unlike the two frames above, this one is a hand-taken snapshot rather than something the build reads live, and it goes stale the moment the board moves.
77
77
 
@@ -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.43.0",
4
+ "version": "4.45.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -47,7 +47,7 @@ Read these in parallel from the current worktree root (`pwd`), not the main work
47
47
  - `.claude/REQUIREMENTS.md`
48
48
  - `.claude/ARCHITECTURE.md`
49
49
  - `.claude/DESIGN.md`
50
- - `.claude/wireframes/index.md` and every `.claude/wireframes/<surface>.md`
50
+ - `.claude/wireframes/index.md` and every surface file it links to, following a grouped surface's own `index.md` and the siblings it lists rather than stopping at the top-level folder
51
51
 
52
52
  Read the task board from the main worktree root instead, resolving that root the way `claude-worktree` does. It is gitignored scratch and never commits with the branch:
53
53
 
@@ -61,7 +61,7 @@ Review the session for decisions that diverged from the original plan:
61
61
 
62
62
  - Requirements added, removed, or changed scope
63
63
  - Architecture or technical decisions made or revised
64
- - Design or UX decisions that differ from DESIGN.md or any `.claude/wireframes/<surface>.md`
64
+ - Design or UX decisions that differ from DESIGN.md or any wireframe surface file, flat or nested under its own subfolder
65
65
  - Tasks blocked or newly identified
66
66
 
67
67
  Then resolve the diff baseline and match it against the board. From `.canon/tasks/index.md` at the main worktree root, pick the task files whose title or description relates to the changed paths and read the ones Step 1 skipped.
@@ -104,7 +104,7 @@ The verb resolves the board at the main worktree root in-process, which is the r
104
104
 
105
105
  Read `ok` and `reason` out of that record rather than the exit. An operator's shell profile may wrap `canon` in a function that runs the binary and then a second command and takes the second status, which flattens every non-zero exit to zero. A refusal arriving as success leaves the outcome unmarked while the chain moves on, so the board reports shipped work as open and the next session re-plans it.
106
106
 
107
- **REQUIREMENTS.md, ARCHITECTURE.md, DESIGN.md, `.claude/wireframes/<surface>.md`**
107
+ **REQUIREMENTS.md, ARCHITECTURE.md, DESIGN.md, a wireframe surface file (flat or nested)**
108
108
 
109
109
  - Update only the sections affected by session decisions.
110
110
  - Do not rewrite sections unrelated to what changed.
@@ -9,7 +9,7 @@ Mechanics for Step 4 of `claude-docs`. The body owns the skip conditions and the
9
9
 
10
10
  ## Deriving a candidate slug
11
11
 
12
- For each UI-affecting path, derive a candidate surface slug from the file's basename and parent folder (e.g. `web/src/features/mock/MockDemoStrip.tsx` → `mock-demo-strip` or `mock`). Cross-reference against the surface files in `.claude/wireframes/`.
12
+ For each UI-affecting path, derive a candidate surface slug from the file's basename and parent folder (e.g. `web/src/features/mock/MockDemoStrip.tsx` → `mock-demo-strip` or `mock`). Cross-reference against every surface file in `.claude/wireframes/`, including one nested inside a grouped surface's own subfolder (`.claude/wireframes/<group>/<surface>.md`), by walking the tree rather than globbing the top level alone.
13
13
 
14
14
  ## Findings
15
15
 
@@ -32,7 +32,7 @@ TODO: describe when and where this surface appears.
32
32
  - TODO
33
33
  ```
34
34
 
35
- Skip the write when the slug would collide with an existing file (different surface, same slug). Surface the collision in the report instead.
35
+ Skip the write when the slug would collide with an existing file, flat or nested (different surface, same slug). Surface the collision in the report instead.
36
36
 
37
37
  ## Output
38
38
 
@@ -23,7 +23,7 @@ Read these in parallel from the project root, skipping any that do not exist:
23
23
  Also read these when the feature touches code or UI. Skip them for prose, docs, catalog, or config-only changes:
24
24
 
25
25
  - `.claude/DESIGN.md`: tokens, typography, spacing, and component rules
26
- - `.claude/wireframes/index.md` + the surface files relevant to the feature: intended UI layout and behavior. Read `index.md` first, then load only the per-surface files (`.claude/wireframes/<surface>.md`) the feature actually touches. Do not read the whole folder speculatively.
26
+ - `.claude/wireframes/index.md` + the surface files relevant to the feature: intended UI layout and behavior. Read `index.md` first, then follow only the links the feature actually touches, each a flat `.claude/wireframes/<surface>.md` or a grouped surface's own `.claude/wireframes/<surface>/index.md` and the siblings it lists. Do not read the whole folder speculatively.
27
27
 
28
28
  When the plan adds or revises a surface, the wireframe file follows `${CLAUDE_SKILL_DIR}/../../standards/wireframes.md`.
29
29
 
@@ -5,27 +5,29 @@ description: Rendering HTML sources to PNG, what the command asserts about fonts
5
5
 
6
6
  # Capture
7
7
 
8
- `canon capture [source] --selector <sel>` renders HTML sources to PNG, which is how a generated documentation image is rebuilt from the markup it was generated out of. The source defaults to `assets/`, where a directory expands to every `.html` directly inside it, so adding a capture means dropping a file beside the first one and running the same command. That default names this repository's own folder rather than a convention every target shares, and it stays because a missing one still refuses loud, naming the argument, rather than failing silently.
8
+ `canon capture [source] --selector <sel>` renders HTML sources to PNG, which is how a generated documentation image is rebuilt from the markup it was generated out of. The source defaults to `assets/`, where a directory expands to every `.html` directly inside it, so adding a capture means dropping a file beside the first one and running the same command. That default is a generic starting point rather than a convention every target shares, and it stays because a missing folder still refuses loud, naming the argument, rather than failing silently. This repository no longer takes it: its own sources moved to `assets/captures/` and its images stayed in `assets/`, so a run here names the source explicitly and sends the output where that run wants it, `--out assets` to rebuild the committed images and somewhere disposable to preview them.
9
9
 
10
10
  ```bash
11
11
  canon capture --selector .window
12
- canon capture assets/install.html --selector .window
13
- canon capture assets --selector .window --out .canon/review/captures
12
+ canon capture assets/captures/install.html --selector .window --out assets
13
+ canon capture assets/captures --selector .window --out .canon/review/captures
14
14
  ```
15
15
 
16
16
  `--selector` is required and every example above passes it. The element a capture crops to belongs to the page, not to the command, so there is no value that could be right for an arbitrary project's markup. `.window` is what this repository's own five sources declare, and a project renders its own pages by naming whatever theirs declare.
17
17
 
18
18
  ## What this repository captures
19
19
 
20
- `assets/` here holds five sources, so one run over the folder rebuilds every one. None is edited by hand. `scripts/core/regen-hero.sh` writes each from a template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
20
+ A capture set here spans two folders. `assets/captures/` holds five sources and the template each is written from, and `assets/` holds the five images a document points at with the stamp answering for each. One run over the source folder rebuilds every image, and it takes `--out assets` to put each PNG where its document points rather than beside the markup it rendered. None of the five is edited by hand. `scripts/core/regen-hero.sh` writes each `.html` from the template beside it, filling one shared value map into all of them, and `bun run check` regenerates them and fails on the difference.
21
21
 
22
- Three of the five take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` and `showcase-task-board.html` are the two exceptions: the first holds terminal text from a real run and the second holds a hand-frozen snapshot of a gitignored board, each in its template rather than derived from a live catalog at build time.
22
+ Three of the five take catalog data, so a stack gaining a rule moves the frame on the next run. `install.html` and `task-board.html` are the two exceptions: the first holds terminal text from a real run and the second holds a hand-frozen snapshot of a gitignored board, each in its template rather than derived from a live catalog at build time.
23
23
 
24
- The folder is read flat and never descends, by the regeneration script, by this command, and by the drift stage alike. A source in a subfolder is skipped by all three with nothing reported, so a new frame takes a name prefix rather than a folder of its own.
24
+ `assets/captures/` is read flat and never descends, by the regeneration script, by this command, and by the drift stage alike. A source nested a further folder down is skipped by all three with nothing reported, so a new frame is a template dropped directly in rather than a folder of its own. The split is what retired the name prefix the flat layout used to need: a frame is named for itself now, since `assets/` no longer mixes markup in with the images.
25
25
 
26
- Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets --selector .window` when the check reports the HTML changed.
26
+ Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets/captures --selector .window --out assets` when the check reports the HTML changed.
27
27
 
28
- Every render writes a stamp beside its PNG, `hero.png` next to `hero.stamp`, holding the source filename, a `source-sha256` over the markup bytes it read, and an `image-sha256` over the image bytes it wrote. Both digests are what `bun run check` compares, so a markup edit committed without a capture and a PNG swapped under unchanged markup each fail. The stamp is tracked and commits alongside the pair. Nothing hand-edits it, and a capture that cannot write it reports that source as failed and exits 1, so an image whose stamp never landed is reported rather than passed over.
28
+ Every render writes a stamp beside its PNG, `hero.png` next to `hero.stamp`, holding the source filename, a `source-sha256` over the markup bytes it read, and an `image-sha256` over the image bytes it wrote. Both digests are what `bun run check` compares, so a markup edit committed without a capture and a PNG swapped under unchanged markup each fail. The stamp is tracked and commits alongside the pair. A capture that cannot write it reports that source as failed and exits 1, so an image whose stamp never landed is reported rather than passed over.
29
+
30
+ Neither digest is ever written by hand. A digest is what the gate compares, so a hand-set one asserts agreement the tool never checked, and the way to move it is a capture. The `source:` line above them is the one field a rename may correct in place, since nothing reads it and the alternative is a stamp naming a file that no longer exists. Renaming a frame is the case that comes up, and the correction is the same basename the next capture would have written anyway.
29
31
 
30
32
  | Option | Behavior |
31
33
  | ------------------ | ------------------------------------------------- |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.43.0",
4
+ "version": "4.45.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
- # Fills every assets/*.html.tmpl from the CLI catalogs and the design source,
3
- # writing the .html beside each one.
2
+ # Fills every assets/captures/*.html.tmpl from the CLI catalogs and the design
3
+ # source, writing the .html beside each one.
4
4
  #
5
5
  # The name says hero because the hero was the only template when it was written
6
6
  # and every citation of it across docs/, .claude/context/, and src/ spells that
@@ -18,10 +18,12 @@
18
18
  # Only the HTML regenerates here. The PNG beside it is a chromium render whose
19
19
  # bytes move with the browser version, so asserting it in verify.sh would fail
20
20
  # on a machine whose chromium differs rather than on a stale count. Rebuild the
21
- # images with `canon capture assets --selector .window` after this script
22
- # reports a change. The selector has no default, since the element a capture
23
- # crops to belongs to the page rather than to the command, and `.window` is the
24
- # class this repository's own two sources declare.
21
+ # images with `canon capture assets/captures --selector .window --out assets`
22
+ # after this script reports a change. The markup and the image sit in two
23
+ # folders, so that run reads the sources here and sends every PNG and stamp back
24
+ # up to assets/, where the documents point. The selector has no default, since
25
+ # the element a capture crops to belongs to the page rather than to the command,
26
+ # and `.window` is the class this repository's own sources declare.
25
27
  # That capture also writes a .stamp beside each PNG, which records the digest of
26
28
  # the markup it rendered and is what the Hero stage compares, so a frame's three
27
29
  # files commit together. The frame carries no version. `package.json` is bumped on main by the release
@@ -36,7 +38,7 @@ set -o pipefail
36
38
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
37
39
  PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
38
40
 
39
- ASSET_DIR="$PROJECT_ROOT/assets"
41
+ ASSET_DIR="$PROJECT_ROOT/assets/captures"
40
42
  LISTED=10
41
43
 
42
44
  # `bun src/cli.ts` rather than `canon`, since a globally linked binary resolves to
package/src/cli.ts CHANGED
@@ -120,7 +120,7 @@ function showHelp(): void {
120
120
  `${GREY}│${NC} canon design render`,
121
121
  `${GREY}│${NC} canon slides render`,
122
122
  `${GREY}│${NC} canon slides list --json`,
123
- `${GREY}│${NC} canon capture assets/install.html --selector .window`,
123
+ `${GREY}│${NC} canon capture assets/captures/install.html --selector .window --out assets`,
124
124
  `${GREY}│${NC} canon serve .canon/teach`,
125
125
  `${GREY}│${NC} canon inventory focus --json`,
126
126
  `${GREY}│${NC} canon drive http://localhost:4173 run.json --json`,
@@ -281,6 +281,20 @@ function reportRecords(plan: RecordsPlan, records: number): void {
281
281
  )
282
282
  for (const path of plan.coupled) logInfo(` ${path}`)
283
283
  }
284
+
285
+ // Named individually rather than counted. A rewritten glob stops matching
286
+ // silently, which is the failure this boundary exists to surface, so the
287
+ // report gives the reader the file and line to go and check by hand.
288
+ if (plan.frontmatterGlobs.length > 0) {
289
+ logInfo(
290
+ `${plural(plan.frontmatterGlobs.length, 'file')} carry a frontmatter paths: glob left alone:`,
291
+ )
292
+ for (const entry of plan.frontmatterGlobs) {
293
+ for (const line of entry.lines) {
294
+ logInfo(` ${entry.path}:${line.line} ${excerpt(line.text)}`)
295
+ }
296
+ }
297
+ }
284
298
  }
285
299
 
286
300
  function toRecordsRecord(
@@ -298,6 +312,8 @@ function toRecordsRecord(
298
312
  kept: plan.kept,
299
313
  excluded: plan.excluded.length,
300
314
  coupled: plan.coupled,
315
+ frontmatterGlobs: plan.frontmatterGlobs,
316
+ globs: plan.globs,
301
317
  records,
302
318
  paths: plan.entries.map((entry) => ({
303
319
  path: entry.path,
@@ -555,6 +571,10 @@ export function register(program: Command): void {
555
571
  'below it, keeps the old root. Prose that dates a decision needs it; a',
556
572
  'live path does not.',
557
573
  '',
574
+ 'A rule frontmatter paths: glob naming a moved root is reported and left',
575
+ 'alone rather than rewritten, since a rewritten glob stops matching',
576
+ 'silently. No marker is needed; the YAML shape is enough.',
577
+ '',
558
578
  'The records themselves are never swept. Everything under .canon/ and',
559
579
  'every .claude/ record folder is left alone and reported as a count, so',
560
580
  'a run after the ignore entries collapse touches the same files as one',
@@ -80,7 +80,7 @@ export const TOKENS: DesignTokens = {
80
80
  '',
81
81
  'The values below are the system rather than a reading of one. Until 2026-09-01 this record transcribed two surfaces and agreed with nothing else, which is what made a change to it reach nobody. The slide theme, the token preview, and a teach workspace stylesheet now read the module this file is rendered from, so a value changed there changes what all three render.',
82
82
  '',
83
- 'The two rendered captures read it as well. `scripts/core/regen-hero.sh` fills `assets/hero.html.tmpl` and `assets/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
83
+ 'The two rendered captures read it as well. `scripts/core/regen-hero.sh` fills `assets/captures/hero.html.tmpl` and `assets/captures/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
84
84
  '',
85
85
  'The terminal framing is the one surface left holding its own values, and that is a decision rather than a gap. `scripts/lib/ui.sh` and `src/ui.ts` each spell six escape constants, and `.claude/ARCHITECTURE.md` records one color source per language with a check behind each, so generating a third spelling from here would break the rule those two checks enforce. What the record is still incomplete about is the other half of those six: `WHITE` and `GREY` name no role below, so the terminal palette is described here in part rather than in whole.',
86
86
  ].join('\n'),
@@ -94,7 +94,7 @@ export const TOKENS: DesignTokens = {
94
94
  '',
95
95
  'Warning and error hold ANSI codes because that is what `scripts/lib/ui.sh` writes and no rendered surface implements an equivalent. Giving either a hex value would invent a mapping no file has, so they carry no contrast reading either.',
96
96
  '',
97
- 'Success is the one of the three that does have a rendered equivalent, which is why it carries a hex. `assets/install.html` marks every confirmed step with it, and the shell writes `ANSI 32` for the same role, so the two are one role in two registers rather than one value in two spellings. The hex is what the rendered surface picked and no reading claims the terminal renders that value. It declares `background` alone as its ground, since that is the only role it is drawn on, where every other dark text role is drawn on both.',
97
+ 'Success is the one of the three that does have a rendered equivalent, which is why it carries a hex. `assets/captures/install.html` marks every confirmed step with it, and the shell writes `ANSI 32` for the same role, so the two are one role in two registers rather than one value in two spellings. The hex is what the rendered surface picked and no reading claims the terminal renders that value. It declares `background` alone as its ground, since that is the only role it is drawn on, where every other dark text role is drawn on both.',
98
98
  ].join('\n'),
99
99
 
100
100
  color: [
@@ -114,7 +114,7 @@ export const SANDBOX_UNDECLARED_CEILING = 47
114
114
  export const AUDITS_BASELINE = '.claude/canon/baseline.json'
115
115
 
116
116
  export const CAPTURE_STAMP_FAILURE =
117
- 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets --selector .window and commit each frame with its image and its stamp.'
117
+ 'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets and commit each frame with its image and its stamp.'
118
118
 
119
119
  function parseJson(payload: string): unknown {
120
120
  try {
@@ -861,19 +861,28 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
861
861
  return [...seen].sort()
862
862
  }
863
863
 
864
- const CAPTURE_DIR = 'assets'
864
+ /**
865
+ * A capture set spans two folders. The markup and the template beside it are
866
+ * the authored half and sit under `assets/captures/`, while the image a
867
+ * document points at and the stamp answering for it stay in `assets/`, which is
868
+ * where `--out assets` sends them.
869
+ */
870
+ const CAPTURE_MARKUP_DIR = 'assets/captures'
871
+ const CAPTURE_OUTPUT_DIR = 'assets'
865
872
 
866
873
  /**
867
- * Every capture under `assets/`, named by the base its three files share.
874
+ * Every capture under `assets/captures/`, named by the base its three files
875
+ * share.
868
876
  *
869
877
  * Read off the folder rather than listed, and off the markup specifically,
870
878
  * because that is how `resolveCaptureSources` decides what `canon capture
871
- * assets` renders. A list would fail open on the frame somebody adds next,
872
- * which is the one nobody thinks to add here, and driving off the PNGs instead
873
- * would report a missing set for any image in the folder that is not a capture.
879
+ * assets/captures` renders. A list would fail open on the frame somebody adds
880
+ * next, which is the one nobody thinks to add here, and driving off the PNGs
881
+ * instead would report a missing set for any image in `assets/` that is not a
882
+ * capture.
874
883
  */
875
884
  function captureBases(root: string): string[] {
876
- const dir = join(root, CAPTURE_DIR)
885
+ const dir = join(root, CAPTURE_MARKUP_DIR)
877
886
  if (!existsSync(dir)) return []
878
887
 
879
888
  return readdirSync(dir)
@@ -898,7 +907,7 @@ function captureBases(root: string): string[] {
898
907
  * Both digests are checked because either file can move alone. The markup side
899
908
  * catches an edit committed with no capture, and the image side catches an
900
909
  * image replaced under markup that never changed. A tree carrying no markup
901
- * under `assets/` has no set to read and passes, which is correct.
910
+ * under `assets/captures/` has no set to read and passes, which is correct.
902
911
  */
903
912
  export const captureStamps: Measure = async (ctx) => {
904
913
  const lines = captureBases(ctx.root).flatMap((base) =>
@@ -912,11 +921,21 @@ export const captureStamps: Measure = async (ctx) => {
912
921
  }
913
922
  }
914
923
 
915
- /** One capture set, as the lines it has to report and none where it agrees. */
924
+ /**
925
+ * One capture set, as the lines it has to report and none where it agrees.
926
+ *
927
+ * The markup resolves against the source folder and the two files a render
928
+ * writes resolve against the output folder, so the set spans both rather than
929
+ * hanging off one base directory. Sending any of the three to the wrong folder
930
+ * reports it as missing rather than failing loudly, since a missing file reads
931
+ * the same way whether the path is wrong or the file is.
932
+ */
916
933
  function readCaptureSet(root: string, base: string): string[] {
917
- const set = (['html', 'png', 'stamp'] as const).map(
918
- (extension) => `${CAPTURE_DIR}/${base}.${extension}`,
919
- )
934
+ const set = [
935
+ `${CAPTURE_MARKUP_DIR}/${base}.html`,
936
+ `${CAPTURE_OUTPUT_DIR}/${base}.png`,
937
+ `${CAPTURE_OUTPUT_DIR}/${base}.stamp`,
938
+ ]
920
939
 
921
940
  const missing = set.filter((rel) => !existsSync(join(root, rel)))
922
941
  if (missing.length > 0) {
@@ -186,18 +186,19 @@ export const STAGES: readonly Stage[] = [
186
186
  // would fail on a machine whose chromium differs rather than on a stale
187
187
  // count. The stamp measure below is what covers the image instead.
188
188
  //
189
- // The stage covers both frames under `assets/` and keeps the narrower name,
190
- // because the label is spelled across the context entries and the CI table
191
- // and renaming it buys nothing the pathspec below does not already say.
189
+ // The stage covers every frame under `assets/captures/` and keeps the
190
+ // narrower name, because the label is spelled across the context entries and
191
+ // the CI table and renaming it buys nothing the pathspec below does not
192
+ // already say.
192
193
  id: 'hero',
193
194
  label: 'Hero',
194
195
  checks: [
195
196
  script('regen-hero.sh', 'Hero regen failed'),
196
197
  {
197
198
  kind: 'drift',
198
- pathspec: 'assets/*.html',
199
+ pathspec: 'assets/captures/*.html',
199
200
  failure:
200
- 'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets --selector .window, and commit each assets/*.html with its .png and .stamp.',
201
+ 'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets/captures --selector .window --out assets, and commit each assets/captures/*.html with its .png and .stamp.',
201
202
  },
202
203
  { kind: 'measure', measure: captureStamps },
203
204
  ],
@@ -15,7 +15,12 @@
15
15
 
16
16
  import { readdir, readFile, writeFile } from 'node:fs/promises'
17
17
  import { join } from 'node:path'
18
- import { RECORD_ONLY_ROOTS, rewriteText, scanText } from '@/migrate/records'
18
+ import {
19
+ type CitationLine,
20
+ RECORD_ONLY_ROOTS,
21
+ rewriteText,
22
+ scanText,
23
+ } from '@/migrate/records'
19
24
 
20
25
  /**
21
26
  * The folders inside the record root a session still follows a path into.
@@ -82,12 +87,6 @@ export interface RecordTreeSource {
82
87
  readonly text: string
83
88
  }
84
89
 
85
- /** Where a citation sits, so a reader can judge it before `--write` runs. */
86
- export interface CitationLine {
87
- readonly line: number
88
- readonly text: string
89
- }
90
-
91
90
  /** One record file whose citations move. */
92
91
  export interface RecordTreeEntry {
93
92
  readonly path: string
@@ -223,43 +223,119 @@ function isKept(lines: readonly string[], index: number): boolean {
223
223
  return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
224
224
  }
225
225
 
226
- /** Rewrites every unmarked citation into a moved entry. */
226
+ /**
227
+ * The line index where a leading YAML frontmatter block closes, or
228
+ * `undefined` when the file does not open with a bare `---` on line one.
229
+ */
230
+ function frontmatterEnd(lines: readonly string[]): number | undefined {
231
+ if (lines[0] !== '---') return undefined
232
+ const end = lines.indexOf('---', 1)
233
+ return end === -1 ? undefined : end
234
+ }
235
+
236
+ /**
237
+ * Marks a line inside a rule's frontmatter `paths:` key: the key line itself,
238
+ * or an indented `- 'glob'` list item under it. Structural rather than
239
+ * marker-based, found by walking up to the nearest line starting at column 0
240
+ * and testing whether that line is the bare `paths:` key.
241
+ *
242
+ * Independent of `KEEP_MARKER`. A rewritten glob stops matching silently,
243
+ * where a rewritten sentence is at least visible to a reader, so this line is
244
+ * held whether or not anyone remembered the marker.
245
+ */
246
+ function isFrontmatterPathsLine(
247
+ lines: readonly string[],
248
+ index: number,
249
+ frontmatterEndIndex: number | undefined,
250
+ ): boolean {
251
+ if (frontmatterEndIndex === undefined) return false
252
+ if (index <= 0 || index >= frontmatterEndIndex) return false
253
+
254
+ let top = index
255
+ while (top > 0 && /^\s/.test(lines[top] ?? '')) top -= 1
256
+
257
+ return lines[top]?.trim().startsWith('paths:') ?? false
258
+ }
259
+
260
+ type LineClass = 'live' | 'kept' | 'glob'
261
+
262
+ function classifyLine(
263
+ lines: readonly string[],
264
+ index: number,
265
+ frontmatterEndIndex: number | undefined,
266
+ ): LineClass {
267
+ if (isFrontmatterPathsLine(lines, index, frontmatterEndIndex)) return 'glob'
268
+ if (isKept(lines, index)) return 'kept'
269
+ return 'live'
270
+ }
271
+
272
+ /** Rewrites every unmarked, non-glob citation into a moved entry. */
227
273
  export function rewriteText(text: string): string {
228
274
  const lines = text.split('\n')
275
+ const frontmatterEndIndex = frontmatterEnd(lines)
229
276
 
230
277
  return lines
231
278
  .map((line, index) =>
232
- isKept(lines, index)
233
- ? line
234
- : line.replace(CITATION, (_match, entry: string) =>
279
+ classifyLine(lines, index, frontmatterEndIndex) === 'live'
280
+ ? line.replace(CITATION, (_match, entry: string) =>
235
281
  destinationPath(entry),
236
- ),
282
+ )
283
+ : line,
237
284
  )
238
285
  .join('\n')
239
286
  }
240
287
 
241
288
  /**
242
- * How many citations `rewriteText` would rewrite, and how many marked lines it
243
- * left alone. The second number is what says the markers fired at all, which a
244
- * diff cannot show because a protected line does not appear in one.
289
+ * 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
292
+ * all, which a diff cannot show because a protected line does not appear in
293
+ * one.
245
294
  */
246
295
  export function scanText(text: string): {
247
296
  readonly rewritten: number
248
297
  readonly kept: number
298
+ readonly globs: number
249
299
  } {
250
300
  const lines = text.split('\n')
301
+ const frontmatterEndIndex = frontmatterEnd(lines)
251
302
  let rewritten = 0
252
303
  let kept = 0
304
+ let globs = 0
253
305
 
254
306
  for (const [index, line] of lines.entries()) {
255
307
  const matches = [...line.matchAll(CITATION)].length
256
308
  if (matches === 0) continue
257
309
 
258
- if (isKept(lines, index)) kept += matches
310
+ const kind = classifyLine(lines, index, frontmatterEndIndex)
311
+ if (kind === 'glob') globs += matches
312
+ else if (kind === 'kept') kept += matches
259
313
  else rewritten += matches
260
314
  }
261
315
 
262
- return { rewritten, kept }
316
+ return { rewritten, kept, globs }
317
+ }
318
+
319
+ /** Where a citation sits, so a reader can judge it before `--write` runs. */
320
+ export interface CitationLine {
321
+ readonly line: number
322
+ readonly text: string
323
+ }
324
+
325
+ /** Every frontmatter `paths:` line in `text` carrying a citation. */
326
+ function frontmatterGlobLines(text: string): CitationLine[] {
327
+ const lines = text.split('\n')
328
+ const frontmatterEndIndex = frontmatterEnd(lines)
329
+ const held: CitationLine[] = []
330
+
331
+ for (const [index, line] of lines.entries()) {
332
+ if (!isFrontmatterPathsLine(lines, index, frontmatterEndIndex)) continue
333
+ if ([...line.matchAll(CITATION)].length === 0) continue
334
+
335
+ held.push({ line: index + 1, text: line.trim() })
336
+ }
337
+
338
+ return held
263
339
  }
264
340
 
265
341
  export interface FolderMove {
@@ -320,14 +396,22 @@ export interface CitationEntry {
320
396
  readonly kept: number
321
397
  }
322
398
 
399
+ /** One file whose frontmatter `paths:` glob names a moved root, held rather than rewritten. */
400
+ export interface FrontmatterGlobEntry {
401
+ readonly path: string
402
+ readonly lines: readonly CitationLine[]
403
+ }
404
+
323
405
  export interface RecordsPlan {
324
406
  readonly moves: readonly FolderMove[]
325
407
  readonly collisions: readonly string[]
326
408
  readonly entries: readonly CitationEntry[]
327
409
  readonly excluded: readonly string[]
328
410
  readonly coupled: readonly string[]
411
+ readonly frontmatterGlobs: readonly FrontmatterGlobEntry[]
329
412
  readonly rewritten: number
330
413
  readonly kept: number
414
+ readonly globs: number
331
415
  }
332
416
 
333
417
  /**
@@ -346,7 +430,9 @@ export function planRecordsMove(
346
430
  const entries: CitationEntry[] = []
347
431
  const excluded: string[] = []
348
432
  const coupled: string[] = []
433
+ const frontmatterGlobs: FrontmatterGlobEntry[] = []
349
434
  let kept = 0
435
+ let globs = 0
350
436
 
351
437
  for (const source of sources) {
352
438
  // Silently, and ahead of the exclusion test. The command boundary filters
@@ -365,6 +451,14 @@ export function planRecordsMove(
365
451
 
366
452
  const counts = scanText(source.text)
367
453
  kept += counts.kept
454
+ globs += counts.globs
455
+
456
+ // Ahead of the rewritten === 0 continue below: a file whose only citation
457
+ // sits in its paths: glob still needs to be reported.
458
+ const held = frontmatterGlobLines(source.text)
459
+ if (held.length > 0)
460
+ frontmatterGlobs.push({ path: source.path, lines: held })
461
+
368
462
  if (counts.rewritten === 0) continue
369
463
 
370
464
  if (referencesExcluded(source.text)) coupled.push(source.path)
@@ -383,7 +477,9 @@ export function planRecordsMove(
383
477
  entries,
384
478
  excluded,
385
479
  coupled,
480
+ frontmatterGlobs,
386
481
  rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
387
482
  kept,
483
+ globs,
388
484
  }
389
485
  }