@erclx/canon 4.10.0 → 4.12.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,343 @@
1
+ /**
2
+ * The old-root citations left inside the record tree itself.
3
+ *
4
+ * `migrate records` sweeps a git listing, so it reaches every tracked file and
5
+ * none of the records, which are gitignored by construction. What that leaves
6
+ * behind is the tree's own citations: a pointer a session still follows, and a
7
+ * far larger body of prose that has to keep saying what it says. This module
8
+ * holds the scope that separates the two.
9
+ *
10
+ * Separate from `records.ts` because that module answers about a repository
11
+ * listing and this one answers about a directory walk. Folding them would give
12
+ * one file two enumeration models, and the predicates each needs are the
13
+ * inverse of the other's.
14
+ */
15
+
16
+ import { readdir, readFile, writeFile } from 'node:fs/promises'
17
+ import { join } from 'node:path'
18
+ import { RECORD_ONLY_ROOTS, rewriteText, scanText } from '@/migrate/records'
19
+
20
+ /**
21
+ * The folders inside the record root a session still follows a path into.
22
+ *
23
+ * Every one of them holds a live pointer: a task naming its plan, a review
24
+ * naming the branch it graded, a memory entry naming the folder that owns it.
25
+ * The corpora left out are closed trails and scratch, which are the same class
26
+ * of document the exclusion set in `records.ts` already refuses to rewrite, so
27
+ * this is that reasoning applied one level down rather than a new rule.
28
+ *
29
+ * `proposals` is listed though the tree does not carry it here. The scope is a
30
+ * statement about which folders are live rather than about what one machine
31
+ * holds, and a folder absent from disk costs a `readdir` that finds nothing.
32
+ */
33
+ export const LIVE_FOLDERS: readonly string[] = [
34
+ 'diagrams',
35
+ 'memory',
36
+ 'plans',
37
+ 'proposals',
38
+ 'review',
39
+ 'tasks',
40
+ 'teach',
41
+ ]
42
+
43
+ /**
44
+ * The backup history, skipped by name rather than by content.
45
+ *
46
+ * It is a git object store, so a walk that does not know it by name reads
47
+ * binary object files whole and discards them. `runRecords` already names that
48
+ * cost for this tree at 9,744 files and 83M.
49
+ */
50
+ export const OBJECT_STORE = '.records.git'
51
+
52
+ /**
53
+ * Segments that end the walk wherever they appear inside a live folder.
54
+ *
55
+ * `archive` is the substantive one: an archived plan or a retired memory entry
56
+ * describes work that closed, and a path inside that sentence is history rather
57
+ * than a pointer. It is pruned at any depth because the archives do not all sit
58
+ * at the same one, `review/memory/archive/` being two levels down.
59
+ */
60
+ export const PRUNED_SEGMENTS: readonly string[] = [
61
+ 'archive',
62
+ 'node_modules',
63
+ '.git',
64
+ ]
65
+
66
+ /** A corpus the sweep passes over, with what it holds. */
67
+ export interface ExcludedCorpus {
68
+ readonly path: string
69
+ readonly files: number
70
+ }
71
+
72
+ /** What the walk found, before anything is read. */
73
+ export interface RecordTreeWalk {
74
+ readonly files: readonly string[]
75
+ readonly excluded: readonly ExcludedCorpus[]
76
+ readonly skipped: readonly string[]
77
+ }
78
+
79
+ /** One file the walk reached, carried with its text. */
80
+ export interface RecordTreeSource {
81
+ readonly path: string
82
+ readonly text: string
83
+ }
84
+
85
+ /** Where a citation sits, so a reader can judge it before `--write` runs. */
86
+ export interface CitationLine {
87
+ readonly line: number
88
+ readonly text: string
89
+ }
90
+
91
+ /** One record file whose citations move. */
92
+ export interface RecordTreeEntry {
93
+ readonly path: string
94
+ readonly text: string
95
+ readonly rewritten: number
96
+ readonly kept: number
97
+ readonly lines: readonly CitationLine[]
98
+ }
99
+
100
+ export interface RecordTreePlan {
101
+ readonly entries: readonly RecordTreeEntry[]
102
+ readonly excluded: readonly ExcludedCorpus[]
103
+ readonly skipped: readonly string[]
104
+ readonly rewritten: number
105
+ readonly kept: number
106
+ }
107
+
108
+ /**
109
+ * Where a path sits relative to the project root, always forward-slashed.
110
+ *
111
+ * `Bun.Glob` reports a platform separator on Windows and every path here is
112
+ * compared against a literal `/` or printed for a reader, so the separator is
113
+ * spelled rather than joined.
114
+ */
115
+ function under(...segments: readonly string[]): string {
116
+ return segments.join('/')
117
+ }
118
+
119
+ /** How many files sit under a directory, without reading any of them. */
120
+ async function countFiles(directory: string): Promise<number> {
121
+ const glob = new Bun.Glob('**/*')
122
+ let count = 0
123
+
124
+ for await (const _path of glob.scan({
125
+ cwd: directory,
126
+ onlyFiles: true,
127
+ dot: true,
128
+ })) {
129
+ count += 1
130
+ }
131
+
132
+ return count
133
+ }
134
+
135
+ /**
136
+ * The files inside one live folder, with each pruned subtree counted rather
137
+ * than swept.
138
+ *
139
+ * The prune is attributed to the shallowest pruned segment on the path, so an
140
+ * archive reports as one corpus instead of one per folder inside it.
141
+ */
142
+ async function scanLiveFolder(
143
+ root: string,
144
+ relative: string,
145
+ ): Promise<{ files: string[]; excluded: Map<string, number> }> {
146
+ const glob = new Bun.Glob('**/*')
147
+ const files: string[] = []
148
+ const excluded = new Map<string, number>()
149
+
150
+ for await (const path of glob.scan({
151
+ cwd: join(root, relative),
152
+ onlyFiles: true,
153
+ dot: true,
154
+ })) {
155
+ const segments = path.split(/[/\\]/)
156
+ const cut = segments.findIndex((segment) =>
157
+ PRUNED_SEGMENTS.includes(segment),
158
+ )
159
+
160
+ if (cut === -1) {
161
+ files.push(under(relative, ...segments))
162
+ continue
163
+ }
164
+
165
+ const corpus = under(relative, ...segments.slice(0, cut + 1))
166
+ excluded.set(corpus, (excluded.get(corpus) ?? 0) + 1)
167
+ }
168
+
169
+ return { files, excluded }
170
+ }
171
+
172
+ /**
173
+ * Every file in the live record surface, and a count for each corpus left out.
174
+ *
175
+ * The scope is root-directed rather than an inversion of `isRecordArtifact`.
176
+ * That predicate is true for the new root whole and for each record entry under
177
+ * the old one, since the sweep it serves skips both, so inverting it would
178
+ * reach a project the move has not run in, where the old spelling is correct.
179
+ */
180
+ export async function walkRecordTree(root: string): Promise<RecordTreeWalk> {
181
+ const files: string[] = []
182
+ const excluded: ExcludedCorpus[] = []
183
+ const skipped: string[] = []
184
+
185
+ for (const recordRoot of RECORD_ONLY_ROOTS) {
186
+ const entries = await readdir(join(root, recordRoot), {
187
+ withFileTypes: true,
188
+ }).catch(() => undefined)
189
+ if (entries === undefined) continue
190
+
191
+ // Each top-level entry is a separate subtree, so the scans are independent
192
+ // and run together. Nothing downstream depends on the order they settle in,
193
+ // since both lists are sorted once the whole root has been read.
194
+ const scans = entries.map(async (entry) => {
195
+ const relative = under(recordRoot, entry.name)
196
+
197
+ if (entry.name === OBJECT_STORE) {
198
+ skipped.push(relative)
199
+ return
200
+ }
201
+
202
+ if (!entry.isDirectory()) {
203
+ excluded.push({ path: relative, files: 1 })
204
+ return
205
+ }
206
+
207
+ if (!LIVE_FOLDERS.includes(entry.name)) {
208
+ excluded.push({
209
+ path: relative,
210
+ files: await countFiles(join(root, relative)),
211
+ })
212
+ return
213
+ }
214
+
215
+ const folder = await scanLiveFolder(root, relative)
216
+ files.push(...folder.files)
217
+ for (const [path, count] of folder.excluded) {
218
+ excluded.push({ path, files: count })
219
+ }
220
+ })
221
+
222
+ await Promise.all(scans)
223
+ }
224
+
225
+ files.sort()
226
+ excluded.sort((left, right) => left.path.localeCompare(right.path))
227
+ skipped.sort()
228
+
229
+ return { files, excluded, skipped }
230
+ }
231
+
232
+ /**
233
+ * Reads what the walk found.
234
+ *
235
+ * A file whose bytes carry a NUL is skipped outright rather than carried as
236
+ * empty text, which is what `readSources` does for the tracked sweep. There is
237
+ * no path to move here, so a binary record has nothing left to contribute.
238
+ */
239
+ export async function readRecordTree(
240
+ root: string,
241
+ paths: readonly string[],
242
+ ): Promise<RecordTreeSource[]> {
243
+ const sources: RecordTreeSource[] = []
244
+
245
+ for (const path of paths) {
246
+ const bytes = await readFile(join(root, path)).catch(() => undefined)
247
+ if (bytes === undefined || bytes.includes(0)) continue
248
+
249
+ sources.push({ path, text: bytes.toString('utf8') })
250
+ }
251
+
252
+ return sources
253
+ }
254
+
255
+ /**
256
+ * Which lines the rewrite would change, read off the rewrite rather than off a
257
+ * second expression.
258
+ *
259
+ * A marked line is unchanged by `rewriteText`, so it never lands here, which is
260
+ * what keeps the report and the write agreeing about what is in scope. The
261
+ * count of marked citations comes from `scanText` instead, since a protected
262
+ * line is invisible in a diff and the reader needs to know the markers fired.
263
+ */
264
+ function citationLines(before: string, after: string): CitationLine[] {
265
+ const original = before.split('\n')
266
+ const rewritten = after.split('\n')
267
+
268
+ return original.flatMap((line, index) =>
269
+ rewritten[index] === line ? [] : [{ line: index + 1, text: line.trim() }],
270
+ )
271
+ }
272
+
273
+ /**
274
+ * What the sweep would rewrite, without writing it.
275
+ *
276
+ * Pure over the sources it is handed, the way `planRecordsMove` is, so a caller
277
+ * reports and applies from one value. A file whose text does not change is
278
+ * dropped rather than carried as a no-op.
279
+ */
280
+ export function planRecordTree(
281
+ sources: readonly RecordTreeSource[],
282
+ excluded: readonly ExcludedCorpus[],
283
+ skipped: readonly string[],
284
+ ): RecordTreePlan {
285
+ const entries: RecordTreeEntry[] = []
286
+ let kept = 0
287
+
288
+ for (const source of sources) {
289
+ const counts = scanText(source.text)
290
+ kept += counts.kept
291
+ if (counts.rewritten === 0) continue
292
+
293
+ const text = rewriteText(source.text)
294
+ entries.push({
295
+ path: source.path,
296
+ text,
297
+ rewritten: counts.rewritten,
298
+ kept: counts.kept,
299
+ lines: citationLines(source.text, text),
300
+ })
301
+ }
302
+
303
+ return {
304
+ entries,
305
+ excluded,
306
+ skipped,
307
+ rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
308
+ kept,
309
+ }
310
+ }
311
+
312
+ export interface RecordTreeResult {
313
+ readonly written: number
314
+ readonly failed: readonly string[]
315
+ }
316
+
317
+ /**
318
+ * Writes the plan.
319
+ *
320
+ * The write lives here rather than beside the two in `apply.ts` because this
321
+ * verb has no folder half: the citations are the whole of it, and the module
322
+ * that decided which files are in scope is the one that should be trusted to
323
+ * name them again. A rejected write is recorded rather than thrown, since this
324
+ * runs inside a commander action that would unwind past the report.
325
+ */
326
+ export async function applyRecordTree(
327
+ root: string,
328
+ plan: RecordTreePlan,
329
+ ): Promise<RecordTreeResult> {
330
+ let written = 0
331
+ const failed: string[] = []
332
+
333
+ for (const entry of plan.entries) {
334
+ const done = await writeFile(join(root, entry.path), entry.text)
335
+ .then(() => true)
336
+ .catch(() => false)
337
+
338
+ if (done) written += 1
339
+ else failed.push(entry.path)
340
+ }
341
+
342
+ return { written, failed }
343
+ }
@@ -119,8 +119,13 @@ export function isExcludedPath(path: string): boolean {
119
119
  * about. The old root is the one that cannot take a whole-root reading, and it
120
120
  * is derived by exclusion rather than named, so a third root added later reads
121
121
  * as records-only unless someone says otherwise.
122
+ *
123
+ * Exported because `record-tree.ts` sweeps inside these roots and has to name
124
+ * them rather than invert `isRecordArtifact`. That predicate is true for the old
125
+ * root's record entries as well, so an inversion would sweep a project the move
126
+ * has not run in, where the old spelling is the correct one.
122
127
  */
123
- const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
128
+ export const RECORD_ONLY_ROOTS: readonly RecordRoot[] = RECORD_ROOTS.filter(
124
129
  (root) => root !== FROM_ROOT,
125
130
  )
126
131