@erclx/canon 4.43.0 → 4.44.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 +3 -3
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/capture.md +10 -8
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +9 -7
- package/src/cli.ts +1 -1
- package/src/commands/migrate.ts +20 -0
- package/src/design/tokens.ts +2 -2
- package/src/gate/measures.ts +31 -12
- package/src/gate/stages.ts +6 -5
- package/src/migrate/record-tree.ts +6 -7
- package/src/migrate/records.ts +106 -10
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
|
package/docs/agents/capture.md
CHANGED
|
@@ -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
|
|
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/`
|
|
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 `
|
|
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
|
-
|
|
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.
|
|
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,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Fills every assets/*.html.tmpl from the CLI catalogs and the design
|
|
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
|
|
22
|
-
# reports a change. The
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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`,
|
package/src/commands/migrate.ts
CHANGED
|
@@ -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',
|
package/src/design/tokens.ts
CHANGED
|
@@ -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: [
|
package/src/gate/measures.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
872
|
-
* which is the one nobody thinks to add here, and driving off the PNGs
|
|
873
|
-
* would report a missing set for any image in
|
|
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,
|
|
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
|
-
/**
|
|
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 =
|
|
918
|
-
|
|
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) {
|
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
|
190
|
-
// because the label is spelled across the context entries and
|
|
191
|
-
// and renaming it buys nothing the pathspec below does not
|
|
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 {
|
|
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
|
package/src/migrate/records.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
243
|
-
* left alone
|
|
244
|
-
*
|
|
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
|
-
|
|
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
|
}
|