@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
@@ -1,6 +1,13 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { resolveSurfacePath } from '@/roots/surface'
4
+ import {
5
+ type Disposition,
6
+ hashFile,
7
+ readStamp,
8
+ stampedDispositions,
9
+ stampedHashes,
10
+ } from '@/sync/stamp'
4
11
  import { mergeSections } from '@/tooling/gitignore'
5
12
  import {
6
13
  ancestorsFirst,
@@ -19,10 +26,14 @@ import {
19
26
  } from '@/tooling/package'
20
27
  import { isSubfolderTarget, isWithheldInSubfolder } from '@/tooling/subfolder'
21
28
 
29
+ export type DriftCause = 'stale' | 'edited' | 'unattributed'
30
+
31
+ /** `cause` is set on a drifted config alone. */
22
32
  export interface ConfigState {
23
33
  readonly rel: string
24
34
  readonly stack: string
25
35
  readonly state: 'new' | 'drifted' | 'matching'
36
+ readonly cause?: DriftCause
26
37
  }
27
38
 
28
39
  export interface SeedState {
@@ -41,9 +52,24 @@ export interface WithheldState {
41
52
  readonly present: boolean
42
53
  }
43
54
 
55
+ /**
56
+ * A golden file the project declined or customized, which every write skips.
57
+ * `moved` is set on a customized config alone, true once the golden copy no
58
+ * longer hashes to the one the project took.
59
+ */
60
+ export interface DispositionState {
61
+ readonly rel: string
62
+ readonly stack: string
63
+ readonly kind: 'config' | 'seed'
64
+ readonly disposition: Disposition
65
+ readonly present: boolean
66
+ readonly moved?: boolean
67
+ }
68
+
44
69
  export interface ScanResult {
45
70
  readonly configs: readonly ConfigState[]
46
71
  readonly withheld: readonly WithheldState[]
72
+ readonly dispositions: readonly DispositionState[]
47
73
  readonly seeds: readonly SeedState[]
48
74
  readonly scripts: readonly ScriptState[]
49
75
  readonly deps: readonly DepState[]
@@ -53,6 +79,17 @@ export interface ScanResult {
53
79
  readonly totalChanges: number
54
80
  }
55
81
 
82
+ /**
83
+ * Why a drifted config differs, which decides whether a write may replace it.
84
+ * A copy still at the hash the last write recorded is `stale`, since canon
85
+ * moved and the project did not. A copy off it is `edited`, and a copy with no
86
+ * recorded hash is `unattributed`, since nothing says who changed it.
87
+ */
88
+ function driftCause(recorded: string | undefined, dest: string): DriftCause {
89
+ if (recorded === undefined) return 'unattributed'
90
+ return hashFile(dest) === recorded ? 'stale' : 'edited'
91
+ }
92
+
56
93
  function isIdentical(a: string, b: string): boolean {
57
94
  if (!existsSync(b)) return false
58
95
  return readFileSync(a).equals(readFileSync(b))
@@ -72,11 +109,19 @@ function isIdentical(a: string, b: string): boolean {
72
109
  * GitHub reads only at the root moves to `withheld` and counts as no change.
73
110
  * The scope is read here so every caller of `scan` agrees without passing a
74
111
  * flag.
112
+ *
113
+ * A path the project declined or customized moves to `dispositions` ahead of
114
+ * the subfolder test, so it is listed once. The record is read here for the
115
+ * same reason as the scope.
75
116
  */
76
117
  export function scan(chain: readonly Manifest[], target: string): ScanResult {
77
118
  const isSubfolder = isSubfolderTarget(target)
119
+ const stamp = readStamp(target)
120
+ const held = stampedDispositions(stamp, 'tooling')
121
+ const recorded = stampedHashes(stamp, 'tooling')
78
122
  const configs: ConfigState[] = []
79
123
  const withheld: WithheldState[] = []
124
+ const dispositions: DispositionState[] = []
80
125
  const seenConfigs = new Set<string>()
81
126
 
82
127
  for (const manifest of chain) {
@@ -87,18 +132,38 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
87
132
  const source = join(manifest.configsDir, rel)
88
133
  const dest = join(target, rel)
89
134
 
135
+ const entry = held[rel]
136
+ if (entry !== undefined) {
137
+ dispositions.push({
138
+ rel,
139
+ stack: manifest.name,
140
+ kind: 'config',
141
+ disposition: entry.disposition,
142
+ present: existsSync(dest),
143
+ ...(entry.disposition === 'customized'
144
+ ? { moved: hashFile(source) !== entry.base }
145
+ : {}),
146
+ })
147
+ continue
148
+ }
149
+
90
150
  if (isSubfolder && isWithheldInSubfolder(rel)) {
91
151
  withheld.push({ rel, stack: manifest.name, present: existsSync(dest) })
92
152
  continue
93
153
  }
94
154
 
95
- const state = !existsSync(dest)
96
- ? 'new'
97
- : isIdentical(source, dest)
98
- ? 'matching'
99
- : 'drifted'
100
-
101
- configs.push({ rel, stack: manifest.name, state })
155
+ if (!existsSync(dest)) {
156
+ configs.push({ rel, stack: manifest.name, state: 'new' })
157
+ } else if (isIdentical(source, dest)) {
158
+ configs.push({ rel, stack: manifest.name, state: 'matching' })
159
+ } else {
160
+ configs.push({
161
+ rel,
162
+ stack: manifest.name,
163
+ state: 'drifted',
164
+ cause: driftCause(recorded[rel], dest),
165
+ })
166
+ }
102
167
  }
103
168
  }
104
169
 
@@ -110,6 +175,18 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
110
175
  if (seenSeeds.has(rel)) continue
111
176
  seenSeeds.add(rel)
112
177
 
178
+ const entry = held[rel]
179
+ if (entry !== undefined) {
180
+ dispositions.push({
181
+ rel,
182
+ stack: manifest.name,
183
+ kind: 'seed',
184
+ disposition: entry.disposition,
185
+ present: existsSync(resolveSurfacePath(target, rel)),
186
+ })
187
+ continue
188
+ }
189
+
113
190
  if (isSubfolder && isWithheldInSubfolder(rel)) {
114
191
  withheld.push({
115
192
  rel,
@@ -144,6 +221,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
144
221
  const totalChanges =
145
222
  (newManifest ? 1 : 0) +
146
223
  configs.filter((entry) => entry.state !== 'matching').length +
224
+ dispositions.filter((entry) => entry.moved === true).length +
147
225
  seeds.filter((entry) => entry.state === 'missing').length +
148
226
  scripts.filter((entry) => entry.state !== 'matching').length +
149
227
  deps.filter((entry) => entry.state === 'missing').length +
@@ -152,6 +230,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
152
230
  return {
153
231
  configs,
154
232
  withheld,
233
+ dispositions,
155
234
  seeds,
156
235
  scripts,
157
236
  deps,
@@ -1,7 +1,19 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { readStamp, stampedChain, writeChainStamp } from '@/sync/stamp'
4
- import { isWholeStack, loadManifest, type Manifest } from '@/tooling/manifest'
3
+ import {
4
+ type DomainHashes,
5
+ hashFile,
6
+ readStamp,
7
+ stampedChain,
8
+ stampedHashes,
9
+ writeChainStamp,
10
+ } from '@/sync/stamp'
11
+ import {
12
+ configFiles,
13
+ isWholeStack,
14
+ loadManifest,
15
+ type Manifest,
16
+ } from '@/tooling/manifest'
5
17
  import { readPackage } from '@/tooling/package'
6
18
 
7
19
  /**
@@ -32,15 +44,47 @@ export async function recordToolingChain(
32
44
  ): Promise<string[] | undefined> {
33
45
  if (isWorkspaceRoot(target)) return undefined
34
46
 
35
- const merged = mergeAddOns(
36
- toolkitRoot,
37
- stampedChain(readStamp(target), 'tooling'),
38
- chain,
47
+ const stamp = readStamp(target)
48
+ const merged = mergeAddOns(toolkitRoot, stampedChain(stamp, 'tooling'), chain)
49
+ await writeChainStamp(
50
+ target,
51
+ { domain: 'tooling', toolkitRoot },
52
+ merged,
53
+ now,
54
+ matchedHashes(chain, target, stampedHashes(stamp, 'tooling')),
39
55
  )
40
- await writeChainStamp(target, { domain: 'tooling', toolkitRoot }, merged, now)
41
56
  return merged
42
57
  }
43
58
 
59
+ /**
60
+ * The golden hash of every config the target now matches, over the hashes an
61
+ * earlier run recorded. A config the target holds a different copy of keeps
62
+ * its previous hash, so a held edit still reads as edited on the next run
63
+ * rather than as a stale copy the write may replace.
64
+ */
65
+ function matchedHashes(
66
+ chain: readonly Manifest[],
67
+ target: string,
68
+ previous: DomainHashes,
69
+ ): DomainHashes {
70
+ const hashes: Record<string, string> = { ...previous }
71
+ const seen = new Set<string>()
72
+
73
+ for (const manifest of chain) {
74
+ for (const rel of configFiles(manifest)) {
75
+ if (seen.has(rel)) continue
76
+ seen.add(rel)
77
+
78
+ const dest = join(target, rel)
79
+ if (!existsSync(dest)) continue
80
+ const golden = hashFile(join(manifest.configsDir, rel))
81
+ if (hashFile(dest) === golden) hashes[rel] = golden
82
+ }
83
+ }
84
+
85
+ return hashes
86
+ }
87
+
44
88
  /**
45
89
  * An add-on stands alone beside a core chain, so syncing one must not displace
46
90
  * the chain it layers onto. Base extends nothing and still heads a core chain.