@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +2 -1
  5. package/claude/skills/design-identity/references/card-route.md +3 -3
  6. package/claude/skills/design-record/references/design.md +19 -0
  7. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  8. package/claude/skills/document-health/SKILL.md +6 -5
  9. package/claude/skills/document-health/references/axes.md +3 -1
  10. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  11. package/claude/skills/review-ui/SKILL.md +3 -2
  12. package/claude/skills/review-ui/references/post.md +2 -2
  13. package/claude/skills/target-update/SKILL.md +9 -1
  14. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  15. package/claude/skills/ui-checklist/SKILL.md +12 -0
  16. package/claude/skills/ux-audit/SKILL.md +1 -1
  17. package/docs/agents/install-and-sync.md +45 -11
  18. package/docs/agents/records.md +1 -1
  19. package/docs/index.md +1 -1
  20. package/docs/target/migrations.md +7 -9
  21. package/docs/target/sync.md +20 -1
  22. package/docs/workflow/ai-workflow.md +1 -1
  23. package/docs/workflow/index.md +3 -3
  24. package/docs/workflow/parallel-features.md +1 -1
  25. package/docs/workflow/visual-design-workflow.md +58 -32
  26. package/governance/detect.toml +8 -0
  27. package/governance/ledger/ui/a11y.md +8 -6
  28. package/governance/ledger/ui/keyboard.md +20 -21
  29. package/governance/ledger/ui/pointer.md +24 -0
  30. package/governance/ledger/ui/surface-capture.md +12 -12
  31. package/governance/ledger/ui/ux-completeness.md +11 -23
  32. package/governance/ledger/ui/ux-controls.md +26 -0
  33. package/governance/rules/ui/a11y.md +2 -0
  34. package/governance/rules/ui/keyboard.md +4 -7
  35. package/governance/rules/ui/pointer.md +23 -0
  36. package/governance/rules/ui/surface-capture.md +5 -5
  37. package/governance/rules/ui/ux-completeness.md +2 -10
  38. package/governance/rules/ui/ux-controls.md +25 -0
  39. package/governance/splits.toml +15 -0
  40. package/package.json +2 -1
  41. package/src/commands/driver.ts +29 -3
  42. package/src/commands/migrate.ts +7 -0
  43. package/src/commands/records.ts +3 -2
  44. package/src/commands/sync.ts +3 -1
  45. package/src/commands/tooling.ts +329 -10
  46. package/src/design/audit.ts +25 -1
  47. package/src/design/budget.ts +2 -1
  48. package/src/design/document.ts +5 -1
  49. package/src/design/themes.ts +176 -0
  50. package/src/design/tokens.ts +3 -0
  51. package/src/driver/drive.ts +17 -1
  52. package/src/driver/probes/axe.ts +195 -0
  53. package/src/driver/steps.ts +20 -1
  54. package/src/gate/measures.ts +9 -3
  55. package/src/gov/adapter.ts +53 -4
  56. package/src/gov/splits.ts +36 -0
  57. package/src/markdown/structure.ts +4 -1
  58. package/src/migrate/records.ts +46 -7
  59. package/src/records/stale-canonical.ts +2 -2
  60. package/src/sync/check.ts +9 -1
  61. package/src/sync/stamp.ts +135 -11
  62. package/src/tooling/disposition.ts +167 -0
  63. package/src/tooling/inject.ts +17 -2
  64. package/src/tooling/scan.ts +86 -7
  65. 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
  }
@@ -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)}/(${[...MOVED_ENTRIES]
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 counts = scanText(source.text)
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(source.text),
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 record whose drift is identity rather than a stale path. */
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 instead and carries no file hashes.
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. Tooling calls this with no files ever recorded, since
266
- * `src/tooling/` never runs the sync engine and the chain is its whole record.
267
- * Governance calls it alongside `writeStamp`, since it records both.
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
- await putDomain(target, source, { chain: [...chain] }, now)
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
- [source.domain]: record,
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(hashes: DomainHashes): DomainHashes {
558
+ function sortKeys<T>(
559
+ entries: Readonly<Record<string, T>>,
560
+ ): Readonly<Record<string, T>> {
439
561
  return Object.fromEntries(
440
- Object.entries(hashes).sort(([left], [right]) => left.localeCompare(right)),
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
+ }
@@ -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) => !(isSubfolder && isWithheldInSubfolder(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) && !(isSubfolder && isWithheldInSubfolder(rel)),
103
+ !shadowed.has(rel) &&
104
+ held[rel] === undefined &&
105
+ !(isSubfolder && isWithheldInSubfolder(rel)),
91
106
  )
92
107
  if (files.length === 0) continue
93
108