@erclx/canon 4.86.0 → 4.88.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +6 -6
- package/claude/skills/canon-feedback-file/SKILL.md +2 -2
- package/claude/skills/design-extract/SKILL.md +3 -3
- package/claude/skills/docs-fold/SKILL.md +6 -6
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/draft-diagram/SKILL.md +2 -2
- package/claude/skills/draft-slides/SKILL.md +1 -1
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +8 -8
- package/claude/skills/git-split/SKILL.md +19 -19
- package/claude/skills/memory-capture/SKILL.md +2 -2
- package/claude/skills/memory-review/SKILL.md +2 -2
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/review-address/SKILL.md +11 -11
- package/claude/skills/review-branch/SKILL.md +3 -3
- package/claude/skills/review-pr/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +1 -1
- package/claude/skills/sketch-design/SKILL.md +4 -4
- package/claude/skills/teach-workspace/SKILL.md +2 -2
- package/claude/skills/ui-test/REQUIREMENT.md +1 -1
- package/claude/skills/ui-test/SKILL.md +2 -2
- package/docs/agents/commands.md +99 -96
- package/docs/agents/design-board.md +7 -7
- package/docs/agents/records.md +27 -0
- package/docs/agents/sandbox.md +1 -1
- package/docs/workflow/ai-workflow.md +5 -3
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/core/055-scratch.md +1 -0
- package/package.json +1 -1
- package/scripts/tooling/verify.sh +2 -2
- package/src/claude/skills-headings.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/design.ts +3 -3
- package/src/commands/feedback.ts +12 -12
- package/src/commands/migrate.ts +36 -13
- package/src/commands/records.ts +159 -0
- package/src/commands/slides.ts +2 -2
- package/src/design/board.ts +17 -10
- package/src/migrate/evidence-ordinal.ts +79 -0
- package/src/migrate/record-layout.ts +328 -75
- package/src/migrate/scratch-evidence.ts +60 -35
- package/src/record-root.ts +3 -0
- package/src/records/prune.ts +488 -0
- package/src/records/size.ts +24 -1
- package/standards/publish.md +1 -1
- package/standards/skill.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +2 -2
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +2 -2
- package/tooling/claude/seeds/CLAUDE.md +2 -6
|
@@ -1,62 +1,156 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* itself rather than sitting in three separate record folders.
|
|
2
|
+
* The record folder layout, moved one intake batch at a time.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
* `.canon/
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Batch 2 folded the memory pen's review receipts and retired entries under
|
|
5
|
+
* the pen itself. Batch 3 leaves `.canon/review/` holding only reviews: toolkit
|
|
6
|
+
* feedback moves to `.canon/feedback/`, option captures to `.canon/picks/`,
|
|
7
|
+
* claim-backing folders to a numbered `.canon/evidence/`, renders rebuilt from
|
|
8
|
+
* committed sources to `.canon/tmp/render/`, a branch report flattens to
|
|
9
|
+
* `review/branch-<slug>.md`, and a flat checklist joins the `tmp/handoff/ui-checklist/`
|
|
10
|
+
* handoff folder. `canon records push` carries every top-level `.canon/`
|
|
11
|
+
* entry except `EXCLUDED_ENTRIES`, so the new root folders are backed with no
|
|
12
|
+
* list edit, and `tmp/render/` is deliberately not.
|
|
12
13
|
*
|
|
13
14
|
* The move and the citation repoint follow `scratch-evidence.ts`'s shape: a
|
|
14
15
|
* dry run by default, `--write` to apply, a collision refusal, the
|
|
15
16
|
* `canon-keep-record-root` marker honored, and archives swept on purpose,
|
|
16
17
|
* since an archived receipt still cites the row it retired.
|
|
17
18
|
*
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* `RECORD_LAYOUT_MOVES` is a data table rather than one function per pair, so
|
|
20
|
+
* a later batch appends a row instead of restructuring the module. A row that
|
|
21
|
+
* cannot name its destination ahead of time, being the pick-or-evidence split,
|
|
22
|
+
* names the rule that derives it from what is on disk, never a list of slugs,
|
|
23
|
+
* since every target holds folders of its own.
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { existsSync, readdirSync } from 'node:fs'
|
|
24
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
mkdir,
|
|
29
|
+
readdir,
|
|
30
|
+
readFile,
|
|
31
|
+
rename,
|
|
32
|
+
rmdir,
|
|
33
|
+
writeFile,
|
|
34
|
+
} from 'node:fs/promises'
|
|
25
35
|
import { dirname, join } from 'node:path'
|
|
36
|
+
import {
|
|
37
|
+
byFirstAppearance,
|
|
38
|
+
nextOrdinal,
|
|
39
|
+
numberedName,
|
|
40
|
+
slugOf,
|
|
41
|
+
} from '@/migrate/evidence-ordinal'
|
|
26
42
|
import { presentFolders } from '@/records/backup'
|
|
27
43
|
import { recordDir, SCRATCH, spell, type RecordRoot } from '@/record-root'
|
|
28
44
|
|
|
29
45
|
const CANON: RecordRoot = '.canon'
|
|
30
46
|
const CLAUDE: RecordRoot = '.claude'
|
|
31
47
|
|
|
32
|
-
|
|
48
|
+
/** A whole folder moving to a fixed destination. */
|
|
49
|
+
export interface FolderLayoutMove {
|
|
50
|
+
readonly kind: 'folder'
|
|
33
51
|
readonly from: readonly string[]
|
|
34
52
|
readonly to: readonly string[]
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
/**
|
|
38
|
-
* Every
|
|
56
|
+
* Every file directly inside `from` whose name starts with `prefix`, each
|
|
57
|
+
* landing in `to` with that prefix swapped for `renamed`. `prune` removes
|
|
58
|
+
* `from` once the moves leave it empty.
|
|
59
|
+
*/
|
|
60
|
+
export interface FilesLayoutMove {
|
|
61
|
+
readonly kind: 'files'
|
|
62
|
+
readonly from: readonly string[]
|
|
63
|
+
readonly prefix: string
|
|
64
|
+
readonly to: readonly string[]
|
|
65
|
+
readonly renamed: string
|
|
66
|
+
readonly prune: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every folder directly inside `from`, sent to `picks` when it holds what a
|
|
71
|
+
* pick writes and to `evidence` under the next ordinal otherwise.
|
|
72
|
+
*/
|
|
73
|
+
export interface SplitLayoutMove {
|
|
74
|
+
readonly kind: 'split'
|
|
75
|
+
readonly from: readonly string[]
|
|
76
|
+
readonly picks: readonly string[]
|
|
77
|
+
readonly evidence: readonly string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type RecordLayoutMove =
|
|
81
|
+
| FolderLayoutMove
|
|
82
|
+
| FilesLayoutMove
|
|
83
|
+
| SplitLayoutMove
|
|
84
|
+
|
|
85
|
+
const RENDER = [SCRATCH, 'render'] as const
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every move this migration makes, as record-relative paths.
|
|
39
89
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* rather than adding a second table.
|
|
90
|
+
* The first two rows are batch 2's. The rest are batch 3's, in the order the
|
|
91
|
+
* intake's review-folder pass listed them. A later batch appends here rather
|
|
92
|
+
* than adding a second table.
|
|
44
93
|
*/
|
|
45
94
|
export const RECORD_LAYOUT_MOVES: readonly RecordLayoutMove[] = [
|
|
46
|
-
{ from: ['review', 'memory'], to: ['memory', 'review'] },
|
|
47
|
-
{
|
|
95
|
+
{ kind: 'folder', from: ['review', 'memory'], to: ['memory', 'review'] },
|
|
96
|
+
{
|
|
97
|
+
kind: 'folder',
|
|
98
|
+
from: [SCRATCH, 'memory-archive'],
|
|
99
|
+
to: ['memory', 'archive'],
|
|
100
|
+
},
|
|
101
|
+
{ kind: 'folder', from: ['review', 'feedback'], to: ['feedback'] },
|
|
102
|
+
{ kind: 'folder', from: ['review', 'design'], to: [...RENDER, 'design'] },
|
|
103
|
+
{ kind: 'folder', from: ['review', 'board'], to: [...RENDER, 'board'] },
|
|
104
|
+
{ kind: 'folder', from: ['review', 'slides'], to: [...RENDER, 'slides'] },
|
|
105
|
+
{ kind: 'folder', from: ['review', 'diagrams'], to: [...RENDER, 'diagrams'] },
|
|
106
|
+
{
|
|
107
|
+
kind: 'folder',
|
|
108
|
+
from: ['review', 'references'],
|
|
109
|
+
to: ['picks', 'references'],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
kind: 'files',
|
|
113
|
+
from: ['review', 'branch'],
|
|
114
|
+
prefix: 'review-',
|
|
115
|
+
to: ['review'],
|
|
116
|
+
renamed: 'branch-',
|
|
117
|
+
prune: true,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
kind: 'files',
|
|
121
|
+
from: ['review'],
|
|
122
|
+
prefix: 'ui-checklist-',
|
|
123
|
+
to: [SCRATCH, 'handoff', 'ui-checklist'],
|
|
124
|
+
renamed: '',
|
|
125
|
+
prune: false,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
kind: 'split',
|
|
129
|
+
from: ['review', 'evidence'],
|
|
130
|
+
picks: ['picks'],
|
|
131
|
+
evidence: ['evidence'],
|
|
132
|
+
},
|
|
48
133
|
]
|
|
49
134
|
|
|
135
|
+
function resolveRecord(root: string, segments: readonly string[]): string {
|
|
136
|
+
const [folder, ...rest] = segments
|
|
137
|
+
return recordDir(root, folder, ...rest)
|
|
138
|
+
}
|
|
139
|
+
|
|
50
140
|
/** Where a move's source sits today. */
|
|
51
141
|
export function sourcePath(root: string, move: RecordLayoutMove): string {
|
|
52
|
-
|
|
53
|
-
return recordDir(root, folder, ...rest)
|
|
142
|
+
return resolveRecord(root, move.from)
|
|
54
143
|
}
|
|
55
144
|
|
|
56
|
-
/** Where
|
|
57
|
-
export function destinationPath(root: string, move:
|
|
58
|
-
|
|
59
|
-
|
|
145
|
+
/** Where a folder row lands, under whichever root its head folder resolves at. */
|
|
146
|
+
export function destinationPath(root: string, move: FolderLayoutMove): string {
|
|
147
|
+
return resolveRecord(root, move.to)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A record-relative path as a citation spells it at `.canon/`, no trailing slash. */
|
|
151
|
+
function canonCitation(segments: readonly string[]): string {
|
|
152
|
+
const [head, ...rest] = segments
|
|
153
|
+
return ['.canon', spell(CANON, head), ...rest].join('/')
|
|
60
154
|
}
|
|
61
155
|
|
|
62
156
|
function escape(value: string): string {
|
|
@@ -64,10 +158,11 @@ function escape(value: string): string {
|
|
|
64
158
|
}
|
|
65
159
|
|
|
66
160
|
/**
|
|
67
|
-
* The prefixes a citation of a
|
|
68
|
-
* either record root, spelled the way each root spells the
|
|
69
|
-
* and relative one and two directories up, in both
|
|
70
|
-
* relative citation carries no root of its own to read the
|
|
161
|
+
* The prefixes a citation of a source is spelled with, each ending in a
|
|
162
|
+
* slash: absolute at either record root, spelled the way each root spells the
|
|
163
|
+
* leading segment, and relative one and two directories up, in both
|
|
164
|
+
* spellings, since a relative citation carries no root of its own to read the
|
|
165
|
+
* spelling from.
|
|
71
166
|
*/
|
|
72
167
|
function oldPrefixes(from: readonly string[]): readonly string[] {
|
|
73
168
|
const [head, ...rest] = from
|
|
@@ -86,24 +181,20 @@ function oldPrefixes(from: readonly string[]): readonly string[] {
|
|
|
86
181
|
return [...prefixes]
|
|
87
182
|
}
|
|
88
183
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
184
|
+
/** Matches a citation of `from` followed by `tail`, a pattern source. */
|
|
185
|
+
function citationPattern(from: readonly string[], tail: string): RegExp {
|
|
186
|
+
const alternation = oldPrefixes(from).map(escape).join('|')
|
|
187
|
+
return new RegExp(`(?:${alternation})${tail}`, 'g')
|
|
92
188
|
}
|
|
93
189
|
|
|
190
|
+
/** Rejects a following name character, so one slug never swallows a sibling. */
|
|
191
|
+
const NAME_BOUNDARY = '(?![A-Za-z0-9._-])'
|
|
192
|
+
|
|
94
193
|
interface Rewrite {
|
|
95
194
|
readonly pattern: RegExp
|
|
96
195
|
readonly destination: string
|
|
97
196
|
}
|
|
98
197
|
|
|
99
|
-
/** One rewrite per move, paired with its citation pattern. */
|
|
100
|
-
function buildRewrites(moves: readonly RecordLayoutMove[]): readonly Rewrite[] {
|
|
101
|
-
return moves.map((move) => ({
|
|
102
|
-
pattern: citationPattern(move),
|
|
103
|
-
destination: `.canon/${move.to.join('/')}/`,
|
|
104
|
-
}))
|
|
105
|
-
}
|
|
106
|
-
|
|
107
198
|
/**
|
|
108
199
|
* Marks a line naming a moved path on purpose, the same marker
|
|
109
200
|
* `scratch-evidence.ts` and `records.ts` read: on the line itself or on the
|
|
@@ -200,10 +291,13 @@ export async function readRecordLayoutCorpus(
|
|
|
200
291
|
return sources
|
|
201
292
|
}
|
|
202
293
|
|
|
203
|
-
export
|
|
294
|
+
export type Classification = 'pick' | 'evidence'
|
|
295
|
+
|
|
296
|
+
export interface PathMove {
|
|
204
297
|
readonly move: RecordLayoutMove
|
|
205
298
|
readonly from: string
|
|
206
299
|
readonly to: string
|
|
300
|
+
readonly classified?: Classification
|
|
207
301
|
}
|
|
208
302
|
|
|
209
303
|
export interface CitationEntry {
|
|
@@ -213,11 +307,12 @@ export interface CitationEntry {
|
|
|
213
307
|
}
|
|
214
308
|
|
|
215
309
|
export interface RecordLayoutPlan {
|
|
216
|
-
readonly moves: readonly
|
|
310
|
+
readonly moves: readonly PathMove[]
|
|
217
311
|
readonly collisions: readonly string[]
|
|
218
312
|
readonly entries: readonly CitationEntry[]
|
|
219
313
|
readonly rewritten: number
|
|
220
314
|
readonly strays: readonly string[]
|
|
315
|
+
readonly prune: readonly string[]
|
|
221
316
|
}
|
|
222
317
|
|
|
223
318
|
const STRAY_RECEIPT_PATTERN = /^memory-review-.*\.md$/
|
|
@@ -241,48 +336,195 @@ export function strayReceipts(root: string): string[] {
|
|
|
241
336
|
.sort()
|
|
242
337
|
}
|
|
243
338
|
|
|
339
|
+
function entriesOf(dir: string, isDirectory: boolean): string[] {
|
|
340
|
+
if (!existsSync(dir)) return []
|
|
341
|
+
|
|
342
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
343
|
+
.filter((entry) => entry.isDirectory() === isDirectory)
|
|
344
|
+
.map((entry) => entry.name)
|
|
345
|
+
.sort()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* What `draft-and-pick` Step 6 and `sketch-design` write into the folder they
|
|
350
|
+
* archive a choice to: a capture per arm, named `arm-<id>`, and a design
|
|
351
|
+
* handoff. A folder carrying neither is evidence.
|
|
352
|
+
*/
|
|
353
|
+
const PICK_FILE = /^(?:arm-[^.]+\..+|design-handoff\.md)$/
|
|
354
|
+
|
|
355
|
+
function classify(dir: string): Classification {
|
|
356
|
+
return entriesOf(dir, false).some((name) => PICK_FILE.test(name))
|
|
357
|
+
? 'pick'
|
|
358
|
+
: 'evidence'
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
interface RowPlan {
|
|
362
|
+
readonly moves: PathMove[]
|
|
363
|
+
readonly collisions: string[]
|
|
364
|
+
readonly rewrites: Rewrite[]
|
|
365
|
+
readonly prune: string[]
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function planFolderRow(
|
|
369
|
+
root: string,
|
|
370
|
+
move: FolderLayoutMove,
|
|
371
|
+
claimed: Set<string>,
|
|
372
|
+
): RowPlan {
|
|
373
|
+
const from = sourcePath(root, move)
|
|
374
|
+
const to = destinationPath(root, move)
|
|
375
|
+
|
|
376
|
+
if (existsSync(from) && (existsSync(to) || claimed.has(to))) {
|
|
377
|
+
return { moves: [], collisions: [to], rewrites: [], prune: [] }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const rewrites = [
|
|
381
|
+
{
|
|
382
|
+
pattern: citationPattern(move.from, ''),
|
|
383
|
+
destination: `${canonCitation(move.to)}/`,
|
|
384
|
+
},
|
|
385
|
+
]
|
|
386
|
+
if (!existsSync(from))
|
|
387
|
+
return { moves: [], collisions: [], rewrites, prune: [] }
|
|
388
|
+
|
|
389
|
+
claimed.add(to)
|
|
390
|
+
return { moves: [{ move, from, to }], collisions: [], rewrites, prune: [] }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A files row moves whole or not at all: one occupied destination refuses
|
|
395
|
+
* every file in the row, since the citation rewrite matches the shared prefix
|
|
396
|
+
* rather than each name.
|
|
397
|
+
*/
|
|
398
|
+
function planFilesRow(
|
|
399
|
+
root: string,
|
|
400
|
+
move: FilesLayoutMove,
|
|
401
|
+
claimed: Set<string>,
|
|
402
|
+
): RowPlan {
|
|
403
|
+
const from = sourcePath(root, move)
|
|
404
|
+
const toDir = resolveRecord(root, move.to)
|
|
405
|
+
const names = entriesOf(from, false).filter((name) =>
|
|
406
|
+
name.startsWith(move.prefix),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
const moves = names.map((name) => ({
|
|
410
|
+
move,
|
|
411
|
+
from: join(from, name),
|
|
412
|
+
to: join(toDir, `${move.renamed}${name.slice(move.prefix.length)}`),
|
|
413
|
+
}))
|
|
414
|
+
const collisions = moves
|
|
415
|
+
.map((candidate) => candidate.to)
|
|
416
|
+
.filter((to) => existsSync(to) || claimed.has(to))
|
|
417
|
+
|
|
418
|
+
if (collisions.length > 0) {
|
|
419
|
+
return { moves: [], collisions, rewrites: [], prune: [] }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const candidate of moves) claimed.add(candidate.to)
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
moves,
|
|
426
|
+
collisions: [],
|
|
427
|
+
rewrites: [
|
|
428
|
+
{
|
|
429
|
+
pattern: citationPattern(move.from, escape(move.prefix)),
|
|
430
|
+
destination: `${canonCitation(move.to)}/${move.renamed}`,
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
prune: move.prune && moves.length > 0 ? [from] : [],
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
244
437
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
438
|
+
* Sends each folder to its derived destination. Picks keep their slug.
|
|
439
|
+
* Evidence folders take ordinals in order of first appearance, continuing past
|
|
440
|
+
* the highest ordinal `evidence/` already holds, and a slug already numbered
|
|
441
|
+
* there refuses rather than taking a second number.
|
|
247
442
|
*/
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
443
|
+
function planSplitRow(
|
|
444
|
+
root: string,
|
|
445
|
+
move: SplitLayoutMove,
|
|
446
|
+
claimed: Set<string>,
|
|
447
|
+
): RowPlan {
|
|
448
|
+
const from = sourcePath(root, move)
|
|
449
|
+
const evidenceDir = resolveRecord(root, move.evidence)
|
|
450
|
+
const existing = entriesOf(evidenceDir, true)
|
|
451
|
+
let next = nextOrdinal(existing)
|
|
452
|
+
|
|
453
|
+
const folders = entriesOf(from, true).map((name) => ({
|
|
454
|
+
name,
|
|
455
|
+
dir: join(from, name),
|
|
456
|
+
}))
|
|
457
|
+
const picks = folders.filter(({ dir }) => classify(dir) === 'pick')
|
|
458
|
+
const evidence = byFirstAppearance(
|
|
459
|
+
folders.filter(({ dir }) => classify(dir) === 'evidence'),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
const plan: RowPlan = { moves: [], collisions: [], rewrites: [], prune: [] }
|
|
463
|
+
|
|
464
|
+
const accept = (
|
|
465
|
+
name: string,
|
|
466
|
+
dir: string,
|
|
467
|
+
segments: readonly string[],
|
|
468
|
+
classified: Classification,
|
|
469
|
+
): void => {
|
|
470
|
+
const to = resolveRecord(root, segments)
|
|
471
|
+
claimed.add(to)
|
|
472
|
+
plan.moves.push({ move, from: dir, to, classified })
|
|
473
|
+
plan.rewrites.push({
|
|
474
|
+
pattern: citationPattern(move.from, `${escape(name)}${NAME_BOUNDARY}`),
|
|
475
|
+
destination: canonCitation(segments),
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
for (const { name, dir } of picks) {
|
|
480
|
+
const segments = [...move.picks, name]
|
|
481
|
+
const to = resolveRecord(root, segments)
|
|
482
|
+
if (existsSync(to) || claimed.has(to)) plan.collisions.push(to)
|
|
483
|
+
else accept(name, dir, segments, 'pick')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
for (const { name, dir } of evidence) {
|
|
487
|
+
const numbered = existing.find((entry) => slugOf(entry) === name)
|
|
488
|
+
if (numbered !== undefined) {
|
|
489
|
+
plan.collisions.push(join(evidenceDir, numbered))
|
|
262
490
|
continue
|
|
263
491
|
}
|
|
264
492
|
|
|
265
|
-
|
|
493
|
+
accept(name, dir, [...move.evidence, numberedName(next, name)], 'evidence')
|
|
494
|
+
next += 1
|
|
266
495
|
}
|
|
267
496
|
|
|
268
|
-
return
|
|
497
|
+
return plan
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function planRow(
|
|
501
|
+
root: string,
|
|
502
|
+
move: RecordLayoutMove,
|
|
503
|
+
claimed: Set<string>,
|
|
504
|
+
): RowPlan {
|
|
505
|
+
switch (move.kind) {
|
|
506
|
+
case 'folder':
|
|
507
|
+
return planFolderRow(root, move, claimed)
|
|
508
|
+
case 'files':
|
|
509
|
+
return planFilesRow(root, move, claimed)
|
|
510
|
+
case 'split':
|
|
511
|
+
return planSplitRow(root, move, claimed)
|
|
512
|
+
}
|
|
269
513
|
}
|
|
270
514
|
|
|
271
515
|
/**
|
|
272
516
|
* What the migration would do, without doing it. Pure over the sources it is
|
|
273
|
-
* handed
|
|
274
|
-
* text does not change is
|
|
517
|
+
* handed apart from reading the tree it plans against, so a caller reports
|
|
518
|
+
* and applies from the same value. A file whose text does not change is
|
|
519
|
+
* dropped.
|
|
275
520
|
*/
|
|
276
521
|
export function planRecordLayout(
|
|
277
522
|
root: string,
|
|
278
523
|
sources: readonly RecordLayoutSource[],
|
|
279
524
|
): RecordLayoutPlan {
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
const
|
|
283
|
-
(move) => !collidedDestinations.has(destinationPath(root, move)),
|
|
284
|
-
)
|
|
285
|
-
const rewrites = buildRewrites(covered)
|
|
525
|
+
const claimed = new Set<string>()
|
|
526
|
+
const rows = RECORD_LAYOUT_MOVES.map((move) => planRow(root, move, claimed))
|
|
527
|
+
const rewrites = rows.flatMap((row) => row.rewrites)
|
|
286
528
|
const entries: CitationEntry[] = []
|
|
287
529
|
|
|
288
530
|
for (const source of sources) {
|
|
@@ -293,11 +535,12 @@ export function planRecordLayout(
|
|
|
293
535
|
}
|
|
294
536
|
|
|
295
537
|
return {
|
|
296
|
-
moves,
|
|
297
|
-
collisions,
|
|
538
|
+
moves: rows.flatMap((row) => row.moves),
|
|
539
|
+
collisions: rows.flatMap((row) => row.collisions),
|
|
298
540
|
entries,
|
|
299
541
|
rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
|
|
300
542
|
strays: strayReceipts(root),
|
|
543
|
+
prune: rows.flatMap((row) => row.prune),
|
|
301
544
|
}
|
|
302
545
|
}
|
|
303
546
|
|
|
@@ -307,14 +550,22 @@ export interface RecordLayoutResult {
|
|
|
307
550
|
readonly failed: readonly string[]
|
|
308
551
|
}
|
|
309
552
|
|
|
553
|
+
async function removeIfEmpty(dir: string): Promise<void> {
|
|
554
|
+
const left = await readdir(dir).catch(() => undefined)
|
|
555
|
+
if (left === undefined || left.length > 0) return
|
|
556
|
+
|
|
557
|
+
await rmdir(dir).catch(() => undefined)
|
|
558
|
+
}
|
|
559
|
+
|
|
310
560
|
/**
|
|
311
|
-
* Writes the plan: every citation rewrite first, then every
|
|
561
|
+
* Writes the plan: every citation rewrite first, then every move, then the
|
|
562
|
+
* folders a files row emptied.
|
|
312
563
|
*
|
|
313
564
|
* A citation can sit inside a file the plan is about to move, since the walk
|
|
314
565
|
* carries archives on purpose and a receipt can cite the row it retired.
|
|
315
566
|
* Rewriting first is what keeps that write landing on a path that still
|
|
316
|
-
* exists:
|
|
317
|
-
*
|
|
567
|
+
* exists: moving first would send `writeFile` at the pre-move path into a
|
|
568
|
+
* directory `rename` already cleared.
|
|
318
569
|
*/
|
|
319
570
|
export async function applyRecordLayout(
|
|
320
571
|
plan: RecordLayoutPlan,
|
|
@@ -342,5 +593,7 @@ export async function applyRecordLayout(
|
|
|
342
593
|
else failed.push(move.from)
|
|
343
594
|
}
|
|
344
595
|
|
|
596
|
+
for (const dir of plan.prune) await removeIfEmpty(dir)
|
|
597
|
+
|
|
345
598
|
return { moved, written, failed }
|
|
346
599
|
}
|