@erclx/canon 4.78.0 → 4.79.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 +9 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-operator/SKILL.md +1 -1
- package/claude/skills/create-standard/SKILL.md +1 -1
- package/claude/skills/design-extract/REQUIREMENT.md +4 -4
- package/claude/skills/design-extract/SKILL.md +11 -11
- package/claude/skills/docs-fold/SKILL.md +14 -14
- package/claude/skills/docs-fold/references/anchor-sweep.md +1 -1
- package/claude/skills/docs-fold/references/wireframe-sweep.md +4 -4
- package/claude/skills/draft-context/REQUIREMENT.md +6 -6
- package/claude/skills/draft-context/SKILL.md +8 -6
- package/claude/skills/draft-diagram/SKILL.md +5 -5
- package/claude/skills/draft-identity/SKILL.md +3 -3
- package/claude/skills/draft-readme/REQUIREMENT.md +1 -1
- package/claude/skills/draft-screencast/SKILL.md +1 -1
- package/claude/skills/draft-wireframes/REQUIREMENT.md +7 -7
- package/claude/skills/draft-wireframes/SKILL.md +7 -5
- package/claude/skills/memory-capture/REQUIREMENT.md +1 -1
- package/claude/skills/memory-capture/SKILL.md +6 -6
- package/claude/skills/memory-review/SKILL.md +1 -1
- package/claude/skills/migration-claude-md/REQUIREMENT.md +3 -3
- package/claude/skills/migration-claude-md/SKILL.md +7 -5
- package/claude/skills/migration-context/REQUIREMENT.md +3 -3
- package/claude/skills/migration-context/SKILL.md +11 -9
- package/claude/skills/plan-feature/SKILL.md +4 -4
- package/claude/skills/plan-groundwork/SKILL.md +2 -2
- package/claude/skills/plan-intake/SKILL.md +2 -2
- package/claude/skills/project-commands/SKILL.md +2 -2
- package/claude/skills/review-branch/SKILL.md +3 -3
- package/claude/skills/review-pr/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/session-relay/REQUIREMENT.md +1 -1
- package/claude/skills/session-resume/SKILL.md +1 -1
- package/claude/skills/setup-gov/SKILL.md +1 -1
- package/claude/skills/setup-init/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/promotion.md +5 -5
- package/claude/skills/ux-audit/SKILL.md +5 -5
- package/claude/skills/ux-measure/SKILL.md +1 -1
- package/docs/agents/commands.md +7 -2
- package/docs/agents/context-audit-checks.md +3 -3
- package/docs/agents/context-audit.md +2 -2
- package/docs/agents/counts.md +3 -3
- package/docs/agents/design-board.md +3 -3
- package/docs/agents/docs.md +3 -3
- package/docs/agents/indexes.md +1 -1
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/review-classification.md +1 -1
- package/docs/agents/routing.md +2 -2
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/scripting.md +1 -1
- package/docs/agents/skills-audit.md +1 -1
- package/docs/agents/skills-reach.md +4 -4
- package/docs/target-projects.md +10 -10
- package/docs/workflow/ai-workflow.md +15 -13
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/visual-design-workflow.md +8 -8
- package/governance/rules/claude/510-context.md +3 -2
- package/governance/rules/claude/520-wireframes.md +3 -2
- package/governance/rules/claude/530-requirements.md +2 -1
- package/governance/rules/claude/540-architecture.md +2 -1
- package/governance/rules/claude/550-design.md +2 -1
- package/governance/rules/core/025-indexes.md +1 -1
- package/governance/rules/core/045-memory.md +1 -1
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +1 -1
- package/scripts/core/check-seed-independence.sh +5 -2
- package/scripts/core/install-check.sh +1 -1
- package/scripts/core/list-seed-roots.sh +3 -3
- package/scripts/core/regen-hero.sh +1 -1
- package/scripts/docs/list.sh +3 -3
- package/scripts/lib/sandbox-git.sh +1 -1
- package/scripts/lib/tooling.sh +6 -4
- package/snippets/claude/figma-steps.md +1 -1
- package/src/audits/catalog.ts +2 -2
- package/src/autoship/paths.ts +1 -1
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/setup.ts +1 -1
- package/src/claude/routing.ts +1 -1
- package/src/claude/seeds-list.ts +1 -1
- package/src/claude/seeds.ts +62 -28
- package/src/claude/skills-reach.ts +3 -3
- package/src/commands/claude.ts +1 -1
- package/src/commands/context.ts +8 -8
- package/src/commands/design.ts +1 -1
- package/src/commands/gov.ts +1 -1
- package/src/commands/migrate.ts +182 -1
- package/src/context/audit.ts +1 -1
- package/src/context/citations.ts +7 -5
- package/src/context/folders.ts +2 -2
- package/src/counts/scan.ts +4 -4
- package/src/design/board.ts +5 -5
- package/src/design/components.ts +2 -2
- package/src/design/css.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/design/tokens.ts +2 -2
- package/src/docs/read.ts +9 -3
- package/src/gate/measures.ts +10 -7
- package/src/gate/stages.ts +4 -4
- package/src/markdown/scan.ts +1 -1
- package/src/migrate/surface-roots.ts +458 -0
- package/src/record-root.ts +6 -4
- package/src/sandbox/census.ts +1 -1
- package/src/shipped/references.ts +3 -3
- package/src/surface-root.ts +39 -7
- package/src/sync/layout.ts +10 -6
- package/src/sync/reverse.ts +1 -1
- package/src/tooling/inject.ts +5 -3
- package/src/tooling/scan.ts +4 -1
- package/standards/architecture.md +4 -4
- package/standards/context.md +7 -7
- package/standards/design.md +3 -3
- package/standards/diagrams.md +6 -6
- package/standards/index.md +5 -5
- package/standards/markdown.md +2 -2
- package/standards/publish.md +3 -3
- package/standards/readme.md +1 -1
- package/standards/requirements.md +4 -4
- package/standards/skill.md +2 -2
- package/standards/tasks.md +1 -1
- package/standards/wiki.md +1 -1
- package/standards/wireframes.md +8 -8
- package/tooling/astro/reference.md +3 -3
- package/tooling/base/reference.md +3 -3
- package/tooling/claude/reference.md +7 -3
- package/tooling/claude/seeds/CLAUDE.md +9 -9
- package/tooling/nextjs/reference.md +2 -2
- package/tooling/python/reference.md +1 -1
- package/tooling/vite-react/reference.md +2 -2
- package/tooling/web/reference.md +2 -2
- /package/tooling/base/seeds/{.claude → canon}/context/ci.md +0 -0
- /package/tooling/base/seeds/{.claude → canon}/context/development.md +0 -0
- /package/tooling/base/seeds/{.claude → canon}/context/index.md +0 -0
- /package/tooling/claude/seeds/{.claude → canon}/ARCHITECTURE.md +0 -0
- /package/tooling/claude/seeds/{.claude → canon}/DESIGN.md +0 -0
- /package/tooling/claude/seeds/{.claude → canon}/REQUIREMENTS.md +0 -0
- /package/tooling/claude/seeds/{.claude → canon}/wireframes/index.md +0 -0
package/src/design/board.ts
CHANGED
|
@@ -14,12 +14,12 @@ import { colorValue } from '@/design/tokens'
|
|
|
14
14
|
import { recordDir } from '@/record-root'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* The one named site for this repository's wireframe corpus
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* The one named site for this repository's wireframe corpus, so the surface
|
|
18
|
+
* move retargets a single constant rather than a literal repeated per panel.
|
|
19
|
+
* The board reads this checkout alone, which has moved, so the new root is
|
|
20
|
+
* spelled directly rather than resolved.
|
|
21
21
|
*/
|
|
22
|
-
export const WIREFRAME_DIR = join('
|
|
22
|
+
export const WIREFRAME_DIR = join('canon', 'wireframes')
|
|
23
23
|
|
|
24
24
|
/** Landing page for the built site the surfaces panel iframes when present. */
|
|
25
25
|
const WEB_DIST = join('web', 'dist')
|
package/src/design/components.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `buildDesignCss`'s `components` option, which is what widening that option
|
|
16
16
|
* from a boolean to `boolean | readonly Component[]` bought.
|
|
17
17
|
*
|
|
18
|
-
* It does not live in
|
|
18
|
+
* It does not live in `canon/DESIGN.md`. `standards/design.md` keeps CSS class
|
|
19
19
|
* names out of that record and says they live in code, which is here.
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -207,7 +207,7 @@ const TEACH_CHROME: Component = {
|
|
|
207
207
|
'navigation shared by every teach page. Recovered from two gitignored',
|
|
208
208
|
'course.css files that predate a regression that dropped this layer from the',
|
|
209
209
|
"generator, and rewritten onto this module's tokens rather than the",
|
|
210
|
-
'incompatible palette they carried. See
|
|
210
|
+
'incompatible palette they carried. See canon/wireframes/teach/chrome.md for',
|
|
211
211
|
'the shape.',
|
|
212
212
|
].join('\n '),
|
|
213
213
|
reads: [
|
package/src/design/css.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { TOKENS } from '@/design/tokens'
|
|
|
12
12
|
*
|
|
13
13
|
* The property names match the ones `@/design/render` already emits from a
|
|
14
14
|
* parsed document, so the toolkit's own source and a target's hand-authored
|
|
15
|
-
*
|
|
15
|
+
* `canon/DESIGN.md` produce one vocabulary rather than two.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
/** A role name as it appears in a custom property. */
|
package/src/design/document.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
import { TOKENS } from '@/design/tokens'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Renders
|
|
11
|
+
* Renders `canon/DESIGN.md` from the token source.
|
|
12
12
|
*
|
|
13
13
|
* The document is the view and `@/design/tokens` is the fact, which is the one
|
|
14
14
|
* thing that changed when this record stopped being a transcript of two other
|
package/src/design/regen.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { renderDesignDocument } from '@/design/document'
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/** The rendered record, at the path `standards/design.md` fixes for it. */
|
|
18
|
-
export const DESIGN_DOCUMENT = join('
|
|
18
|
+
export const DESIGN_DOCUMENT = join('canon', 'DESIGN.md')
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* The base stylesheet a target installs. It sits inside `src/` because that is
|
package/src/design/render.ts
CHANGED
|
@@ -219,7 +219,7 @@ ${previewChrome()}
|
|
|
219
219
|
</head>
|
|
220
220
|
<body>
|
|
221
221
|
<h1>Design tokens</h1>
|
|
222
|
-
<p class="note">Generated from <code
|
|
222
|
+
<p class="note">Generated from <code>canon/DESIGN.md</code> by <code>canon design render</code>. Token preview only, not a screen mock.</p>${summary}
|
|
223
223
|
${sections.join('\n')}
|
|
224
224
|
</body>
|
|
225
225
|
</html>
|
package/src/design/tokens.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The design system's one source of values.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* `canon/DESIGN.md` is rendered from this module rather than read by it, so
|
|
5
5
|
* the document a person opens is a view and this file is the fact. Every
|
|
6
6
|
* rendering surface reads from here in the form it can take: a CSS surface
|
|
7
7
|
* takes custom properties through `@/design/css`, and the slide renderer takes
|
|
@@ -94,7 +94,7 @@ export const TOKENS: DesignTokens = {
|
|
|
94
94
|
'',
|
|
95
95
|
'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.',
|
|
96
96
|
'',
|
|
97
|
-
'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
|
|
97
|
+
'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 `canon/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.',
|
|
98
98
|
].join('\n'),
|
|
99
99
|
|
|
100
100
|
personality: [
|
package/src/docs/read.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
3
|
import { stripFrontmatter } from '@/frontmatter'
|
|
4
|
+
import { SURFACE_ROOTS } from '@/surface-root'
|
|
4
5
|
|
|
5
6
|
const INDEX_TOPIC = 'index'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* The
|
|
9
|
-
* consumer-facing reference and
|
|
9
|
+
* The roots a topic resolves against, in precedence order. `docs/` holds
|
|
10
|
+
* consumer-facing reference and the context folder holds per-domain internal
|
|
10
11
|
* narrative, so a name present in both resolves to the consumer-facing copy.
|
|
12
|
+
* The context folder is read at every surface root, so a checkout the surface
|
|
13
|
+
* move has not reached still resolves its own entries.
|
|
11
14
|
*/
|
|
12
|
-
const ROOTS: readonly string[] = [
|
|
15
|
+
const ROOTS: readonly string[] = [
|
|
16
|
+
'docs',
|
|
17
|
+
...SURFACE_ROOTS.map((root) => join(root, 'context')),
|
|
18
|
+
]
|
|
13
19
|
|
|
14
20
|
export interface ResolvedTopic {
|
|
15
21
|
readonly path: string
|
package/src/gate/measures.ts
CHANGED
|
@@ -369,7 +369,8 @@ export const seedStandards: Measure = async (ctx) => {
|
|
|
369
369
|
if (seedRoots.length === 0) {
|
|
370
370
|
return {
|
|
371
371
|
emissions: [],
|
|
372
|
-
unmeasured:
|
|
372
|
+
unmeasured:
|
|
373
|
+
'No seed root carries .claude/ or canon/, so nothing was measured.',
|
|
373
374
|
}
|
|
374
375
|
}
|
|
375
376
|
|
|
@@ -387,11 +388,13 @@ export const seedStandards: Measure = async (ctx) => {
|
|
|
387
388
|
|
|
388
389
|
// The audit separates 1 from 2 and they mean opposite things. 2 is a seed
|
|
389
390
|
// breaking the standard it seeds. 1 is the audit refusing, which a seed
|
|
390
|
-
// root carrying a
|
|
391
|
-
//
|
|
391
|
+
// root carrying a root but no audited folder produces, and reporting that
|
|
392
|
+
// as a violation sends a reader hunting one that does not exist.
|
|
392
393
|
if (run.exitCode === 1) {
|
|
393
394
|
emissions.push(
|
|
394
|
-
warn(
|
|
395
|
+
warn(
|
|
396
|
+
`${seedRoot}: no audited folder under either root, nothing measured`,
|
|
397
|
+
),
|
|
395
398
|
)
|
|
396
399
|
continue
|
|
397
400
|
}
|
|
@@ -581,8 +584,8 @@ export const shippedReferences: Measure = async (ctx) => {
|
|
|
581
584
|
),
|
|
582
585
|
failure:
|
|
583
586
|
found.length === 1
|
|
584
|
-
? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning
|
|
585
|
-
: `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning
|
|
587
|
+
? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning canon/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
|
|
588
|
+
: `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning canon/context/ entry, state the fact a shipped skill body's cited rule enforces instead of its path, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
|
|
586
589
|
}
|
|
587
590
|
}
|
|
588
591
|
|
|
@@ -1011,7 +1014,7 @@ export const pluginManifests: Measure = async (ctx) => {
|
|
|
1011
1014
|
return {
|
|
1012
1015
|
emissions: [],
|
|
1013
1016
|
unmeasured:
|
|
1014
|
-
'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, and record the move in
|
|
1017
|
+
'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, and record the move in canon/context/ci.md.',
|
|
1015
1018
|
}
|
|
1016
1019
|
}
|
|
1017
1020
|
|
package/src/gate/stages.ts
CHANGED
|
@@ -79,7 +79,7 @@ export interface Stage {
|
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
81
|
* Corpora a `src/` test asserts over from outside `src/`, censused in
|
|
82
|
-
*
|
|
82
|
+
* `canon/context/development/verification.md`. This list and that census are
|
|
83
83
|
* two copies of one set with nothing comparing them, so a corpus joining the
|
|
84
84
|
* census joins this list in the same change. The first four are directory
|
|
85
85
|
* prefixes because their tests walk the tree whole, which is what reaches a
|
|
@@ -221,7 +221,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
221
221
|
success: 'Tooling paths clean',
|
|
222
222
|
},
|
|
223
223
|
{
|
|
224
|
-
//
|
|
224
|
+
// `canon/DESIGN.md`, the base stylesheet, the web stylesheet, and the
|
|
225
225
|
// tab icon are all written from `src/design/tokens.ts` and none is edited
|
|
226
226
|
// by hand. Four artifacts from one source is the cost of the token move,
|
|
227
227
|
// and a render step that has to run is only safe while something fails
|
|
@@ -236,9 +236,9 @@ export const STAGES: readonly Stage[] = [
|
|
|
236
236
|
},
|
|
237
237
|
{
|
|
238
238
|
kind: 'drift',
|
|
239
|
-
pathspec: '
|
|
239
|
+
pathspec: 'canon/DESIGN.md',
|
|
240
240
|
failure:
|
|
241
|
-
'The design record drifted from the token source. Run bun run check and commit
|
|
241
|
+
'The design record drifted from the token source. Run bun run check and commit canon/DESIGN.md.',
|
|
242
242
|
},
|
|
243
243
|
{
|
|
244
244
|
kind: 'drift',
|
package/src/markdown/scan.ts
CHANGED
|
@@ -206,7 +206,7 @@ export function maskDisplayed(text: string): string {
|
|
|
206
206
|
* A code span is walked around rather than through, since keeping it counted
|
|
207
207
|
* and then dropping spans from inside it takes back the decision. The
|
|
208
208
|
* placeholders this repository writes are the case: a reader is shown all of
|
|
209
|
-
*
|
|
209
|
+
* `canon/context/<domain>.md` and the autolink pattern reaches the angle
|
|
210
210
|
* brackets in the middle of it.
|
|
211
211
|
*/
|
|
212
212
|
export function visibleText(text: string): string {
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The move of the tracked toolkit surfaces from `.claude/` to `canon/`.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the records move, every entry here is tracked, so a folder relocating
|
|
5
|
+
* is itself a citation-shaped event: the file's own path is a string every
|
|
6
|
+
* other file may quote. One planner therefore judges both halves per source
|
|
7
|
+
* file rather than moving folders and rewriting citations as two passes, so a
|
|
8
|
+
* file that both moves and cites another moved entry is decided once.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { SURFACE_ENTRIES, spell, type SurfaceRoot } from '@/surface-root'
|
|
13
|
+
|
|
14
|
+
/** The root the entries below leave. */
|
|
15
|
+
export const FROM_ROOT: SurfaceRoot = '.claude'
|
|
16
|
+
|
|
17
|
+
/** The root they arrive at. */
|
|
18
|
+
const TO_ROOT: SurfaceRoot = 'canon'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every entry this move relocates, at the name `.claude/` gives it.
|
|
22
|
+
*
|
|
23
|
+
* Read from `SURFACE_ENTRIES` rather than restated, less the one entry that
|
|
24
|
+
* stays: `canon` names the install stamp folder, which batch 3 owns and which
|
|
25
|
+
* `.claude/canon/` continues to hold until that batch lands.
|
|
26
|
+
*/
|
|
27
|
+
export const MOVED_ENTRIES: readonly string[] = SURFACE_ENTRIES.filter(
|
|
28
|
+
(entry) => entry !== 'canon',
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
function sourcePrefix(entry: string): string {
|
|
32
|
+
return join(FROM_ROOT, spell(FROM_ROOT, entry))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function destinationPrefix(entry: string): string {
|
|
36
|
+
return join(TO_ROOT, spell(TO_ROOT, entry))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where a moved entry's own path lands, or `undefined` when `path` sits
|
|
41
|
+
* outside every moved entry.
|
|
42
|
+
*
|
|
43
|
+
* Tested against the file's own path rather than its content, so a context
|
|
44
|
+
* entry moves regardless of what it cites.
|
|
45
|
+
*/
|
|
46
|
+
export function movedPath(path: string): string | undefined {
|
|
47
|
+
for (const entry of MOVED_ENTRIES) {
|
|
48
|
+
const prefix = sourcePrefix(entry)
|
|
49
|
+
if (path === prefix) return destinationPrefix(entry)
|
|
50
|
+
if (path.startsWith(`${prefix}/`)) {
|
|
51
|
+
return destinationPrefix(entry) + path.slice(prefix.length)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A citation into a moved entry.
|
|
60
|
+
*
|
|
61
|
+
* The tail rejects a following name character rather than asking for a word
|
|
62
|
+
* boundary, which is what keeps `.claude/context` bare and `.claude/context/`
|
|
63
|
+
* both matching while `.claude/contexts/` does not. The alternation is ordered
|
|
64
|
+
* longest first so `ARCHITECTURE.md` is decided before any shorter entry could
|
|
65
|
+
* claim its prefix, and each entry is escaped because two of them carry a dot.
|
|
66
|
+
*/
|
|
67
|
+
const CITATION = new RegExp(
|
|
68
|
+
`${escape(FROM_ROOT)}/(${[...MOVED_ENTRIES]
|
|
69
|
+
.sort((left, right) => right.length - left.length)
|
|
70
|
+
.map(escape)
|
|
71
|
+
.join('|')})(?![A-Za-z0-9._-])`,
|
|
72
|
+
'g',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
function escape(value: string): string {
|
|
76
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Files whose content is left alone entirely.
|
|
81
|
+
*
|
|
82
|
+
* The changelog is release history, and an eval result is a transcript of
|
|
83
|
+
* paths a session actually opened; rewriting either makes it testify to
|
|
84
|
+
* something that never happened. `src/surface-root.ts` and
|
|
85
|
+
* `src/record-root.ts` are the two sources that spell the old root as data on
|
|
86
|
+
* purpose, so sweeping either would turn the very literals this verb reads
|
|
87
|
+
* into their own replacement. A hook is the third such source, guarding on a
|
|
88
|
+
* `case` carrying an arm per root, so `.claude/hooks/` and the seed's copy of
|
|
89
|
+
* it are excluded for the same reason `records.ts` excludes them.
|
|
90
|
+
*
|
|
91
|
+
* No suffix-wide test exclusion. Most of the `src/` test files naming these
|
|
92
|
+
* paths use them as fixture data proving the fallback still resolves, not to
|
|
93
|
+
* pin one particular assertion, so a wholesale ban would leave the majority
|
|
94
|
+
* silently unswept. The two files that do pin a fallback carry `KEEP_MARKER`
|
|
95
|
+
* on the line that needs it instead.
|
|
96
|
+
*/
|
|
97
|
+
const EXCLUDED_PREFIXES: readonly string[] = [
|
|
98
|
+
'src/migrate/',
|
|
99
|
+
'scripts/eval/result-',
|
|
100
|
+
'.claude/hooks/',
|
|
101
|
+
'tooling/claude/seeds/.claude/hooks/',
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
const EXCLUDED_PATHS: readonly string[] = [
|
|
105
|
+
'CHANGELOG.md',
|
|
106
|
+
'src/surface-root.ts',
|
|
107
|
+
'src/record-root.ts',
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
export function isExcludedPath(path: string): boolean {
|
|
111
|
+
if (EXCLUDED_PATHS.includes(path)) return true
|
|
112
|
+
return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether `text` names an `EXCLUDED_PREFIXES` or `EXCLUDED_PATHS` entry as
|
|
117
|
+
* literal substring text, which is how a rewritten file can couple to one
|
|
118
|
+
* this module leaves alone.
|
|
119
|
+
*/
|
|
120
|
+
export function referencesExcluded(text: string): boolean {
|
|
121
|
+
return (
|
|
122
|
+
EXCLUDED_PATHS.some((path) => text.includes(path)) ||
|
|
123
|
+
EXCLUDED_PREFIXES.some((prefix) => text.includes(prefix))
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Marks a line naming the old root on purpose.
|
|
129
|
+
*
|
|
130
|
+
* The marker sits on the line itself or on the nearest non-blank line above
|
|
131
|
+
* it, walking past a run of blank markdown lines to reach a marker written on
|
|
132
|
+
* its own line above the paragraph it protects.
|
|
133
|
+
*/
|
|
134
|
+
const KEEP_MARKER = 'canon-keep-surface-root'
|
|
135
|
+
|
|
136
|
+
function isKept(lines: readonly string[], index: number): boolean {
|
|
137
|
+
if (lines[index]?.includes(KEEP_MARKER)) return true
|
|
138
|
+
|
|
139
|
+
let above = index - 1
|
|
140
|
+
while (above >= 0 && lines[above]?.trim() === '') above -= 1
|
|
141
|
+
|
|
142
|
+
return above >= 0 && (lines[above]?.includes(KEEP_MARKER) ?? false)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The line index where a leading YAML frontmatter block closes, or
|
|
147
|
+
* `undefined` when the file does not open with a bare `---` on line one.
|
|
148
|
+
*/
|
|
149
|
+
function frontmatterEnd(lines: readonly string[]): number | undefined {
|
|
150
|
+
if (lines[0] !== '---') return undefined
|
|
151
|
+
const end = lines.indexOf('---', 1)
|
|
152
|
+
return end === -1 ? undefined : end
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Marks a line inside a rule's frontmatter `paths:` key: the key line itself,
|
|
157
|
+
* or an indented `- 'glob'` list item under it.
|
|
158
|
+
*/
|
|
159
|
+
function isFrontmatterPathsLine(
|
|
160
|
+
lines: readonly string[],
|
|
161
|
+
index: number,
|
|
162
|
+
frontmatterEndIndex: number | undefined,
|
|
163
|
+
): boolean {
|
|
164
|
+
if (frontmatterEndIndex === undefined) return false
|
|
165
|
+
if (index <= 0 || index >= frontmatterEndIndex) return false
|
|
166
|
+
|
|
167
|
+
let top = index
|
|
168
|
+
while (top > 0 && /^\s/.test(lines[top] ?? '')) top -= 1
|
|
169
|
+
|
|
170
|
+
return lines[top]?.trim().startsWith('paths:') ?? false
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A path segment immediately before a citation match, shaped like another
|
|
175
|
+
* repository's own name: it carries a dot or it is itself preceded by a
|
|
176
|
+
* further `/`.
|
|
177
|
+
*/
|
|
178
|
+
const CROSS_REPO_PREFIX = /(?:[\w.-]+\.[\w.-]+|[\w.-]+\/[\w.-]+)\/$/
|
|
179
|
+
|
|
180
|
+
function isCrossRepoPrefix(lines: readonly string[], index: number): boolean {
|
|
181
|
+
const line = lines[index] ?? ''
|
|
182
|
+
const match = [...line.matchAll(CITATION)][0]
|
|
183
|
+
if (match === undefined) return false
|
|
184
|
+
|
|
185
|
+
return CROSS_REPO_PREFIX.test(line.slice(0, match.index ?? 0))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** An ISO date, marking a paragraph as recording what was true on that day. */
|
|
189
|
+
const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Whether the line's citation sits inside a blank-line-delimited paragraph
|
|
193
|
+
* that also carries an ISO date, read as dated prose rather than a live path.
|
|
194
|
+
*
|
|
195
|
+
* `.claude/ARCHITECTURE.md` is itself a moved entry and carries dozens of
|
|
196
|
+
* `Measured at <sha> on <date>` paragraphs naming the old root as what was
|
|
197
|
+
* true that day. Those stay as written rather than being rewritten into a
|
|
198
|
+
* root that did not exist yet on the date being recorded.
|
|
199
|
+
*/
|
|
200
|
+
function isDatedParagraph(lines: readonly string[], index: number): boolean {
|
|
201
|
+
let start = index
|
|
202
|
+
while (start > 0 && lines[start - 1]?.trim() !== '') start -= 1
|
|
203
|
+
|
|
204
|
+
let end = index
|
|
205
|
+
while (end < lines.length - 1 && lines[end + 1]?.trim() !== '') end += 1
|
|
206
|
+
|
|
207
|
+
for (let cursor = start; cursor <= end; cursor += 1) {
|
|
208
|
+
if (ISO_DATE.test(lines[cursor] ?? '')) return true
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type LineClass = 'live' | 'kept' | 'glob' | 'crossRepo' | 'dated'
|
|
215
|
+
|
|
216
|
+
function classifyLine(
|
|
217
|
+
lines: readonly string[],
|
|
218
|
+
index: number,
|
|
219
|
+
frontmatterEndIndex: number | undefined,
|
|
220
|
+
): LineClass {
|
|
221
|
+
if (isFrontmatterPathsLine(lines, index, frontmatterEndIndex)) return 'glob'
|
|
222
|
+
if (isKept(lines, index)) return 'kept'
|
|
223
|
+
if (isCrossRepoPrefix(lines, index)) return 'crossRepo'
|
|
224
|
+
if (isDatedParagraph(lines, index)) return 'dated'
|
|
225
|
+
return 'live'
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Rewrites every unmarked, non-glob citation into a moved entry. */
|
|
229
|
+
export function rewriteText(text: string): string {
|
|
230
|
+
const lines = text.split('\n')
|
|
231
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
232
|
+
|
|
233
|
+
return lines
|
|
234
|
+
.map((line, index) =>
|
|
235
|
+
classifyLine(lines, index, frontmatterEndIndex) === 'live'
|
|
236
|
+
? line.replace(CITATION, (_match, entry: string) =>
|
|
237
|
+
destinationPrefix(entry),
|
|
238
|
+
)
|
|
239
|
+
: line,
|
|
240
|
+
)
|
|
241
|
+
.join('\n')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* How many citations `rewriteText` would rewrite, how many marked lines it
|
|
246
|
+
* left alone, how many sat inside a frontmatter `paths:` glob, how many sat
|
|
247
|
+
* behind a cross-repository-shaped prefix, and how many sat inside a dated
|
|
248
|
+
* paragraph.
|
|
249
|
+
*/
|
|
250
|
+
export function scanText(text: string): {
|
|
251
|
+
readonly rewritten: number
|
|
252
|
+
readonly kept: number
|
|
253
|
+
readonly globs: number
|
|
254
|
+
readonly crossRepo: number
|
|
255
|
+
readonly dated: number
|
|
256
|
+
} {
|
|
257
|
+
const lines = text.split('\n')
|
|
258
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
259
|
+
let rewritten = 0
|
|
260
|
+
let kept = 0
|
|
261
|
+
let globs = 0
|
|
262
|
+
let crossRepo = 0
|
|
263
|
+
let dated = 0
|
|
264
|
+
|
|
265
|
+
for (const [index, line] of lines.entries()) {
|
|
266
|
+
const matches = [...line.matchAll(CITATION)].length
|
|
267
|
+
if (matches === 0) continue
|
|
268
|
+
|
|
269
|
+
const kind = classifyLine(lines, index, frontmatterEndIndex)
|
|
270
|
+
if (kind === 'glob') globs += matches
|
|
271
|
+
else if (kind === 'kept') kept += matches
|
|
272
|
+
else if (kind === 'crossRepo') crossRepo += matches
|
|
273
|
+
else if (kind === 'dated') dated += matches
|
|
274
|
+
else rewritten += matches
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { rewritten, kept, globs, crossRepo, dated }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Where a citation sits, so a reader can judge it before `--write` runs. */
|
|
281
|
+
export interface CitationLine {
|
|
282
|
+
readonly line: number
|
|
283
|
+
readonly text: string
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function frontmatterGlobLines(text: string): CitationLine[] {
|
|
287
|
+
const lines = text.split('\n')
|
|
288
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
289
|
+
const held: CitationLine[] = []
|
|
290
|
+
|
|
291
|
+
for (const [index, line] of lines.entries()) {
|
|
292
|
+
if (!isFrontmatterPathsLine(lines, index, frontmatterEndIndex)) continue
|
|
293
|
+
if ([...line.matchAll(CITATION)].length === 0) continue
|
|
294
|
+
|
|
295
|
+
held.push({ line: index + 1, text: line.trim() })
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return held
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function crossRepoLines(text: string): CitationLine[] {
|
|
302
|
+
const lines = text.split('\n')
|
|
303
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
304
|
+
const held: CitationLine[] = []
|
|
305
|
+
|
|
306
|
+
for (const [index, line] of lines.entries()) {
|
|
307
|
+
if ([...line.matchAll(CITATION)].length === 0) continue
|
|
308
|
+
if (classifyLine(lines, index, frontmatterEndIndex) !== 'crossRepo')
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
held.push({ line: index + 1, text: line.trim() })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return held
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function datedLines(text: string): CitationLine[] {
|
|
318
|
+
const lines = text.split('\n')
|
|
319
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
320
|
+
const held: CitationLine[] = []
|
|
321
|
+
|
|
322
|
+
for (const [index, line] of lines.entries()) {
|
|
323
|
+
if ([...line.matchAll(CITATION)].length === 0) continue
|
|
324
|
+
if (classifyLine(lines, index, frontmatterEndIndex) !== 'dated') continue
|
|
325
|
+
|
|
326
|
+
held.push({ line: index + 1, text: line.trim() })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return held
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** One tracked file, as the planner reads it. */
|
|
333
|
+
export interface SurfaceRootsSource {
|
|
334
|
+
readonly path: string
|
|
335
|
+
readonly text: string
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One file the sweep touches, whether by moving, rewriting, or both. */
|
|
339
|
+
export interface SurfaceRootsEntry {
|
|
340
|
+
readonly path: string
|
|
341
|
+
readonly movesTo?: string
|
|
342
|
+
readonly text?: string
|
|
343
|
+
readonly rewritten: number
|
|
344
|
+
readonly kept: number
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface FrontmatterGlobEntry {
|
|
348
|
+
readonly path: string
|
|
349
|
+
readonly lines: readonly CitationLine[]
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export interface CrossRepoCitationEntry {
|
|
353
|
+
readonly path: string
|
|
354
|
+
readonly lines: readonly CitationLine[]
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface DatedCitationEntry {
|
|
358
|
+
readonly path: string
|
|
359
|
+
readonly lines: readonly CitationLine[]
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface SurfaceRootsPlan {
|
|
363
|
+
readonly entries: readonly SurfaceRootsEntry[]
|
|
364
|
+
readonly excluded: readonly string[]
|
|
365
|
+
readonly coupled: readonly string[]
|
|
366
|
+
readonly frontmatterGlobs: readonly FrontmatterGlobEntry[]
|
|
367
|
+
readonly crossRepoCitations: readonly CrossRepoCitationEntry[]
|
|
368
|
+
readonly datedCitations: readonly DatedCitationEntry[]
|
|
369
|
+
readonly rewritten: number
|
|
370
|
+
readonly kept: number
|
|
371
|
+
readonly globs: number
|
|
372
|
+
readonly crossRepo: number
|
|
373
|
+
readonly dated: number
|
|
374
|
+
readonly moves: number
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* What the move would do, without doing it.
|
|
379
|
+
*
|
|
380
|
+
* A file is judged once for both halves: whether its own path sits under a
|
|
381
|
+
* moved entry, and whether its content cites one. Either alone is enough to
|
|
382
|
+
* produce an entry, and a file excluded from the citation rewrite still moves
|
|
383
|
+
* when its path itself is inside a moved entry, since the two entries excluded
|
|
384
|
+
* today, `src/surface-root.ts` and `src/record-root.ts`, both sit outside
|
|
385
|
+
* every moved entry and never take that branch in practice.
|
|
386
|
+
*/
|
|
387
|
+
export function planSurfaceRootsMove(
|
|
388
|
+
sources: readonly SurfaceRootsSource[],
|
|
389
|
+
): SurfaceRootsPlan {
|
|
390
|
+
const entries: SurfaceRootsEntry[] = []
|
|
391
|
+
const excluded: string[] = []
|
|
392
|
+
const coupled: string[] = []
|
|
393
|
+
const frontmatterGlobs: FrontmatterGlobEntry[] = []
|
|
394
|
+
const crossRepoCitations: CrossRepoCitationEntry[] = []
|
|
395
|
+
const datedCitations: DatedCitationEntry[] = []
|
|
396
|
+
let kept = 0
|
|
397
|
+
let globs = 0
|
|
398
|
+
let crossRepo = 0
|
|
399
|
+
let dated = 0
|
|
400
|
+
|
|
401
|
+
for (const source of sources) {
|
|
402
|
+
const movesTo = movedPath(source.path)
|
|
403
|
+
|
|
404
|
+
if (isExcludedPath(source.path)) {
|
|
405
|
+
if (scanText(source.text).rewritten > 0) excluded.push(source.path)
|
|
406
|
+
if (movesTo !== undefined) {
|
|
407
|
+
entries.push({ path: source.path, movesTo, rewritten: 0, kept: 0 })
|
|
408
|
+
}
|
|
409
|
+
continue
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const counts = scanText(source.text)
|
|
413
|
+
kept += counts.kept
|
|
414
|
+
globs += counts.globs
|
|
415
|
+
crossRepo += counts.crossRepo
|
|
416
|
+
dated += counts.dated
|
|
417
|
+
|
|
418
|
+
const held = frontmatterGlobLines(source.text)
|
|
419
|
+
if (held.length > 0)
|
|
420
|
+
frontmatterGlobs.push({ path: source.path, lines: held })
|
|
421
|
+
|
|
422
|
+
const foreign = crossRepoLines(source.text)
|
|
423
|
+
if (foreign.length > 0)
|
|
424
|
+
crossRepoCitations.push({ path: source.path, lines: foreign })
|
|
425
|
+
|
|
426
|
+
const dates = datedLines(source.text)
|
|
427
|
+
if (dates.length > 0)
|
|
428
|
+
datedCitations.push({ path: source.path, lines: dates })
|
|
429
|
+
|
|
430
|
+
const changed = counts.rewritten > 0
|
|
431
|
+
if (!changed && movesTo === undefined) continue
|
|
432
|
+
|
|
433
|
+
if (changed && referencesExcluded(source.text)) coupled.push(source.path)
|
|
434
|
+
|
|
435
|
+
entries.push({
|
|
436
|
+
path: source.path,
|
|
437
|
+
...(movesTo !== undefined ? { movesTo } : {}),
|
|
438
|
+
...(changed ? { text: rewriteText(source.text) } : {}),
|
|
439
|
+
rewritten: counts.rewritten,
|
|
440
|
+
kept: counts.kept,
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
entries,
|
|
446
|
+
excluded,
|
|
447
|
+
coupled,
|
|
448
|
+
frontmatterGlobs,
|
|
449
|
+
crossRepoCitations,
|
|
450
|
+
datedCitations,
|
|
451
|
+
rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
|
|
452
|
+
kept,
|
|
453
|
+
globs,
|
|
454
|
+
crossRepo,
|
|
455
|
+
dated,
|
|
456
|
+
moves: entries.filter((entry) => entry.movesTo !== undefined).length,
|
|
457
|
+
}
|
|
458
|
+
}
|