@erclx/canon 5.60.0 → 5.62.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/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +2 -1
- package/claude/skills/design-identity/references/card-route.md +3 -3
- package/claude/skills/design-record/references/design.md +19 -0
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/SKILL.md +6 -5
- package/claude/skills/document-health/references/axes.md +3 -1
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +3 -2
- package/claude/skills/review-ui/references/post.md +2 -2
- package/claude/skills/target-update/SKILL.md +9 -1
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +12 -0
- package/claude/skills/ux-audit/SKILL.md +1 -1
- package/docs/agents/install-and-sync.md +45 -11
- package/docs/agents/records.md +1 -1
- package/docs/index.md +1 -1
- package/docs/target/migrations.md +7 -9
- package/docs/target/sync.md +20 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/index.md +3 -3
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/visual-design-workflow.md +58 -32
- package/governance/detect.toml +8 -0
- package/governance/ledger/ui/a11y.md +8 -6
- package/governance/ledger/ui/keyboard.md +20 -21
- package/governance/ledger/ui/pointer.md +24 -0
- package/governance/ledger/ui/surface-capture.md +12 -12
- package/governance/ledger/ui/ux-completeness.md +11 -23
- package/governance/ledger/ui/ux-controls.md +26 -0
- package/governance/rules/ui/a11y.md +2 -0
- package/governance/rules/ui/keyboard.md +4 -7
- package/governance/rules/ui/pointer.md +23 -0
- package/governance/rules/ui/surface-capture.md +5 -5
- package/governance/rules/ui/ux-completeness.md +2 -10
- package/governance/rules/ui/ux-controls.md +25 -0
- package/governance/splits.toml +15 -0
- package/package.json +2 -1
- package/src/commands/driver.ts +29 -3
- package/src/commands/migrate.ts +7 -0
- package/src/commands/records.ts +3 -2
- package/src/commands/sync.ts +3 -1
- package/src/commands/tooling.ts +329 -10
- package/src/design/audit.ts +25 -1
- package/src/design/budget.ts +2 -1
- package/src/design/document.ts +5 -1
- package/src/design/themes.ts +176 -0
- package/src/design/tokens.ts +3 -0
- package/src/driver/drive.ts +17 -1
- package/src/driver/probes/axe.ts +195 -0
- package/src/driver/steps.ts +20 -1
- package/src/gate/measures.ts +9 -3
- package/src/gov/adapter.ts +53 -4
- package/src/gov/splits.ts +36 -0
- package/src/markdown/structure.ts +4 -1
- package/src/migrate/records.ts +46 -7
- package/src/records/stale-canonical.ts +2 -2
- package/src/sync/check.ts +9 -1
- package/src/sync/stamp.ts +135 -11
- package/src/tooling/disposition.ts +167 -0
- package/src/tooling/inject.ts +17 -2
- package/src/tooling/scan.ts +86 -7
- package/src/tooling/stamp.ts +51 -7
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Held rule basename to the basenames its moved bullets now live in. */
|
|
5
|
+
export type Splits = ReadonlyMap<string, readonly string[]>
|
|
6
|
+
|
|
7
|
+
function splitsPath(root: string): string {
|
|
8
|
+
return join(root, 'governance', 'splits.toml')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads the shipped split ledger. A toolkit without one declares no splits,
|
|
13
|
+
* which leaves every sync silent about partners, the way it was before the
|
|
14
|
+
* ledger existed.
|
|
15
|
+
*/
|
|
16
|
+
export function loadSplits(root: string): Splits {
|
|
17
|
+
const path = splitsPath(root)
|
|
18
|
+
if (!existsSync(path)) return new Map()
|
|
19
|
+
|
|
20
|
+
const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
21
|
+
string,
|
|
22
|
+
unknown
|
|
23
|
+
>
|
|
24
|
+
const table = parsed.split
|
|
25
|
+
if (typeof table !== 'object' || table === null) return new Map()
|
|
26
|
+
|
|
27
|
+
const entries: [string, string[]][] = []
|
|
28
|
+
for (const [held, value] of Object.entries(table)) {
|
|
29
|
+
if (!Array.isArray(value)) continue
|
|
30
|
+
const partners = value.filter(
|
|
31
|
+
(partner): partner is string => typeof partner === 'string',
|
|
32
|
+
)
|
|
33
|
+
if (partners.length > 0) entries.push([held, partners])
|
|
34
|
+
}
|
|
35
|
+
return new Map(entries)
|
|
36
|
+
}
|
|
@@ -422,6 +422,9 @@ export function longestRun(
|
|
|
422
422
|
* continuing a bullet do fold in, so a heavy bullet cannot fall under the
|
|
423
423
|
* checkpoint by being wrapped across two source lines. A fenced line closes the
|
|
424
424
|
* open bullet, which keeps a bullet from absorbing the example below it.
|
|
425
|
+
*
|
|
426
|
+
* A bullet is measured without its list marker, the way the `bullet-length`
|
|
427
|
+
* gate measures it, so the count and the gate agree at the checkpoint.
|
|
425
428
|
*/
|
|
426
429
|
export function heavyBullets(
|
|
427
430
|
lines: readonly BodyLine[],
|
|
@@ -470,7 +473,7 @@ function foldBullets(
|
|
|
470
473
|
close()
|
|
471
474
|
open = {
|
|
472
475
|
line: line.number,
|
|
473
|
-
characters: visibleText(text).length,
|
|
476
|
+
characters: visibleText(text.replace(LIST_ITEM, '')).length,
|
|
474
477
|
text,
|
|
475
478
|
indent: item[1].length,
|
|
476
479
|
}
|
package/src/migrate/records.ts
CHANGED
|
@@ -56,14 +56,21 @@ export function destinationPath(entry: string): string {
|
|
|
56
56
|
* shorter entry can claim its prefix, and each entry is escaped because two of
|
|
57
57
|
* them carry a dot.
|
|
58
58
|
*/
|
|
59
|
+
const ENTRY_ALTERNATION = [...MOVED_ENTRIES]
|
|
60
|
+
.sort((left, right) => right.length - left.length)
|
|
61
|
+
.map(escape)
|
|
62
|
+
.join('|')
|
|
63
|
+
|
|
59
64
|
const CITATION = new RegExp(
|
|
60
|
-
`${escape(FROM_ROOT)}/(${[
|
|
61
|
-
.sort((left, right) => right.length - left.length)
|
|
62
|
-
.map(escape)
|
|
63
|
-
.join('|')})(?![A-Za-z0-9._-])`,
|
|
65
|
+
`${escape(FROM_ROOT)}/(${ENTRY_ALTERNATION})(?![A-Za-z0-9._-])`,
|
|
64
66
|
'g',
|
|
65
67
|
)
|
|
66
68
|
|
|
69
|
+
/** A gitignore line that is wholly an entry for one moved folder or file. */
|
|
70
|
+
const IGNORE_ENTRY = new RegExp(
|
|
71
|
+
`^/?${escape(FROM_ROOT)}/(?:${ENTRY_ALTERNATION})/?$`,
|
|
72
|
+
)
|
|
73
|
+
|
|
67
74
|
function escape(value: string): string {
|
|
68
75
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
69
76
|
}
|
|
@@ -386,6 +393,30 @@ export function scanText(text: string): {
|
|
|
386
393
|
return { rewritten, kept, globs, crossRepo, dated }
|
|
387
394
|
}
|
|
388
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Deletes each live line that is wholly an entry for a moved folder, counting
|
|
398
|
+
* them. Only a root `.gitignore` that already ignores `.canon/` may be handed
|
|
399
|
+
* here, since the dropped line is redundant only under that entry. Splitting on
|
|
400
|
+
* `\n` and matching the trimmed line leaves CRLF endings and a missing final
|
|
401
|
+
* newline on the lines that stay.
|
|
402
|
+
*/
|
|
403
|
+
export function dropCoveredIgnoreLines(text: string): {
|
|
404
|
+
readonly text: string
|
|
405
|
+
readonly dropped: number
|
|
406
|
+
} {
|
|
407
|
+
const lines = text.split('\n')
|
|
408
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
409
|
+
const kept = lines.filter(
|
|
410
|
+
(line, index) =>
|
|
411
|
+
!(
|
|
412
|
+
IGNORE_ENTRY.test(line.trim()) &&
|
|
413
|
+
classifyLine(lines, index, frontmatterEndIndex) === 'live'
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return { text: kept.join('\n'), dropped: lines.length - kept.length }
|
|
418
|
+
}
|
|
419
|
+
|
|
389
420
|
/** Where a citation sits, so a reader can judge it before `--write` runs. */
|
|
390
421
|
export interface CitationLine {
|
|
391
422
|
readonly line: number
|
|
@@ -503,6 +534,7 @@ export interface CitationEntry {
|
|
|
503
534
|
readonly text: string
|
|
504
535
|
readonly rewritten: number
|
|
505
536
|
readonly kept: number
|
|
537
|
+
readonly dropped: number
|
|
506
538
|
}
|
|
507
539
|
|
|
508
540
|
/** One file whose frontmatter `paths:` glob names a moved root, held rather than rewritten. */
|
|
@@ -533,6 +565,7 @@ export interface RecordsPlan {
|
|
|
533
565
|
readonly crossRepoCitations: readonly CrossRepoCitationEntry[]
|
|
534
566
|
readonly datedCitations: readonly DatedCitationEntry[]
|
|
535
567
|
readonly rewritten: number
|
|
568
|
+
readonly dropped: number
|
|
536
569
|
readonly kept: number
|
|
537
570
|
readonly globs: number
|
|
538
571
|
readonly crossRepo: number
|
|
@@ -578,7 +611,11 @@ export function planRecordsMove(
|
|
|
578
611
|
continue
|
|
579
612
|
}
|
|
580
613
|
|
|
581
|
-
const
|
|
614
|
+
const swept =
|
|
615
|
+
source.path === '.gitignore' && ignoresDestination(source.text)
|
|
616
|
+
? dropCoveredIgnoreLines(source.text)
|
|
617
|
+
: { text: source.text, dropped: 0 }
|
|
618
|
+
const counts = scanText(swept.text)
|
|
582
619
|
kept += counts.kept
|
|
583
620
|
globs += counts.globs
|
|
584
621
|
crossRepo += counts.crossRepo
|
|
@@ -599,15 +636,16 @@ export function planRecordsMove(
|
|
|
599
636
|
if (dates.length > 0)
|
|
600
637
|
datedCitations.push({ path: source.path, lines: dates })
|
|
601
638
|
|
|
602
|
-
if (counts.rewritten === 0) continue
|
|
639
|
+
if (counts.rewritten === 0 && swept.dropped === 0) continue
|
|
603
640
|
|
|
604
641
|
if (referencesExcluded(source.text)) coupled.push(source.path)
|
|
605
642
|
|
|
606
643
|
entries.push({
|
|
607
644
|
path: source.path,
|
|
608
|
-
text: rewriteText(
|
|
645
|
+
text: rewriteText(swept.text),
|
|
609
646
|
rewritten: counts.rewritten,
|
|
610
647
|
kept: counts.kept,
|
|
648
|
+
dropped: swept.dropped,
|
|
611
649
|
})
|
|
612
650
|
}
|
|
613
651
|
|
|
@@ -621,6 +659,7 @@ export function planRecordsMove(
|
|
|
621
659
|
crossRepoCitations,
|
|
622
660
|
datedCitations,
|
|
623
661
|
rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
|
|
662
|
+
dropped: entries.reduce((sum, entry) => sum + entry.dropped, 0),
|
|
624
663
|
kept,
|
|
625
664
|
globs,
|
|
626
665
|
crossRepo,
|
|
@@ -5,8 +5,8 @@ import { relative } from 'node:path'
|
|
|
5
5
|
import { gitEnv } from '@/git/env'
|
|
6
6
|
import { surfaceDir } from '@/roots/surface'
|
|
7
7
|
|
|
8
|
-
/** The
|
|
9
|
-
const CANONICAL_DOCS = ['REQUIREMENTS.md'] as const
|
|
8
|
+
/** The records carrying a `reviewed:` review point, whose drift is how far the project moved since they were read whole. */
|
|
9
|
+
const CANONICAL_DOCS = ['REQUIREMENTS.md', 'DESIGN.md'] as const
|
|
10
10
|
|
|
11
11
|
export interface CanonicalDoc {
|
|
12
12
|
/** Relative to the root, at whichever surface root carries the doc. */
|
package/src/sync/check.ts
CHANGED
|
@@ -105,8 +105,13 @@ export interface UpstreamCommit {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/** Pending changes per category, from the same scan `canon tooling sync` reads. */
|
|
108
|
+
/**
|
|
109
|
+
* `customized` counts a customization whose golden copy moved, which no write
|
|
110
|
+
* touches, so a caller points at `canon tooling lift` rather than `--write`.
|
|
111
|
+
*/
|
|
108
112
|
export interface ToolingCounts {
|
|
109
113
|
readonly configs: number
|
|
114
|
+
readonly customized: number
|
|
110
115
|
readonly seeds: number
|
|
111
116
|
readonly scripts: number
|
|
112
117
|
readonly deps: number
|
|
@@ -137,6 +142,7 @@ const UNMEASURED_TOOLING: ToolingReport = {
|
|
|
137
142
|
chain: [],
|
|
138
143
|
counts: {
|
|
139
144
|
configs: 0,
|
|
145
|
+
customized: 0,
|
|
140
146
|
seeds: 0,
|
|
141
147
|
scripts: 0,
|
|
142
148
|
deps: 0,
|
|
@@ -257,6 +263,8 @@ export function buildToolingReport(
|
|
|
257
263
|
counts: {
|
|
258
264
|
configs: result.configs.filter((entry) => entry.state !== 'matching')
|
|
259
265
|
.length,
|
|
266
|
+
customized: result.dispositions.filter((entry) => entry.moved === true)
|
|
267
|
+
.length,
|
|
260
268
|
seeds: result.seeds.filter((entry) => entry.state === 'missing').length,
|
|
261
269
|
scripts: result.scripts.filter((entry) => entry.state !== 'matching')
|
|
262
270
|
.length,
|
|
@@ -275,7 +283,7 @@ export function buildToolingReport(
|
|
|
275
283
|
* skipped stack still shipped. The re-resolution is kept only when its whole
|
|
276
284
|
* stacks are exactly the recorded ones.
|
|
277
285
|
*/
|
|
278
|
-
function resolveRecorded(
|
|
286
|
+
export function resolveRecorded(
|
|
279
287
|
toolkitRoot: string,
|
|
280
288
|
recorded: readonly string[],
|
|
281
289
|
): Manifest[] {
|
package/src/sync/stamp.ts
CHANGED
|
@@ -12,7 +12,8 @@ import { readInstalled } from '@/version/installed'
|
|
|
12
12
|
/**
|
|
13
13
|
* Domains the stamp can record. Governance attributes file by file through
|
|
14
14
|
* the sync engine. Tooling runs its own inject and manifest machinery, so it
|
|
15
|
-
* records the stack chain it resolved
|
|
15
|
+
* records the stack chain it resolved, and hashes only the configs a target
|
|
16
|
+
* matched, which is what lets a later write tell a stale copy from an edit.
|
|
16
17
|
*
|
|
17
18
|
* A stamp written before the standards or snippets install channel closed
|
|
18
19
|
* still carries a `standards` or `snippets` record, and one written before the
|
|
@@ -34,6 +35,23 @@ export interface StampSource {
|
|
|
34
35
|
/** Target-relative posix path to content hash, for one domain. */
|
|
35
36
|
export type DomainHashes = Readonly<Record<string, string>>
|
|
36
37
|
|
|
38
|
+
export const DISPOSITIONS = ['declined', 'customized'] as const
|
|
39
|
+
|
|
40
|
+
export type Disposition = (typeof DISPOSITIONS)[number]
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a project decided about one golden file. `base` is the golden hash a
|
|
44
|
+
* customized file was taken from, so a later scan can tell the copy moved.
|
|
45
|
+
*/
|
|
46
|
+
export interface DispositionEntry {
|
|
47
|
+
readonly disposition: Disposition
|
|
48
|
+
readonly base?: string
|
|
49
|
+
readonly reason?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Golden posix path to the project's decision about it, for one domain. */
|
|
53
|
+
export type DomainDispositions = Readonly<Record<string, DispositionEntry>>
|
|
54
|
+
|
|
37
55
|
/**
|
|
38
56
|
* One domain's record. The anchor sits here rather than at the top level
|
|
39
57
|
* because domains sync independently, and a shared anchor would let a gov sync
|
|
@@ -61,6 +79,12 @@ export interface DomainStamp {
|
|
|
61
79
|
* ancestors internally and a reader needs only the leaf to ask it again.
|
|
62
80
|
*/
|
|
63
81
|
readonly chain?: readonly string[]
|
|
82
|
+
/**
|
|
83
|
+
* Golden files the project declined or customized. Validated apart from the
|
|
84
|
+
* record, so a malformed map reads as empty rather than failing the stamp
|
|
85
|
+
* and costing every other domain its record.
|
|
86
|
+
*/
|
|
87
|
+
readonly dispositions?: DomainDispositions
|
|
64
88
|
}
|
|
65
89
|
|
|
66
90
|
export interface Stamp {
|
|
@@ -199,7 +223,7 @@ function withoutRetired(stamp: Stamp): Stamp {
|
|
|
199
223
|
const domains: Partial<Record<StampDomain, DomainStamp>> = {}
|
|
200
224
|
for (const domain of STAMP_DOMAINS) {
|
|
201
225
|
const record = stamp.domains[domain]
|
|
202
|
-
if (record !== undefined) domains[domain] = record
|
|
226
|
+
if (record !== undefined) domains[domain] = withValidDispositions(record)
|
|
203
227
|
}
|
|
204
228
|
|
|
205
229
|
const covers = Array.isArray(stamp.covers)
|
|
@@ -245,6 +269,14 @@ export function stampedChain(
|
|
|
245
269
|
return stamp?.domains[domain]?.chain ?? []
|
|
246
270
|
}
|
|
247
271
|
|
|
272
|
+
/** The golden files a domain's project declined or customized. */
|
|
273
|
+
export function stampedDispositions(
|
|
274
|
+
stamp: Stamp | undefined,
|
|
275
|
+
domain: StampDomain,
|
|
276
|
+
): DomainDispositions {
|
|
277
|
+
return stamp?.domains[domain]?.dispositions ?? {}
|
|
278
|
+
}
|
|
279
|
+
|
|
248
280
|
/**
|
|
249
281
|
* Replaces one domain's file hashes and leaves the others, including that
|
|
250
282
|
* domain's own chain, untouched. Domains install and sync independently but
|
|
@@ -261,24 +293,61 @@ export async function writeStamp(
|
|
|
261
293
|
}
|
|
262
294
|
|
|
263
295
|
/**
|
|
264
|
-
* Records the stack chain an install resolved and leaves that domain's file
|
|
265
|
-
* hashes untouched
|
|
266
|
-
*
|
|
267
|
-
* Governance calls it alongside `writeStamp`, since
|
|
296
|
+
* Records the stack chain an install resolved, and leaves that domain's file
|
|
297
|
+
* hashes untouched unless `files` is passed. Tooling passes the golden hash of
|
|
298
|
+
* each config the target matches, since it never runs the sync engine.
|
|
299
|
+
* Governance calls it alongside `writeStamp`, since that records its files.
|
|
268
300
|
*/
|
|
269
301
|
export async function writeChainStamp(
|
|
270
302
|
target: string,
|
|
271
303
|
source: StampSource,
|
|
272
304
|
chain: readonly string[],
|
|
273
305
|
now: Date,
|
|
306
|
+
files?: DomainHashes,
|
|
307
|
+
): Promise<void> {
|
|
308
|
+
await putDomain(
|
|
309
|
+
target,
|
|
310
|
+
source,
|
|
311
|
+
{
|
|
312
|
+
chain: [...chain],
|
|
313
|
+
...(files === undefined ? {} : { files: sortKeys(files) }),
|
|
314
|
+
},
|
|
315
|
+
now,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Replaces one domain's dispositions and leaves the rest of its record as it
|
|
321
|
+
* was, sync anchor included, since declining a file is not a sync. A domain
|
|
322
|
+
* with no record yet gets one through the ordinary write.
|
|
323
|
+
*/
|
|
324
|
+
export async function writeDispositions(
|
|
325
|
+
target: string,
|
|
326
|
+
source: StampSource,
|
|
327
|
+
dispositions: DomainDispositions,
|
|
328
|
+
now: Date,
|
|
274
329
|
): Promise<void> {
|
|
275
|
-
|
|
330
|
+
const previous = readStamp(target)
|
|
331
|
+
const previousRecord = previous?.domains[source.domain]
|
|
332
|
+
if (previousRecord === undefined) {
|
|
333
|
+
await putDomain(target, source, { dispositions }, now)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const { dispositions: _replaced, ...rest } = previousRecord
|
|
338
|
+
await persist(
|
|
339
|
+
target,
|
|
340
|
+
previous,
|
|
341
|
+
source.domain,
|
|
342
|
+
{ ...rest, ...dispositionsField(dispositions) },
|
|
343
|
+
now,
|
|
344
|
+
)
|
|
276
345
|
}
|
|
277
346
|
|
|
278
347
|
async function putDomain(
|
|
279
348
|
target: string,
|
|
280
349
|
source: StampSource,
|
|
281
|
-
payload: Partial<Pick<DomainStamp, 'files' | 'chain'>>,
|
|
350
|
+
payload: Partial<Pick<DomainStamp, 'files' | 'chain' | 'dispositions'>>,
|
|
282
351
|
now: Date,
|
|
283
352
|
): Promise<void> {
|
|
284
353
|
const previous = readStamp(target)
|
|
@@ -292,11 +361,24 @@ async function putDomain(
|
|
|
292
361
|
syncedAt: now.toISOString(),
|
|
293
362
|
files: payload.files ?? previousRecord?.files ?? {},
|
|
294
363
|
...resolveChainField(payload.chain, previousRecord?.chain),
|
|
364
|
+
...dispositionsField(
|
|
365
|
+
payload.dispositions ?? previousRecord?.dispositions ?? {},
|
|
366
|
+
),
|
|
295
367
|
}
|
|
296
368
|
|
|
369
|
+
await persist(target, previous, source.domain, record, now)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function persist(
|
|
373
|
+
target: string,
|
|
374
|
+
previous: Stamp | undefined,
|
|
375
|
+
domain: StampDomain,
|
|
376
|
+
record: DomainStamp,
|
|
377
|
+
now: Date,
|
|
378
|
+
): Promise<void> {
|
|
297
379
|
const domains = sortDomains({
|
|
298
380
|
...previous?.domains,
|
|
299
|
-
[
|
|
381
|
+
[domain]: record,
|
|
300
382
|
})
|
|
301
383
|
|
|
302
384
|
const stamp: Stamp = {
|
|
@@ -434,10 +516,52 @@ function resolveChainField(
|
|
|
434
516
|
return resolved === undefined ? {} : { chain: resolved }
|
|
435
517
|
}
|
|
436
518
|
|
|
519
|
+
/** An empty map is left out of the file, as an unrecorded chain is. */
|
|
520
|
+
function dispositionsField(
|
|
521
|
+
dispositions: DomainDispositions,
|
|
522
|
+
): Pick<DomainStamp, 'dispositions'> {
|
|
523
|
+
return Object.keys(dispositions).length === 0
|
|
524
|
+
? {}
|
|
525
|
+
: { dispositions: sortKeys(dispositions) }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Keeps each well-formed entry and drops the rest, so one bad entry costs only
|
|
530
|
+
* itself, and a map that is not an object costs only the map.
|
|
531
|
+
*/
|
|
532
|
+
function withValidDispositions(record: DomainStamp): DomainStamp {
|
|
533
|
+
const { dispositions, ...rest } = record
|
|
534
|
+
const raw: unknown = dispositions
|
|
535
|
+
if (raw === undefined) return record
|
|
536
|
+
if (!isRecord(raw)) return rest
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
...rest,
|
|
540
|
+
...dispositionsField(
|
|
541
|
+
Object.fromEntries(
|
|
542
|
+
Object.entries(raw).filter(([, entry]) => isDispositionEntry(entry)),
|
|
543
|
+
) as DomainDispositions,
|
|
544
|
+
),
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function isDispositionEntry(value: unknown): value is DispositionEntry {
|
|
549
|
+
if (!isRecord(value)) return false
|
|
550
|
+
if (!(DISPOSITIONS as readonly unknown[]).includes(value.disposition)) {
|
|
551
|
+
return false
|
|
552
|
+
}
|
|
553
|
+
if (value.base !== undefined && typeof value.base !== 'string') return false
|
|
554
|
+
return value.reason === undefined || typeof value.reason === 'string'
|
|
555
|
+
}
|
|
556
|
+
|
|
437
557
|
/** Deterministic key order keeps a re-sync diff empty and a merge conflict local. */
|
|
438
|
-
function sortKeys(
|
|
558
|
+
function sortKeys<T>(
|
|
559
|
+
entries: Readonly<Record<string, T>>,
|
|
560
|
+
): Readonly<Record<string, T>> {
|
|
439
561
|
return Object.fromEntries(
|
|
440
|
-
Object.entries(
|
|
562
|
+
Object.entries(entries).sort(([left], [right]) =>
|
|
563
|
+
left.localeCompare(right),
|
|
564
|
+
),
|
|
441
565
|
)
|
|
442
566
|
}
|
|
443
567
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { join, posix } from 'node:path'
|
|
2
|
+
import {
|
|
3
|
+
type DomainDispositions,
|
|
4
|
+
hashFile,
|
|
5
|
+
readStamp,
|
|
6
|
+
stampedDispositions,
|
|
7
|
+
} from '@/sync/stamp'
|
|
8
|
+
import { configFiles, type Manifest, seedFiles } from '@/tooling/manifest'
|
|
9
|
+
|
|
10
|
+
/** A golden file as the chain ships it: the stack that wins, and that copy's hash. */
|
|
11
|
+
export interface GoldenPath {
|
|
12
|
+
readonly rel: string
|
|
13
|
+
readonly stack: string
|
|
14
|
+
readonly kind: 'config' | 'seed'
|
|
15
|
+
readonly hash: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Transition =
|
|
19
|
+
| { readonly ok: true; readonly dispositions: DomainDispositions }
|
|
20
|
+
| { readonly ok: false; readonly reason: string; readonly message: string }
|
|
21
|
+
|
|
22
|
+
export function readDispositions(target: string): DomainDispositions {
|
|
23
|
+
return stampedDispositions(readStamp(target), 'tooling')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The posix key a target-relative path is recorded under, or undefined for a
|
|
28
|
+
* path that leaves the target, since no golden file lives there.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeGoldenPath(input: string): string | undefined {
|
|
31
|
+
const rel = posix.normalize(input.split('\\').join('/'))
|
|
32
|
+
if (rel === '.' || rel === '' || posix.isAbsolute(rel)) return undefined
|
|
33
|
+
if (rel === '..' || rel.startsWith('../')) return undefined
|
|
34
|
+
return rel
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolves a path the way `scan` and the injectors do: nearest stack first,
|
|
39
|
+
* and a config shadows a seed at the same path. The hash is of the copy
|
|
40
|
+
* `injectConfigs` writes last, which is the nearest stack's.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveGolden(
|
|
43
|
+
chain: readonly Manifest[],
|
|
44
|
+
rel: string,
|
|
45
|
+
): GoldenPath | undefined {
|
|
46
|
+
for (const manifest of chain) {
|
|
47
|
+
if (configFiles(manifest).includes(rel)) {
|
|
48
|
+
return golden(manifest, rel, 'config', manifest.configsDir)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const manifest of chain) {
|
|
52
|
+
if (seedFiles(manifest).includes(rel)) {
|
|
53
|
+
return golden(manifest, rel, 'seed', manifest.seedsDir)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function golden(
|
|
60
|
+
manifest: Manifest,
|
|
61
|
+
rel: string,
|
|
62
|
+
kind: GoldenPath['kind'],
|
|
63
|
+
dir: string,
|
|
64
|
+
): GoldenPath {
|
|
65
|
+
return { rel, stack: manifest.name, kind, hash: hashFile(join(dir, rel)) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function decline(
|
|
69
|
+
dispositions: DomainDispositions,
|
|
70
|
+
path: GoldenPath | undefined,
|
|
71
|
+
reason?: string,
|
|
72
|
+
): Transition {
|
|
73
|
+
if (path === undefined) return NOT_GOLDEN
|
|
74
|
+
const { rel } = path
|
|
75
|
+
const held = refuseHeld(dispositions, rel)
|
|
76
|
+
if (held !== undefined) return held
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
dispositions: {
|
|
81
|
+
...dispositions,
|
|
82
|
+
[rel]: {
|
|
83
|
+
disposition: 'declined',
|
|
84
|
+
...(reason === undefined ? {} : { reason }),
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A seed is copy-once, so the target already owns it and a customization
|
|
92
|
+
* would record nothing a sync does not already respect.
|
|
93
|
+
*/
|
|
94
|
+
export function customize(
|
|
95
|
+
dispositions: DomainDispositions,
|
|
96
|
+
path: GoldenPath | undefined,
|
|
97
|
+
present: boolean,
|
|
98
|
+
): Transition {
|
|
99
|
+
if (path === undefined) return NOT_GOLDEN
|
|
100
|
+
const { rel, kind, hash } = path
|
|
101
|
+
const held = refuseHeld(dispositions, rel)
|
|
102
|
+
if (held !== undefined) return held
|
|
103
|
+
|
|
104
|
+
if (kind === 'seed') {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
reason: 'seed-not-customizable',
|
|
108
|
+
message: `${rel} is a seed, which sync never overwrites. Decline it instead to keep sync from restoring it.`,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!present) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: 'missing-file',
|
|
115
|
+
message: `${rel} is not in the target, so there is no copy to keep. Decline it instead.`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
dispositions: {
|
|
122
|
+
...dispositions,
|
|
123
|
+
[rel]: { disposition: 'customized', base: hash },
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Lifts by key alone, so a path the chain stopped shipping can still be
|
|
130
|
+
* cleared from the record.
|
|
131
|
+
*/
|
|
132
|
+
export function lift(
|
|
133
|
+
dispositions: DomainDispositions,
|
|
134
|
+
rel: string,
|
|
135
|
+
): Transition {
|
|
136
|
+
if (dispositions[rel] === undefined) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason: 'not-held',
|
|
140
|
+
message: `${rel} is neither declined nor customized.`,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { [rel]: _lifted, ...rest } = dispositions
|
|
145
|
+
return { ok: true, dispositions: rest }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const NOT_GOLDEN: Transition = {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: 'not-golden',
|
|
151
|
+
message:
|
|
152
|
+
'No stack in the recorded chain ships that path. Pass it relative to the target, as `canon tooling diff` lists it.',
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function refuseHeld(
|
|
156
|
+
dispositions: DomainDispositions,
|
|
157
|
+
rel: string,
|
|
158
|
+
): Transition | undefined {
|
|
159
|
+
const held = dispositions[rel]
|
|
160
|
+
if (held === undefined) return undefined
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
reason: 'already-held',
|
|
165
|
+
message: `${rel} is already ${held.disposition}. Run \`canon tooling lift ${rel}\` first to change it.`,
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/tooling/inject.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join, relative } from 'node:path'
|
|
4
4
|
import { copyPreservingMode } from '@/sync/copy'
|
|
5
|
+
import { readDispositions } from '@/tooling/disposition'
|
|
5
6
|
import { type InstallResult, installDevDependencies } from '@/tooling/install'
|
|
6
7
|
import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
|
|
7
8
|
import { resolveSurfacePath } from '@/roots/surface'
|
|
@@ -24,16 +25,27 @@ import {
|
|
|
24
25
|
import { isSubfolderTarget, isWithheldInSubfolder } from '@/tooling/subfolder'
|
|
25
26
|
import { logAdd, logRemove, logStep, logWarn } from '@/cli/ui'
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Skips every path the project declined or customized, which is what makes
|
|
30
|
+
* the record hold for `sync --write` and `inject` alike. `hold` names the
|
|
31
|
+
* further paths a caller decided this write must leave, which `sync` derives
|
|
32
|
+
* from the scan and `inject`, writing without one, never passes.
|
|
33
|
+
*/
|
|
27
34
|
export async function injectConfigs(
|
|
28
35
|
chain: readonly Manifest[],
|
|
29
36
|
target: string,
|
|
37
|
+
hold: ReadonlySet<string> = new Set(),
|
|
30
38
|
): Promise<string[]> {
|
|
31
39
|
const applied: string[] = []
|
|
32
40
|
const isSubfolder = isSubfolderTarget(target)
|
|
41
|
+
const held = readDispositions(target)
|
|
33
42
|
|
|
34
43
|
for (const manifest of ancestorsFirst(chain)) {
|
|
35
44
|
const files = configFiles(manifest).filter(
|
|
36
|
-
(rel) =>
|
|
45
|
+
(rel) =>
|
|
46
|
+
held[rel] === undefined &&
|
|
47
|
+
!hold.has(rel) &&
|
|
48
|
+
!(isSubfolder && isWithheldInSubfolder(rel)),
|
|
37
49
|
)
|
|
38
50
|
if (files.length === 0) continue
|
|
39
51
|
|
|
@@ -83,11 +95,14 @@ export async function injectSeeds(
|
|
|
83
95
|
const applied: string[] = []
|
|
84
96
|
const isSubfolder = isSubfolderTarget(target)
|
|
85
97
|
const shadowed = configPaths(chain)
|
|
98
|
+
const held = readDispositions(target)
|
|
86
99
|
|
|
87
100
|
for (const manifest of ancestorsFirst(chain)) {
|
|
88
101
|
const files = seedFiles(manifest).filter(
|
|
89
102
|
(rel) =>
|
|
90
|
-
!shadowed.has(rel) &&
|
|
103
|
+
!shadowed.has(rel) &&
|
|
104
|
+
held[rel] === undefined &&
|
|
105
|
+
!(isSubfolder && isWithheldInSubfolder(rel)),
|
|
91
106
|
)
|
|
92
107
|
if (files.length === 0) continue
|
|
93
108
|
|