@erclx/canon 4.40.1 → 4.41.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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The move of a target's installed rules from the flat `.claude/rules/<subdir>/`
3
+ * layout onto `.claude/rules/canon/<subdir>/`.
4
+ *
5
+ * `#1446` gave this repository's own installed tree a `canon/` wrapper and no
6
+ * way for an already-synced target to follow. `canon gov sync` narrowed its
7
+ * walk to that wrapper the same release, so a target still on the flat layout
8
+ * reads back as unstamped rather than as needing this move, and `canon gov
9
+ * install` writes a second copy beside the stale one rather than detecting it.
10
+ * This module is the mover neither of those verbs is.
11
+ */
12
+
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import { mkdir, readdir, rename, unlink } from 'node:fs/promises'
15
+ import { dirname, join } from 'node:path'
16
+ import {
17
+ type DomainHashes,
18
+ hashFile,
19
+ readStamp,
20
+ stampedHashes,
21
+ toStampKey,
22
+ writeStamp,
23
+ } from '@/sync/stamp'
24
+
25
+ /**
26
+ * The one rule `#1446` renumbered, keyed and valued as `<subdir>/<name>` so a
27
+ * future renumber that also changes subdirectory is representable without a
28
+ * second field. A name match cannot find this rule under its old name, since
29
+ * that name no longer exists in `governance/rules/`, so the table carries the
30
+ * one case a lookup cannot.
31
+ */
32
+ export const RENUMBERED_RULES: Readonly<Record<string, string>> = {
33
+ 'snippets/505-at-references': 'snippets/600-at-references',
34
+ }
35
+
36
+ /**
37
+ * Band folders the walk skips by name rather than by content, so the
38
+ * exclusion holds even for a band the toolkit has not shipped yet.
39
+ * `canon/` and `internal/` are already-migrated destinations, and
40
+ * `project/` is a target's own rules, which this verb never touches.
41
+ */
42
+ const EXCLUDED_BANDS: readonly string[] = ['project', 'canon', 'internal']
43
+
44
+ /** One flat rule file, before classification. */
45
+ export interface FlatRuleFile {
46
+ readonly rel: string
47
+ readonly subdir: string
48
+ readonly name: string
49
+ }
50
+
51
+ function relPath(...segments: readonly string[]): string {
52
+ return segments.join('/')
53
+ }
54
+
55
+ export function flatRulesRoot(target: string): string {
56
+ return join(target, '.claude', 'rules')
57
+ }
58
+
59
+ /**
60
+ * Every rule file sitting directly under a flat band folder. One level for
61
+ * the band and one glob below it for `*.md`, since `governance/rules/` never
62
+ * nests a rule deeper than its band, and the flat layout this verb migrates
63
+ * off of mirrors that depth.
64
+ */
65
+ export async function walkFlatRules(target: string): Promise<FlatRuleFile[]> {
66
+ const root = flatRulesRoot(target)
67
+ const entries = await readdir(root, { withFileTypes: true }).catch(
68
+ () => undefined,
69
+ )
70
+ if (entries === undefined) return []
71
+
72
+ const bands = entries.filter(
73
+ (entry) => entry.isDirectory() && !EXCLUDED_BANDS.includes(entry.name),
74
+ )
75
+
76
+ const files: FlatRuleFile[] = []
77
+ for (const band of bands) {
78
+ const glob = new Bun.Glob('*.md')
79
+ for (const name of glob.scanSync({
80
+ cwd: join(root, band.name),
81
+ onlyFiles: true,
82
+ })) {
83
+ files.push({
84
+ rel: relPath('.claude', 'rules', band.name, name),
85
+ subdir: band.name,
86
+ name: name.slice(0, -'.md'.length),
87
+ })
88
+ }
89
+ }
90
+
91
+ files.sort((left, right) => left.rel.localeCompare(right.rel))
92
+ return files
93
+ }
94
+
95
+ export type RuleStatus = 'clean' | 'edited'
96
+
97
+ /**
98
+ * Where a stamp entry exists, an exact hash match is `clean` and any other
99
+ * value is `edited`. Where none exists, a name recognized by the current
100
+ * catalog or the renumber table is read as `clean` too, since nothing on
101
+ * this path suggests an edit, only that a hash cannot confirm one. A name
102
+ * neither source recognizes is `unclaimed`, on the reasoning
103
+ * `misplacedOrphan` in `src/sync/engine.ts` already states for why a mover
104
+ * must not guess who authored a file.
105
+ */
106
+ export function classifyRule(
107
+ root: string,
108
+ file: FlatRuleFile,
109
+ hashes: DomainHashes,
110
+ catalog: ReadonlySet<string>,
111
+ ): RuleStatus | 'unclaimed' {
112
+ const stamped = hashes[toStampKey(file.rel)]
113
+ if (stamped !== undefined) {
114
+ return stamped === hashFile(join(root, file.rel)) ? 'clean' : 'edited'
115
+ }
116
+
117
+ const renumberKey = relPath(file.subdir, file.name)
118
+ return catalog.has(file.name) || renumberKey in RENUMBERED_RULES
119
+ ? 'clean'
120
+ : 'unclaimed'
121
+ }
122
+
123
+ /**
124
+ * Where a classified file lands. The renumbered rule reads its destination
125
+ * from the table instead of its own current name, and every other file keeps
126
+ * its current subdirectory with `canon/` inserted ahead of it, rather than
127
+ * recomputing one from the toolkit's current tree, since a subdirectory
128
+ * reconciliation independent of this move is an ordinary sync concern.
129
+ */
130
+ export function destinationRel(file: FlatRuleFile): string {
131
+ const key = relPath(file.subdir, file.name)
132
+ const [subdir, name] = (RENUMBERED_RULES[key] ?? key).split('/')
133
+ return relPath('.claude', 'rules', 'canon', subdir, `${name}.md`)
134
+ }
135
+
136
+ function sameBytes(left: string, right: string): boolean {
137
+ return readFileSync(left).equals(readFileSync(right))
138
+ }
139
+
140
+ export interface MoveEntry {
141
+ readonly from: string
142
+ readonly to: string
143
+ readonly status: RuleStatus
144
+ }
145
+
146
+ /** A flat file whose destination already holds identical bytes. */
147
+ export interface DuplicateEntry {
148
+ readonly path: string
149
+ readonly destination: string
150
+ }
151
+
152
+ /** A flat file whose destination already holds different bytes. Neither side moves. */
153
+ export interface CollisionEntry {
154
+ readonly path: string
155
+ readonly destination: string
156
+ }
157
+
158
+ export interface RuleLayoutPlan {
159
+ readonly moves: readonly MoveEntry[]
160
+ readonly duplicates: readonly DuplicateEntry[]
161
+ readonly collisions: readonly CollisionEntry[]
162
+ readonly unclaimed: readonly string[]
163
+ }
164
+
165
+ /**
166
+ * What the move would do, without doing it.
167
+ *
168
+ * A collision is reported by name with neither file touched, and every other
169
+ * planned move proceeds independently rather than the whole run refusing, per
170
+ * the concurrency standard's rule for partial failure in a batched operation.
171
+ */
172
+ export function planRuleLayout(
173
+ root: string,
174
+ files: readonly FlatRuleFile[],
175
+ hashes: DomainHashes,
176
+ catalog: ReadonlySet<string>,
177
+ ): RuleLayoutPlan {
178
+ const moves: MoveEntry[] = []
179
+ const duplicates: DuplicateEntry[] = []
180
+ const collisions: CollisionEntry[] = []
181
+ const unclaimed: string[] = []
182
+
183
+ for (const file of files) {
184
+ const status = classifyRule(root, file, hashes, catalog)
185
+ if (status === 'unclaimed') {
186
+ unclaimed.push(file.rel)
187
+ continue
188
+ }
189
+
190
+ const destination = destinationRel(file)
191
+
192
+ if (existsSync(join(root, destination))) {
193
+ const entry = { path: file.rel, destination }
194
+ if (sameBytes(join(root, file.rel), join(root, destination))) {
195
+ duplicates.push(entry)
196
+ } else {
197
+ collisions.push(entry)
198
+ }
199
+ continue
200
+ }
201
+
202
+ moves.push({ from: file.rel, to: destination, status })
203
+ }
204
+
205
+ return { moves, duplicates, collisions, unclaimed }
206
+ }
207
+
208
+ /**
209
+ * The stamp's hashes, re-keyed for every entry this run actually moved and
210
+ * otherwise carried forward only where the file they name still exists. A
211
+ * key surviving neither test is dropped, so the stamp does not accumulate
212
+ * entries for rules that no longer exist.
213
+ *
214
+ * A file that moved on the catalog or renumber-table fallback carries no old
215
+ * key to re-key, since `classifyRule` never required a stamp entry to call it
216
+ * `clean`, so it lands under `canon/` still absent from the map. Hashing it
217
+ * here to fill the gap would record an assumption the stamp never verified as
218
+ * though a sync had confirmed it. That is no regression: the same file was
219
+ * outside the walked root and invisible to `canon gov sync` before this move,
220
+ * so it goes from unattributed to unattributed rather than from attributed to
221
+ * not.
222
+ */
223
+ function nextHashes(
224
+ root: string,
225
+ hashes: DomainHashes,
226
+ moved: readonly MoveEntry[],
227
+ ): DomainHashes {
228
+ const renamed = new Map(
229
+ moved.map((entry) => [toStampKey(entry.from), toStampKey(entry.to)]),
230
+ )
231
+ const next: Record<string, string> = {}
232
+
233
+ for (const [key, hash] of Object.entries(hashes)) {
234
+ const movedTo = renamed.get(key)
235
+ if (movedTo !== undefined) {
236
+ next[movedTo] = hash
237
+ continue
238
+ }
239
+
240
+ if (existsSync(join(root, key))) next[key] = hash
241
+ }
242
+
243
+ return next
244
+ }
245
+
246
+ export interface RuleLayoutResult {
247
+ readonly moved: number
248
+ readonly deleted: number
249
+ readonly failed: readonly string[]
250
+ }
251
+
252
+ /**
253
+ * Writes the plan: every move, then every byte-identical duplicate deleted,
254
+ * then the stamp rewritten from whichever moves actually landed.
255
+ *
256
+ * The stamp keeps the hash the file was recorded with rather than re-hashing
257
+ * current content, which is what keeps an `edited` file reporting `edited` on
258
+ * the next `canon gov sync` instead of silently reading `stale` and losing
259
+ * its own signal that it carries a local change.
260
+ */
261
+ export async function applyRuleLayout(
262
+ root: string,
263
+ plan: RuleLayoutPlan,
264
+ toolkitRoot: string,
265
+ ): Promise<RuleLayoutResult> {
266
+ let moved = 0
267
+ let deleted = 0
268
+ const failed: string[] = []
269
+ const succeeded: MoveEntry[] = []
270
+
271
+ for (const entry of plan.moves) {
272
+ await mkdir(dirname(join(root, entry.to)), { recursive: true })
273
+ const done = await rename(join(root, entry.from), join(root, entry.to))
274
+ .then(() => true)
275
+ .catch(() => false)
276
+
277
+ if (done) {
278
+ moved += 1
279
+ succeeded.push(entry)
280
+ } else {
281
+ failed.push(entry.from)
282
+ }
283
+ }
284
+
285
+ for (const duplicate of plan.duplicates) {
286
+ const done = await unlink(join(root, duplicate.path))
287
+ .then(() => true)
288
+ .catch(() => false)
289
+
290
+ if (done) deleted += 1
291
+ else failed.push(duplicate.path)
292
+ }
293
+
294
+ if (succeeded.length > 0 || deleted > 0) {
295
+ const hashes = stampedHashes(readStamp(root), 'governance')
296
+ await writeStamp(
297
+ root,
298
+ { domain: 'governance', toolkitRoot },
299
+ nextHashes(root, hashes, succeeded),
300
+ new Date(),
301
+ )
302
+ }
303
+
304
+ return { moved, deleted, failed }
305
+ }
@@ -8,12 +8,15 @@ const CLAUDE_DIR = '.claude'
8
8
 
9
9
  /**
10
10
  * Domains an older toolkit installed at the project root, each with the source
11
- * folder naming what it owns. Governance is absent because its rules have always
12
- * landed under `.claude/rules/`, so there is no earlier location to be stranded
13
- * at. Standards and snippets are absent because no copy installs into a target
14
- * at all now, so a root `standards/` or `snippets/` folder there is the
15
- * project's own authoring surface and reporting it as unmigrated would propose
16
- * moving files nothing installed.
11
+ * folder naming what it owns. Governance is absent because its move stayed
12
+ * inside `.claude/rules/`, from a flat `<subdir>/` layout to a `canon/`-wrapped
13
+ * one, and this shape represents a domain stranded at the project root rather
14
+ * than one stranded a folder short of where it belongs. `canon migrate
15
+ * rule-layout` is the separate command that move needed instead. Standards and
16
+ * snippets are absent because no copy installs into a target at all now, so a
17
+ * root `standards/` or `snippets/` folder there is the project's own authoring
18
+ * surface and reporting it as unmigrated would propose moving files nothing
19
+ * installed.
17
20
  *
18
21
  * A tuple array rather than a partial record, so the domain key stays typed
19
22
  * without asserting an `Object.entries` result back into the union. Empty