create-wxl 3.0.0 → 3.1.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,660 @@
1
+ // Step 8 (traceability.md "Step order" row 8, "Pre-checks", "Merge table"): for every path in
2
+ // B ∪ T ∪ tracked, decides what happens to `o` by the pre-checks and the merge table, and (for an
3
+ // M12 path) shells out to the real `git merge-file`. Nothing is written here -- `merge-apply.mjs`
4
+ // executes the plan this module returns. b and t are present only when their exact path is among
5
+ // the names the pristine range lists, so a case-insensitive file system cannot merge two paths (U53).
6
+
7
+ import { join } from 'node:path'
8
+ import { buildGitEnv } from './git-env.mjs'
9
+ import { runGitNamed, isIgnoredPath } from './git-named.mjs'
10
+ import { toSlash } from './text.mjs'
11
+
12
+ /** M12 (traceability.md: "Any other exit, a signal, or output not read in full -> UP-E11"):
13
+ * `git merge-file` ended outside the `0`-`127` range it can legitimately exit with (a clean merge
14
+ * is `0`, a conflict is `1`-`127`; anything else, including `null` for a signal, is a planning
15
+ * failure, not a result to write). `path` is the one `upgrade.mjs` reports. */
16
+ export class MergeFileError extends Error {
17
+ constructor(path) {
18
+ super(`git merge-file exited outside 0-127 at ${path}`)
19
+ this.name = 'MergeFileError'
20
+ this.path = path
21
+ }
22
+ }
23
+
24
+ /** traceability.md "Codes" UP-E11: "a read error, or `git merge-file` ... or was not read in
25
+ * full"): a read error of `o`, `b` or `t` (other than the absent cases
26
+ * `readFileOrNull` already maps to `null`), or a `runGit` rejection for `merge-file`, is a
27
+ * planning failure naming `path` and carrying `detail` (the underlying error's own message) --
28
+ * unlike `MergeFileError`, which never has a `detail` (its own fixed text already says enough). */
29
+ export class PlanningError extends Error {
30
+ constructor(path, detail) {
31
+ super(`step 8 planning failed at ${path}: ${detail}`)
32
+ this.name = 'PlanningError'
33
+ this.path = path
34
+ this.detail = detail
35
+ }
36
+ }
37
+
38
+ const BINARY_SCAN_BYTES = 8000
39
+
40
+ function isBinary(buf) {
41
+ if (buf === null) return false
42
+ return buf.subarray(0, BINARY_SCAN_BYTES).includes(0)
43
+ }
44
+
45
+ /** T-0027 increment 17 (U56): `0x0D 0x0A` -> `0x0A`, scanning raw bytes -- never decode to a
46
+ * string and back, which would corrupt a non-UTF-8 file (D4's own problem) before D4 even lands.
47
+ * Only ever applied to a side already classified as text (binary is decided on RAW bytes first,
48
+ * below, unaffected by this). */
49
+ function normaliseCrlf(buf) {
50
+ const out = Buffer.alloc(buf.length)
51
+ let o = 0
52
+ for (let i = 0; i < buf.length; i++) {
53
+ if (buf[i] === 0x0d && buf[i + 1] === 0x0a) continue
54
+ out[o++] = buf[i]
55
+ }
56
+ return out.subarray(0, o)
57
+ }
58
+
59
+ /** `≡` (traceability.md "Vocabulary"): both absent, or both present and byte-for-byte equal for a
60
+ * binary pair (classified on raw, unnormalised bytes), or equal after CRLF -> LF normalisation for
61
+ * a text pair (T-0027 increment 17, U56: nothing in U17's own 13 branches at increment 1 needed
62
+ * this, so it was added here, the first row whose fixture actually does).
63
+ * `forceBinary`, when given, is the PATH's own binary decision (any of o/b/t present with a NUL),
64
+ * not just this one pair's -- `classify`, below, is the caller that needs this; every other
65
+ * caller (P-2's `eq(b, t)`, the anchor-file check's `eq(anchorB, anchorT)`) has only two sides of
66
+ * its own to look at, so it leaves `forceBinary` `undefined` and keeps the old pair-wise reading. */
67
+ function eq(a, b, forceBinary) {
68
+ if (a === null && b === null) return true
69
+ if (a === null || b === null) return false
70
+ const binary = forceBinary !== undefined ? forceBinary : isBinary(a) || isBinary(b)
71
+ if (binary) return a.equals(b)
72
+ return normaliseCrlf(a).equals(normaliseCrlf(b))
73
+ }
74
+
75
+ /** Manual `/`-joins (not `node:path.join`), matching `git ls-files -z`'s own output and the
76
+ * archive's own `rest` strings -- Windows-neutral by construction. */
77
+ async function listFilesRecursive(fs, dir) {
78
+ const result = []
79
+ async function walk(current, rel) {
80
+ const names = await fs.readdir(current, { withFileTypes: true })
81
+ for (const name of names) {
82
+ const relPath = rel ? `${rel}/${name.name}` : name.name
83
+ const full = join(current, name.name)
84
+ if (name.isDirectory()) await walk(full, relPath)
85
+ else if (name.isFile()) result.push(relPath)
86
+ }
87
+ }
88
+ await walk(dir, '')
89
+ return result
90
+ }
91
+
92
+ /** `b`/`t`/`o`: "p's file in B, T, or absent" (traceability.md "Vocabulary") -- a directory at
93
+ * that path is not a file, so reading it as one (`EISDIR`) is "absent" by the same reading as
94
+ * `ENOENT`, not a bug. T-0027 increment 4: this is what keeps a tracked symbolic-linked
95
+ * directory's own bare path (present as a real directory in B and/or T once any path nested under
96
+ * it differs between them) from crashing here -- P-3, below, is what then leaves a path reached
97
+ * THROUGH that directory untouched. T-0027 increment 15: `ENOTDIR` -- a parent component inside
98
+ * the range, B or T is a FILE, not a directory -- means the same thing for a nested path reached
99
+ * through it: nothing is there, so it reads as absent too, not a bug. */
100
+ async function readFileOrNull(fs, absPath) {
101
+ try {
102
+ return await fs.readFile(absPath)
103
+ } catch (err) {
104
+ if (err?.code === 'ENOENT' || err?.code === 'EISDIR' || err?.code === 'ENOTDIR') return null
105
+ throw err
106
+ }
107
+ }
108
+
109
+ /** P-3 (traceability.md "Pre-checks"): true when `path` itself, or any parent directory of it
110
+ * inside the range, is a symbolic link (by `lstat`, never followed). An absent component (and
111
+ * everything under it) is not a symlink -- not yet classified as a path at all; absence is left
112
+ * to the merge table, as every other pre-check already does. T-0027 increment 15 (deviation,
113
+ * recorded in the ticket): `ENOTDIR` is treated the same as `ENOENT` here too -- a parent
114
+ * component that is itself a FILE (not a directory) means nothing is at `path` either, by the
115
+ * same "ENOTDIR-as-absent" reading `readFileOrNull`, above, already applies; without this, a
116
+ * path reached through a kept file (U54's own file-to-directory swap) would crash here instead of
117
+ * reaching the merge table. */
118
+ async function hasSymlinkInPath(fs, rangeRoot, path) {
119
+ let current = rangeRoot
120
+ for (const part of path.split('/')) {
121
+ current = join(current, part)
122
+ let st
123
+ try {
124
+ st = await fs.lstat(current)
125
+ } catch (err) {
126
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return false
127
+ throw err
128
+ }
129
+ if (st.isSymbolicLink()) return true
130
+ }
131
+ return false
132
+ }
133
+
134
+ /** P-9 (traceability.md "Pre-checks", Vocabulary "name on disk"; D12, T-0062): true when the name
135
+ * the file system stores for q differs from q, where q is `path` when `o` exists by `lstat`, and
136
+ * otherwise `path`'s deepest proper ancestor that exists, found by `lstat` walking from the range
137
+ * root (it may be a regular file). When q is the range root, P-9 does not apply. The comparison
138
+ * takes R, the range root's native realpath as step 3 resolved it, and Q, the native realpath of
139
+ * q, both with `\` turned into `/`; Q must start with R followed by `/` (or R itself when R
140
+ * already ends in `/`, a drive root), and the rest of Q must equal q once both are NFC-normalised.
141
+ * An `lstat` error other than ENOENT/ENOTDIR, or a `realpath` rejection, is a planning failure
142
+ * naming `path` (UP-E11). This catches what P-4 cannot see: a Windows 8.3 short name, HFS+
143
+ * ignorable code points, or letter case in a parent directory that B, T and tracked do not name. */
144
+ async function hasNameAlias(fs, rangeRoot, realRangeRoot, path) {
145
+ const parts = path.split('/')
146
+ const lstatOk = async (rel) => {
147
+ try {
148
+ await fs.lstat(join(rangeRoot, ...rel))
149
+ return true
150
+ } catch (err) {
151
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return false
152
+ throw new PlanningError(path, err?.message ?? String(err))
153
+ }
154
+ }
155
+ let q = null
156
+ if (await lstatOk(parts)) {
157
+ q = parts
158
+ } else {
159
+ for (let i = 1; i < parts.length; i++) {
160
+ if (!(await lstatOk(parts.slice(0, i)))) break
161
+ q = parts.slice(0, i)
162
+ }
163
+ }
164
+ if (q === null) return false // q is the range root: P-9 does not apply
165
+ let realQ
166
+ try {
167
+ realQ = await fs.realpath(join(rangeRoot, ...q))
168
+ } catch (err) {
169
+ throw new PlanningError(path, err?.message ?? String(err))
170
+ }
171
+ const root = toSlash(realRangeRoot)
172
+ const prefix = root.endsWith('/') ? root : `${root}/`
173
+ const resolved = toSlash(realQ)
174
+ if (!resolved.startsWith(prefix)) return true
175
+ return resolved.slice(prefix.length).normalize('NFC') !== q.join('/').normalize('NFC')
176
+ }
177
+
178
+ /** P-6 (traceability.md "Pre-checks"): true when `o` exists, is reached without crossing a
179
+ * non-directory component (`ENOENT`/`ENOTDIR` both mean "nothing non-file is here" -- the same
180
+ * reading as `readFileOrNull`/`hasSymlinkInPath`, above), is not a symbolic link (already ruled
181
+ * out by P-3, which runs earlier in the pre-check order), and is not a regular file either. `t` is
182
+ * always a file or absent in `allPaths` (a bare directory path never enters it on its own), so a
183
+ * non-file `o` can never be `≡` to it -- "o ≢ t" is automatic once this is true, no content read
184
+ * needed. */
185
+ async function isNonFileOther(fs, rangeRoot, path) {
186
+ let st
187
+ try {
188
+ st = await fs.lstat(join(rangeRoot, ...path.split('/')))
189
+ } catch (err) {
190
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return false
191
+ throw err
192
+ }
193
+ return !st.isSymbolicLink() && !st.isFile()
194
+ }
195
+
196
+ /** P-10 (D24, T-0066): true when `path` exists by `lstat`; the caller checks that it is untracked. Step 4 left no untracked file that the
197
+ * range does not ignore, and P-5 already took the ignored ones, so an untracked `path` that exists
198
+ * is reached through another name on disk -- one the native `realpath` did not report (P-9). An
199
+ * `lstat` error other than ENOENT or ENOTDIR fails closed (UP-E11). */
200
+ async function existsOnDisk(fs, rangeRoot, path) {
201
+ try {
202
+ await fs.lstat(join(rangeRoot, ...path.split('/')))
203
+ return true
204
+ } catch (err) {
205
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return false
206
+ throw new PlanningError(path, err?.message ?? String(err))
207
+ }
208
+ }
209
+
210
+ /** D10 (traceability.md "Pre-checks" P-8): parses `git ls-files -s -z`'s own stdout into the set
211
+ * of paths whose INDEX entry is a gitlink (mode `160000`, a submodule) -- each NUL-terminated
212
+ * entry is `<mode> <sha1> <stage>\t<path>`, unquoted (`-z`, the same convention `ls-files -z`'s
213
+ * own tracked-paths call already relies on). */
214
+ function parseSubmodulePaths(stdout) {
215
+ const paths = new Set()
216
+ for (const entry of stdout.split('\0')) {
217
+ if (entry === '') continue
218
+ const tab = entry.indexOf('\t')
219
+ const mode = entry.slice(0, tab).split(' ')[0]
220
+ if (mode === '160000') paths.add(entry.slice(tab + 1))
221
+ }
222
+ return paths
223
+ }
224
+
225
+ /** P-8 (traceability.md "Pre-checks", D10): true when `path` IS a submodule, or lies under one --
226
+ * `submodulePaths` holds exact gitlink paths (`parseSubmodulePaths`, above); a path under one is
227
+ * never itself an index entry (the submodule's own files are not part of the range's index), so
228
+ * this is a prefix check, not a set lookup. */
229
+ function isUnderSubmodule(path, submodulePaths) {
230
+ for (const submodulePath of submodulePaths) {
231
+ if (path === submodulePath || path.startsWith(`${submodulePath}/`)) return true
232
+ }
233
+ return false
234
+ }
235
+
236
+ /** P-4 (traceability.md "Pre-checks", T-0027 increment 13): true when some OTHER path of
237
+ * `allPaths` differs from `path` only by NFC normalisation and ASCII/Unicode case folding --
238
+ * `foldedGroups` maps every path's own folded key to the list of paths sharing it, built once per
239
+ * run (not per path), so this is an O(1) lookup here. */
240
+ function hasCaseCollision(path, foldedGroups) {
241
+ return foldedGroups.get(path.normalize('NFC').toLowerCase()).length > 1
242
+ }
243
+
244
+ /** The merge table (rows checked in order; the first match applies), given `o`/`b`/`t` as
245
+ * `Buffer | null`, and ALREADY past P-2 (`b ≡ t`, checked by the caller before this is ever
246
+ * reached). Every remaining combination is total -- traceability.md's own paragraph just below
247
+ * the table ("with b ≢ t and o a file or absent, exactly one of M5-M12 applies"). */
248
+ function classify(path, o, b, t) {
249
+ // Decided once per path, from all three sides -- not re-derived separately (and possibly
250
+ // differently) for each pair `eq`, below, compares.
251
+ const binary = isBinary(o) || isBinary(b) || isBinary(t)
252
+ if (eq(o, t, binary)) return { row: 'M4' }
253
+ if (eq(o, b, binary)) {
254
+ if (t !== null) return { row: 'M5', existedBefore: o !== null }
255
+ return { row: 'M6' }
256
+ }
257
+ if (o === null) return { row: 'M7' }
258
+ if (t === null) return { row: 'M8' }
259
+ if (b === null) return { row: 'M9' }
260
+ if (path === 'pnpm-lock.yaml') return { row: 'M10' }
261
+ if (binary) return { row: 'M11' }
262
+ return { row: 'M12' }
263
+ }
264
+
265
+ /** M12: shells out to the real `git merge-file`, its three inputs written into the run's own
266
+ * merge-inputs subdirectory, created lazily the first time any path reaches this row
267
+ * (`mergeDirRef.current` is set once and reused, under a fresh file name, for every later M12
268
+ * path in the same run, so a run with no M12 path never creates this directory at all --
269
+ * F14's own `mkdtemp` count stays unaffected). `result.code === 0` is a clean merge
270
+ * (traceability.md "Merge table" M12); `1`-`127` is a conflict, written with its three labels and
271
+ * reported UP-A1; anything else -- including `null` for a process ended by a signal -- is a
272
+ * planning failure (`MergeFileError`, UP-E11; U57, D4). */
273
+ async function runMergeFile({ deps, guard, rangeRoot, baseVersion, targetVersion, mergeDirRef, path, o, b, t }) {
274
+ if (mergeDirRef.current === undefined) {
275
+ mergeDirRef.current = await deps.fs.mkdtemp(join(deps.tmpRoot, 'create-wxl-upgrade-merge-'))
276
+ }
277
+ const index = (mergeDirRef.count = (mergeDirRef.count ?? 0) + 1)
278
+ const oPath = join(mergeDirRef.current, `o-${index}`)
279
+ const bPath = join(mergeDirRef.current, `b-${index}`)
280
+ const tPath = join(mergeDirRef.current, `t-${index}`)
281
+ // T-0027 increment 17 (U56's own approved extra sub-case): M12 is only reached for a text pair
282
+ // (M11 already caught any binary one), so every input is safe to CRLF-normalise the same way
283
+ // the `≡` helper, above, already does -- otherwise a CRLF-only `o` would make every line look
284
+ // different from `b`/`t` to `merge-file` and conflict needlessly.
285
+ await deps.fs.writeFile(oPath, normaliseCrlf(o))
286
+ await deps.fs.writeFile(bPath, normaliseCrlf(b))
287
+ await deps.fs.writeFile(tPath, normaliseCrlf(t))
288
+
289
+ // T-0027 increment 25 (D4): `binary: true` keeps `result.stdout` as a `Buffer` -- the ordinary
290
+ // string-decoding `runGit` would already have replaced every invalid UTF-8 byte sequence (e.g.
291
+ // a non-UTF-8 text file such as Big5) with U+FFFD by the time this function ever saw it.
292
+ // A `runGit` rejection (not a resolved result with an unusable `code`) is also a planning
293
+ // failure, UP-E11 naming `path` with the rejection's own text -- not a bug.
294
+ let result
295
+ try {
296
+ result = await deps.runGit(
297
+ ['-c', 'core.autocrlf=false', 'merge-file', '-p', '--diff3', '-L', 'range', '-L', baseVersion, '-L', targetVersion, oPath, bPath, tPath],
298
+ { cwd: rangeRoot, env: buildGitEnv(deps.env), binary: true },
299
+ )
300
+ } catch (err) {
301
+ throw new PlanningError(path, err.message)
302
+ }
303
+ // A signal this process already received while that `runGit` call was in flight wins over
304
+ // interpreting its result -- checked the moment it resolves, BEFORE `result.code` is read, so
305
+ // a `merge-file` that ended with `code: null` (or any other value) because of that same signal
306
+ // is never reported as `UP-E11`.
307
+ guard.throwIfRaised()
308
+
309
+ if (result.code === 0) {
310
+ // T-0027 increment 19 (U58): a clean merge of a `.json` file whose merged output does not
311
+ // parse keeps the range's own version and reports UP-A11, instead of writing output nothing
312
+ // downstream can read. `JSON.parse` coerces `result.stdout` (now a `Buffer`) through its own
313
+ // `toString()` (UTF-8), harmless here since valid JSON is ASCII/UTF-8-safe by construction.
314
+ if (path.endsWith('.json')) {
315
+ try {
316
+ JSON.parse(result.stdout)
317
+ } catch {
318
+ return { action: 'none', report: 'UP-A11' }
319
+ }
320
+ }
321
+ return { action: 'write', content: result.stdout, report: undefined }
322
+ }
323
+ // T-0027 increment 18 (U57): `0`-`127` is the range a real `git merge-file` process can
324
+ // legitimately exit with (0 clean, 1-127 conflicting); anything else -- including `null` for a
325
+ // process ended by a signal -- is a planning failure, not a result to write.
326
+ if (Number.isInteger(result.code) && result.code >= 1 && result.code <= 127) {
327
+ return { action: 'write', content: result.stdout, report: 'UP-A1' }
328
+ }
329
+ throw new MergeFileError(path)
330
+ }
331
+
332
+ /** Like `Promise.all`, but waits for every operation to settle before rejecting with the first
333
+ * rejection in array order (PR #72's windows-latest run): a rejection must not let the run report
334
+ * and clean up while another `git` child still runs in the range, or a read still holds a file in a
335
+ * pristine directory (EBUSY removing it on Windows). */
336
+ async function settleAll(promises) {
337
+ const settled = await Promise.allSettled(promises)
338
+ const rejected = settled.find((result) => result.status === 'rejected')
339
+ if (rejected !== undefined) throw rejected.reason
340
+ return settled.map((result) => result.value)
341
+ }
342
+
343
+ /**
344
+ * `planMerge({ deps, guard, rangeRoot, realRangeRoot, baseDir, targetDir, baseVersion, targetVersion })` ->
345
+ * (`realRangeRoot`: the range root's native realpath as step 3 resolved it, for P-9) ->
346
+ * `{ ops, attention, mergeDir }`. `ops` is sorted by path, each `{ path, action: 'delete' |
347
+ * 'write', content?, existedBefore?, report? }` (`report`, when present, is the UP-A code this op
348
+ * also carries in `attention` -- used by the report stage to exclude an M10/M12-conflict write
349
+ * from the "updated"/"added" counts, traceability.md "Report" R2's own "M10 is not counted as
350
+ * updated"). `attention` is sorted by path, each `{ path, code }`. `mergeDir` is the lazily
351
+ * created merge-inputs directory's path, or `undefined` when no path reached M12.
352
+ */
353
+ export async function planMerge({ deps, guard, rangeRoot, realRangeRoot, baseDir, targetDir, baseVersion, targetVersion }) {
354
+ // D10 (traceability.md "Pre-checks" P-8): a SEPARATE `git ls-files -s -z` call, kept apart from
355
+ // the plain `ls-files -z` call below (which lists only the TRACKED PATHS, not their index mode)
356
+ // -- a gitlink is also a tracked path, so it is in BOTH results; only this one can tell it apart
357
+ // from an ordinary file.
358
+ const [baseFiles, targetFiles, trackedResult, submoduleResult] = await settleAll([
359
+ listFilesRecursive(deps.fs, baseDir),
360
+ listFilesRecursive(deps.fs, targetDir),
361
+ // D19 (T-0062): a `runGit` rejection of either names the command in CW-E14.
362
+ runGitNamed(deps, guard, ['ls-files', '-z'], { cwd: rangeRoot, env: buildGitEnv(deps.env) }, 'ls-files -z'),
363
+ runGitNamed(deps, guard, ['ls-files', '-s', '-z'], { cwd: rangeRoot, env: buildGitEnv(deps.env) }, 'ls-files -s -z'),
364
+ ])
365
+ guard.throwIfRaised()
366
+ if (trackedResult.code !== 0) {
367
+ throw new Error(`git ls-files -z failed (exit code ${trackedResult.code}): ${trackedResult.stderr}`)
368
+ }
369
+ if (submoduleResult.code !== 0) {
370
+ throw new Error(`git ls-files -s -z failed (exit code ${submoduleResult.code}): ${submoduleResult.stderr}`)
371
+ }
372
+ const tracked = trackedResult.stdout.split('\0').filter((entry) => entry !== '')
373
+ const trackedSet = new Set(tracked)
374
+ const submodulePaths = parseSubmodulePaths(submoduleResult.stdout)
375
+ // T-0027 increment 13 fix (U53): B and T are the pristine ranges listFilesRecursive already
376
+ // listed, above -- `b`/`t` exist at `path` only when that EXACT (case-sensitive) string is one
377
+ // of those listed names. Reading by joining `path` onto `baseDir`/`targetDir` and letting the
378
+ // file system resolve it would, on a case-insensitive file system, silently read the OTHER
379
+ // path's bytes for a case-only rename (`guide.md` resolving to `Guide.md`'s entry), making
380
+ // `b ≡ t` hold and P-2 swallow the path before P-4 ever runs.
381
+ const baseFileSet = new Set(baseFiles)
382
+ const targetFileSet = new Set(targetFiles)
383
+
384
+ const allPaths = new Set([...baseFiles, ...targetFiles, ...tracked])
385
+ allPaths.delete('range.json') // P-1: step 9 writes it
386
+
387
+ // P-4: grouped once per run, by every path's own NFC-normalised, case-folded key.
388
+ const foldedGroups = new Map()
389
+ for (const path of allPaths) {
390
+ const folded = path.normalize('NFC').toLowerCase()
391
+ if (!foldedGroups.has(folded)) foldedGroups.set(folded, [])
392
+ foldedGroups.get(folded).push(path)
393
+ }
394
+
395
+ const ops = []
396
+ const attention = []
397
+ const mergeDirRef = {}
398
+
399
+ // The merge-inputs directory `runMergeFile` creates lazily, above, must be removed however
400
+ // this loop ends -- a read error, a `runGit` rejection, or a signal noticed by any of this
401
+ // loop's own `guard.throwIfRaised()` calls, not only `MergeFileError`. Wrapping the whole loop
402
+ // lets every one of those attach `mergeDirRef.current` (if a path has reached M12 by then) to
403
+ // the error it throws, for the caller (`upgrade.mjs`) to clean up -- `planMerge` itself never
404
+ // sees that caller's own `tmpDirs` list.
405
+ try {
406
+ for (const path of [...allPaths].sort()) {
407
+ // A read error of b or t other than the absent cases `readFileOrNull` already maps to
408
+ // `null` (e.g. `EACCES`) is a planning failure, UP-E11 naming this path -- not a bug
409
+ // (`CW-E14`), since it is the range's own file system that refused the read, not a
410
+ // programming error.
411
+ let b, t
412
+ try {
413
+ ;[b, t] = await settleAll([
414
+ baseFileSet.has(path) ? readFileOrNull(deps.fs, join(baseDir, ...path.split('/'))) : null,
415
+ targetFileSet.has(path) ? readFileOrNull(deps.fs, join(targetDir, ...path.split('/'))) : null,
416
+ ])
417
+ } catch (err) {
418
+ throw new PlanningError(path, err.message)
419
+ }
420
+ guard.throwIfRaised()
421
+
422
+ if (eq(b, t)) continue // P-2: leave o as it is, unreported
423
+
424
+ if (await hasSymlinkInPath(deps.fs, rangeRoot, path)) {
425
+ // P-3: untouched, UP-A7.
426
+ attention.push({ path, code: 'UP-A7' })
427
+ continue
428
+ }
429
+
430
+ if (hasCaseCollision(path, foldedGroups)) {
431
+ // P-4: untouched, UP-A8.
432
+ attention.push({ path, code: 'UP-A8' })
433
+ continue
434
+ }
435
+
436
+ if (!trackedSet.has(path) && (await isIgnoredPath(deps, guard, rangeRoot, path))) {
437
+ // P-5: untouched, UP-A9.
438
+ attention.push({ path, code: 'UP-A9' })
439
+ continue
440
+ }
441
+
442
+ if (await isNonFileOther(deps.fs, rangeRoot, path)) {
443
+ // P-6 (T-0027 increment 15): untouched, UP-A10.
444
+ attention.push({ path, code: 'UP-A10' })
445
+ continue
446
+ }
447
+
448
+ // P-7 (D9): an ignore file (at any level -- the LAST path component, not only the
449
+ // range's root one) that upstream changed is never written, so the upgrade never leaves a
450
+ // missing or changed ignore file for the restore commands' `git clean -fd` to act on (D14
451
+ // also runs `git checkout -- .` first, T-0063). Checked in traceability.md's "Pre-checks"
452
+ // table order, after P-6 and before P-8. The last component is folded the way P-4 folds a whole path, so an upstream
453
+ // `.GITIGNORE` (or any other-cased or normalised form) is still an ignore file.
454
+ if (path.split('/').pop().normalize('NFC').toLowerCase() === '.gitignore') {
455
+ attention.push({ path, code: 'UP-A13' })
456
+ continue
457
+ }
458
+
459
+ // P-8 (D10): `path` is a submodule, or lies under one -- its own working tree is not part
460
+ // of the range's index, so the restore commands cannot undo a write or delete there.
461
+ if (isUnderSubmodule(path, submodulePaths)) {
462
+ attention.push({ path, code: 'UP-A14' })
463
+ continue
464
+ }
465
+
466
+ // P-9 (D12, T-0062): a path whose name on disk differs from `path` -- in itself, or in the
467
+ // deepest existing ancestor when `o` is absent -- is never read, written or deleted here.
468
+ // Checked after P-1 to P-8, so a symbolic link stays UP-A7 (P-3) and a collision within
469
+ // B ∪ T ∪ tracked stays UP-A8 (P-4); only P-10 comes after it.
470
+ if (await hasNameAlias(deps.fs, rangeRoot, realRangeRoot, path)) {
471
+ attention.push({ path, code: 'UP-A15' })
472
+ continue
473
+ }
474
+ guard.throwIfRaised()
475
+
476
+ // P-10 (D24, T-0066): an untracked path that exists is an alias P-9 could not see (a file
477
+ // system whose `realpath` does not report the stored name, such as WSL's `/mnt/c`).
478
+ if (!trackedSet.has(path) && (await existsOnDisk(deps.fs, rangeRoot, path))) {
479
+ attention.push({ path, code: 'UP-A15' })
480
+ continue
481
+ }
482
+
483
+ // `o` is classified by `lstat` BEFORE any read -- P-3 (a symbolic link anywhere in `path`)
484
+ // and P-6 (a present, non-symbolic-link, non-regular-file `o`), both just above, already
485
+ // ran and left without ever reading `o`'s own content; only once
486
+ // neither applies is `o` known to be a regular file or absent, safe for an ordinary
487
+ // `readFile` -- otherwise a symlink to something whose read hangs or fails (a FIFO, a
488
+ // socket, a device) would crash or stall the whole run instead of being left untouched.
489
+ let o
490
+ try {
491
+ o = await readFileOrNull(deps.fs, join(rangeRoot, ...path.split('/')))
492
+ } catch (err) {
493
+ throw new PlanningError(path, err.message)
494
+ }
495
+ guard.throwIfRaised()
496
+
497
+ const { row, existedBefore } = classify(path, o, b, t)
498
+
499
+ if (row === 'M4') continue
500
+ if (row === 'M5') {
501
+ const op = { path, action: 'write', content: t, existedBefore }
502
+ // M5 (traceability.md "Merge table"): "new files take t's executable bit; existing ones
503
+ // keep their mode" -- only a brand-new path (M5-added) ever needs t's own mode here; an
504
+ // existing one's mode is preserved by `merge-apply.mjs` regardless of which row wrote it.
505
+ if (!existedBefore) {
506
+ const targetStat = await deps.fs.stat(join(targetDir, ...path.split('/')))
507
+ op.newFileExecutable = (targetStat.mode & 0o111) !== 0
508
+ }
509
+ ops.push(op)
510
+ continue
511
+ }
512
+ if (row === 'M6') {
513
+ ops.push({ path, action: 'delete' })
514
+ continue
515
+ }
516
+ if (row === 'M7') {
517
+ attention.push({ path, code: 'UP-A5' })
518
+ continue
519
+ }
520
+ if (row === 'M8') {
521
+ attention.push({ path, code: 'UP-A3' })
522
+ continue
523
+ }
524
+ if (row === 'M9') {
525
+ attention.push({ path, code: 'UP-A4' })
526
+ continue
527
+ }
528
+ if (row === 'M10') {
529
+ // `pnpm-lock.yaml` always already exists once a path reaches M10
530
+ // (its base and the teacher's own copy both had it) -- `existedBefore: true` is what
531
+ // tells `merge-apply.mjs`'s own write loop to preserve its CURRENT mode, the same "M5:
532
+ // existing ones keep their mode" guarantee every other existing-file write already gets.
533
+ ops.push({ path, action: 'write', content: t, existedBefore: true, report: 'UP-A6' })
534
+ attention.push({ path, code: 'UP-A6' })
535
+ continue
536
+ }
537
+ if (row === 'M11') {
538
+ attention.push({ path, code: 'UP-A2' })
539
+ continue
540
+ }
541
+ // M12
542
+ const outcome = await runMergeFile({ deps, guard, rangeRoot, baseVersion, targetVersion, mergeDirRef, path, o, b, t })
543
+ guard.throwIfRaised()
544
+ if (outcome.action === 'write') {
545
+ ops.push({ path, action: 'write', content: outcome.content, existedBefore: o !== null, report: outcome.report })
546
+ }
547
+ if (outcome.report !== undefined) attention.push({ path, code: outcome.report })
548
+ }
549
+ } catch (err) {
550
+ // Whatever throws here -- `MergeFileError`, a `SignalAbort` from
551
+ // `guard.throwIfRaised()`, a read error, or a `runGit` rejection -- the merge-inputs
552
+ // directory (if a path already reached M12 by then) is attached to it for the caller
553
+ // (`upgrade.mjs`) to remove; `planMerge` itself never sees that caller's own `tmpDirs` list.
554
+ if (mergeDirRef.current !== undefined && err.mergeDir === undefined) err.mergeDir = mergeDirRef.current
555
+ throw err
556
+ }
557
+
558
+ // Parent check (traceability.md:153, after the merge table; T-0027 increment 16, tightened by
559
+ // the verification follow-up's item 3): a planned write whose path has A PARENT INSIDE THE
560
+ // RANGE -- any ancestor directory level, not only the immediate one -- that exists, is not a
561
+ // directory, and is not itself being deleted by this same plan, is dropped -- UP-A10 for the
562
+ // write's own path, not counted as added/updated. Deletions run before writes (step 9), so a
563
+ // file replaced by a directory of the same name (U54) is unaffected: its own parent IS one of
564
+ // this plan's own deletes. `lstat`-ing the immediate parent alone is not enough: when a
565
+ // SHALLOWER ancestor (e.g. `a` for a write to `a/b/x`) is the non-directory the plan keeps,
566
+ // `fs.lstat(rangeRoot, 'a', 'b')` fails with ENOTDIR (a component of the path prefix is not a
567
+ // directory) rather than naming `a` itself -- walking every ancestor from the top catches this.
568
+ const deletingPaths = new Set(ops.filter((op) => op.action === 'delete').map((op) => op.path))
569
+ const finalOps = []
570
+ for (const op of ops) {
571
+ if (op.action === 'write') {
572
+ const parts = op.path.split('/')
573
+ let blocked = false
574
+ for (let depth = 1; depth < parts.length && !blocked; depth++) {
575
+ const ancestorParts = parts.slice(0, depth)
576
+ const ancestorPath = ancestorParts.join('/')
577
+ if (deletingPaths.has(ancestorPath)) continue
578
+ let ancestorStat = null
579
+ try {
580
+ ancestorStat = await deps.fs.lstat(join(rangeRoot, ...ancestorParts))
581
+ } catch (err) {
582
+ // Only ENOENT (absent) and ENOTDIR (a SHALLOWER ancestor already a
583
+ // file -- in practice never reached here, since this loop itself would already have set
584
+ // `blocked` at that shallower depth first) mean "nothing to block this write at this
585
+ // ancestor level"; any other error (e.g. EACCES) is a planning failure, not evidence of
586
+ // absence -- fail closed, like every other pre-check's own lstat.
587
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
588
+ ancestorStat = null
589
+ } else {
590
+ const planningErr = new PlanningError(op.path, err.message)
591
+ if (mergeDirRef.current !== undefined) planningErr.mergeDir = mergeDirRef.current
592
+ throw planningErr
593
+ }
594
+ }
595
+ if (ancestorStat !== null && !ancestorStat.isDirectory()) blocked = true
596
+ }
597
+ if (blocked) {
598
+ attention.push({ path: op.path, code: 'UP-A10' })
599
+ continue
600
+ }
601
+ }
602
+ finalOps.push(op)
603
+ }
604
+
605
+ finalOps.sort((a, b2) => (a.path < b2.path ? -1 : a.path > b2.path ? 1 : 0))
606
+ attention.sort((a, b2) => (a.path < b2.path ? -1 : a.path > b2.path ? 1 : 0))
607
+
608
+ // Fork:init-derived content (traceability.md "Fork:init-derived content", T-0027 increment 20,
609
+ // U59): when ANY of the nine anchor files (0004's A1-A9 plus the deploy template) differs
610
+ // between base and target, the report adds ONE UP-A12 line -- not one per anchor, and not tied
611
+ // to any single path, so it is appended after the path-sort above, with no `path` field of its
612
+ // own (`upgrade-report.mjs`'s own sort already places a `UP-A12` item last regardless of this
613
+ // append order; this is also why `attention.sort`, above, never sees it). The nine paths are
614
+ // written as literals here, at this one call site -- there is no shared module exporting them.
615
+ const ANCHOR_PATHS = [
616
+ 'package.json',
617
+ 'README.md',
618
+ 'README.en.md',
619
+ 'CONTRIBUTE.md',
620
+ '.vitepress/config.mts',
621
+ '.vitepress/challenge/shortLink.ts',
622
+ 'platform/index.md',
623
+ 'platform/zh-TW/index.md',
624
+ 'scripts/templates/deploy.yml.template',
625
+ ]
626
+ // T-0027 verification follow-up (item 4, approved sub-case, 2026-11-09): the same exact-name
627
+ // (case-sensitive) presence rule as the U53 fix, above (`baseFileSet`/`targetFileSet`), not a
628
+ // path read -- on a case-insensitive filesystem, joining `anchorPath` onto `targetDir` and
629
+ // reading it would resolve to a DIFFERENTLY-cased sibling the target pristine range actually
630
+ // lists (e.g. `readme.md` for the literal anchor `README.md`), wrongly reading it as present.
631
+ // This check's own reads are wrapped the same way the main loop's own reads, above, already
632
+ // are -- a read error here (other than the absent cases `readFileOrNull` already maps to
633
+ // `null`) is a planning failure too (traceability.md "Codes" UP-E11), naming the anchor path,
634
+ // with the merge-inputs directory (if any path already reached M12) attached for the caller to
635
+ // remove -- not a bug reaching step 9 with that directory left behind under `tmpRoot`.
636
+ let anchorChanged = false
637
+ try {
638
+ for (const anchorPath of ANCHOR_PATHS) {
639
+ let anchorB, anchorT
640
+ try {
641
+ ;[anchorB, anchorT] = await settleAll([
642
+ baseFileSet.has(anchorPath) ? readFileOrNull(deps.fs, join(baseDir, ...anchorPath.split('/'))) : null,
643
+ targetFileSet.has(anchorPath) ? readFileOrNull(deps.fs, join(targetDir, ...anchorPath.split('/'))) : null,
644
+ ])
645
+ } catch (err) {
646
+ throw new PlanningError(anchorPath, err.message)
647
+ }
648
+ if (!eq(anchorB, anchorT)) {
649
+ anchorChanged = true
650
+ break
651
+ }
652
+ }
653
+ } catch (err) {
654
+ if (mergeDirRef.current !== undefined && err.mergeDir === undefined) err.mergeDir = mergeDirRef.current
655
+ throw err
656
+ }
657
+ if (anchorChanged) attention.push({ code: 'UP-A12' })
658
+
659
+ return { ops: finalOps, attention, mergeDir: mergeDirRef.current }
660
+ }