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,582 @@
1
+ // The upgrade path (0006-S1, traceability.md "Step order"): `run.mjs` dispatches here once
2
+ // `parseArgvStrict` reports `mode === 'upgrade'` (Command grammar). Step 7 ends by building both
3
+ // pristine ranges (base, then target); step 8 plans the merge (merge-plan.mjs), step 9 applies
4
+ // it (merge-apply.mjs), and step 10 removes the run's temporary directories and prints the
5
+ // report (upgrade-report.mjs).
6
+
7
+ import { join, resolve as resolvePath } from 'node:path'
8
+ import { buildGitEnv } from './git-env.mjs'
9
+ import { SignalAbort } from './signals.mjs'
10
+ import { runGitNamed, isIgnoredPath } from './git-named.mjs'
11
+ import { planMerge, MergeFileError, PlanningError } from './merge-plan.mjs'
12
+ import { toSlash, foldAsciiCase } from './text.mjs'
13
+ import { applyMerge } from './merge-apply.mjs'
14
+ import { buildReport } from './upgrade-report.mjs'
15
+ import { COMMIT_RE, VERSION_RE } from './patterns.mjs'
16
+ import { parseGitVersion, isGitVersionOk, MINIMUM_GIT_VERSION } from './git-version.mjs'
17
+ import { downloadArchive, readArchiveEntries, makeTmpDir, extractInto, applyPristine, removeTmpDir } from './run-shared.mjs'
18
+ import { isAtLeast } from './numeric-version.mjs'
19
+ import { MINIMUM_BASE_VERSION } from './base-version.mjs'
20
+
21
+ /** `version`'s numeric parts (always `X.Y.Z` here: either `VERSION_RE`-checked already, or a
22
+ * literal like `deps.source.version`), for `isAtLeast`'s own comparison (S-11). */
23
+ function versionParts(version) {
24
+ return version.split('.').map(Number)
25
+ }
26
+
27
+ /** Strictly greater, not `isAtLeast`'s own ">=": `a` is at least `b` AND `b` is not at least `a`. */
28
+ function isHigherVersion(a, b) {
29
+ const partsA = versionParts(a)
30
+ const partsB = versionParts(b)
31
+ return isAtLeast(partsA, partsB) && !isAtLeast(partsB, partsA)
32
+ }
33
+
34
+ /** `git rev-parse`'s own stdout ends with exactly one trailing newline (amendment 3, 2026-11-07);
35
+ * trimmed before use, including before `realpath`. */
36
+ function trimTrailingNewline(value) {
37
+ return value.replace(/\r?\n$/, '')
38
+ }
39
+
40
+ /** `git rev-parse --git-dir`'s own stdout is a path, often relative to `deps.cwd` ("the stdout of
41
+ * ... --git-dir ... is resolved against deps.cwd", amendment 3). */
42
+ function parseGitDirStdout(deps, stdout) {
43
+ return resolvePath(deps.cwd, trimTrailingNewline(stdout))
44
+ }
45
+
46
+ /** `git ls-files -v`'s own tag letter (traceability.md "Step order" row 4): any lowercase letter
47
+ * means assume-unchanged, `S` means skip-worktree. */
48
+ function lsFilesTag(line) {
49
+ return line[0]
50
+ }
51
+
52
+ // D7 (candidate 15, stage 7): `sequencer/` holds a multi-commit cherry-pick or revert's own
53
+ // to-do list in progress; a mid-sequence commit removes `CHERRY_PICK_HEAD`/`REVERT_HEAD` without
54
+ // finishing the operation, so this marker is needed in addition to those two.
55
+ const IN_PROGRESS_MARKERS = ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'rebase-merge', 'rebase-apply', 'sequencer']
56
+
57
+ /** D6: a non-zero OR `null` exit (killed by a signal) from any of
58
+ * step 4's three git commands is never treated as "no output, therefore clean" -- it stops the
59
+ * run with CW-E14 naming the command and the stderr git reported, through the same bug/signal
60
+ * catch `runUpgrade` already has (fail closed). */
61
+ function requireGitSuccess(command, result) {
62
+ if (result.code !== 0) {
63
+ throw new Error(`git ${command} failed (exit code ${result.code}): ${result.stderr}`)
64
+ }
65
+ }
66
+
67
+ /** J1 (`lstat`: absent, or a regular file) then J3 (parses as JSON,
68
+ * and is an object) for `range.json` at `deps.cwd` -- the shape increment 13's `--from` branch and
69
+ * increment 14's no-`--from` branch both need. Returns `{ absent: true }` when there is no
70
+ * `range.json`, `{ token }` for a J1/J3 failure (`not-a-file`/`not-json`/`not-object`), or
71
+ * `{ value }` with the parsed object otherwise. */
72
+ async function readRangeJson(deps) {
73
+ let rangeJsonStat
74
+ try {
75
+ rangeJsonStat = await deps.fs.lstat(join(deps.cwd, 'range.json'))
76
+ } catch (err) {
77
+ if (err?.code !== 'ENOENT') throw err
78
+ return { absent: true }
79
+ }
80
+ if (!rangeJsonStat.isFile()) return { token: 'not-a-file' }
81
+ const raw = await deps.fs.readFile(join(deps.cwd, 'range.json'), 'utf8')
82
+ let value
83
+ try {
84
+ value = JSON.parse(raw)
85
+ } catch {
86
+ return { token: 'not-json' }
87
+ }
88
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return { token: 'not-object' }
89
+ return { value }
90
+ }
91
+
92
+ /** D3 (inc 19): removes every directory in `tmpDirs` by its own path -- idempotent (`removeTmpDir`
93
+ * uses `force: true`), so calling it again on a directory a shared function's own cleanup already
94
+ * removed is harmless. Used at every early return in step 7 after the first `makeTmpDir`, so an
95
+ * earlier, already-built range is never left behind when a later one fails. */
96
+ async function cleanupTmpDirs(deps, tmpDirs) {
97
+ for (const dir of tmpDirs) {
98
+ try {
99
+ await removeTmpDir(deps, dir)
100
+ } catch {
101
+ // F14 (stage 6 disposition): one directory's own `rm` failure must not stop the rest of
102
+ // this list from being removed, and must never replace the exit code the run was already
103
+ // going to return with a rejection of its own.
104
+ }
105
+ }
106
+ }
107
+
108
+ async function runUpgradeBody(parsed, io, deps, t2, lang2, guard, { hasOverride, overrideRaw }, tmpDirs) {
109
+ const gitEnv = buildGitEnv(deps.env)
110
+
111
+ // F02/A2 (stage 6 disposition): every step 3-7 opens with the same `checkpoint`/`throwIfRaised`
112
+ // pair 0005's own steps use (run.mjs:203,217-218,...), so a signal during any of them is never
113
+ // silently absorbed into an unrelated exit code -- the existing `checkpoint('step-8')` after
114
+ // step 7 (below) is unchanged, marking the start of step 8 (T-0027's).
115
+ deps.checkpoint('step-3')
116
+ guard.throwIfRaised()
117
+
118
+ // Step 3 (traceability.md "Step order" row 3): `git --version` must report 2.26 or later (D21)
119
+ // before anything else -- a rejection (git cannot be spawned, U04(1)) and an unparsable or too
120
+ // old a version (U04(2)) are reported the same way; `undefined` is passed to the message when
121
+ // there is no version string to show.
122
+ let versionStdout
123
+ try {
124
+ ;({ stdout: versionStdout } = await deps.runGit(['--version'], { cwd: deps.cwd, env: gitEnv }))
125
+ } catch {
126
+ versionStdout = undefined
127
+ }
128
+ guard.throwIfRaised() // candidate 13 (stage 7): a signal already received wins over interpreting this result
129
+ const parsedVersion = versionStdout === undefined ? null : parseGitVersion(versionStdout)
130
+ if (!isGitVersionOk(parsedVersion, MINIMUM_GIT_VERSION)) {
131
+ io.stderr.write(t2['UP-E02']({ version: parsedVersion ? parsedVersion.join('.') : undefined }) + '\n')
132
+ return 1
133
+ }
134
+
135
+ // Git tells a work tree from a non-work-tree, then its own (possibly symlink-resolved) top
136
+ // level is compared with `deps.cwd`, both through `realpath` so a range reached through a
137
+ // symbolic link to its root is still accepted (U03).
138
+ const toplevel = await deps.runGit(['rev-parse', '--show-toplevel'], {
139
+ cwd: deps.cwd,
140
+ env: gitEnv,
141
+ })
142
+ guard.throwIfRaised() // candidate 13 (stage 7): a signal already received wins over interpreting this result
143
+ if (toplevel.code !== 0) {
144
+ io.stderr.write(t2['UP-E01']({ path: deps.cwd }) + '\n')
145
+ return 1
146
+ }
147
+ const [realTop, realCwd] = await Promise.all([
148
+ deps.fs.realpath(trimTrailingNewline(toplevel.stdout)),
149
+ deps.fs.realpath(deps.cwd),
150
+ ])
151
+ if (toSlash(realTop) !== toSlash(realCwd)) {
152
+ io.stderr.write(t2['UP-E01']({ path: deps.cwd }) + '\n')
153
+ return 1
154
+ }
155
+
156
+ deps.checkpoint('step-4')
157
+ guard.throwIfRaised()
158
+
159
+ // Step 4 (traceability.md "Step order" row 4): a clean repository, checked in this order so a
160
+ // missing HEAD (no-head) is never misreported through the other checks' own behaviour on a
161
+ // headless repository.
162
+ const headCheck = await runGitNamed(deps, guard, ['rev-parse', '--verify', 'HEAD'], { cwd: deps.cwd, env: gitEnv }, 'rev-parse --verify HEAD')
163
+ guard.throwIfRaised() // candidate 13 (stage 7): a signal already received wins over interpreting this result
164
+ if (headCheck.code !== 0) {
165
+ io.stderr.write(t2['UP-E03']({ reason: 'no-head' }) + '\n')
166
+ return 1
167
+ }
168
+ const statusResult = await runGitNamed(
169
+ deps,
170
+ guard,
171
+ ['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all', '--ignore-submodules=none'],
172
+ { cwd: deps.cwd, env: gitEnv },
173
+ 'status',
174
+ )
175
+ guard.throwIfRaised() // candidate 13 (stage 7): a signal already received wins over interpreting this result
176
+ requireGitSuccess('status', statusResult)
177
+ if (statusResult.stdout !== '') {
178
+ io.stderr.write(t2['UP-E03']({ reason: 'changes' }) + '\n')
179
+ return 1
180
+ }
181
+ const lsFilesResult = await runGitNamed(deps, guard, ['ls-files', '-v'], { cwd: deps.cwd, env: gitEnv }, 'ls-files -v')
182
+ guard.throwIfRaised() // candidate 13 (stage 7): a signal already received wins over interpreting this result
183
+ requireGitSuccess('ls-files -v', lsFilesResult)
184
+ for (const line of lsFilesResult.stdout.split('\n')) {
185
+ if (line === '') continue
186
+ const tag = lsFilesTag(line)
187
+ if (/^[a-z]$/.test(tag)) {
188
+ io.stderr.write(t2['UP-E03']({ reason: 'assume-unchanged' }) + '\n')
189
+ return 1
190
+ }
191
+ if (tag === 'S') {
192
+ io.stderr.write(t2['UP-E03']({ reason: 'skip-worktree' }) + '\n')
193
+ return 1
194
+ }
195
+ }
196
+ const gitDirResult = await runGitNamed(deps, guard, ['rev-parse', '--git-dir'], { cwd: deps.cwd, env: gitEnv }, 'rev-parse --git-dir')
197
+ guard.throwIfRaised() // candidate 13 (stage 7): a signal already received wins over interpreting this result
198
+ requireGitSuccess('rev-parse --git-dir', gitDirResult)
199
+ const gitDir = parseGitDirStdout(deps, gitDirResult.stdout)
200
+ for (const marker of IN_PROGRESS_MARKERS) {
201
+ try {
202
+ await deps.fs.lstat(join(gitDir, marker))
203
+ } catch (err) {
204
+ // Candidate 03/14 (stage 7): only "absent" means not in progress; any other lstat error
205
+ // (e.g. EACCES) is a bug, not evidence of a clean range -- fail closed, like D6.
206
+ if (err?.code === 'ENOENT') continue
207
+ throw err
208
+ }
209
+ io.stderr.write(t2['UP-E03']({ reason: 'in-progress' }) + '\n')
210
+ return 1
211
+ }
212
+
213
+ deps.checkpoint('step-5')
214
+ guard.throwIfRaised()
215
+
216
+ // Step 5 (bare, inc 6; U08, inc 12; U09, inc 13): `--from <commit>` names the base commit
217
+ // directly; `--from <version>` sets only `baseVersion`, leaving `baseCommit` unknown until step
218
+ // 7's download resolves the tag; either way `range.json`'s own commit/version is never read
219
+ // (U08) -- but when `--from` is given AND `range.json` is present, it still has to pass J1
220
+ // (`lstat`: absent, or a regular file) and J3 (parses as JSON, and is an object); J2, J4, J5 and
221
+ // J6 do not apply with `--from` (U09, D17). Absent `--from`, read the base from `range.json`. J2
222
+ // (inc 11): an absent `range.json` without `--from` is UP-E04, not a bug. Full J1/J3/J4/J5
223
+ // validation of the no-`--from` path (U10) is the `else` branch below; J6 (the recorded commit
224
+ // is the commit of tag `v<version>`, D11) is checked in step 7, on the tag's own archive. After
225
+ // J1-J5, with or without `--from`, D15 refuses an untracked `range.json` the range ignores.
226
+ let baseCommit
227
+ let baseVersion
228
+ // D11 (T-0061): a base read from `range.json` must be a release -- it is downloaded through
229
+ // upstream's own tag (step 7, J6). A `--from <commit>` base is not checked against a tag (D17);
230
+ // a `--from <version>` base is its tag's commit by construction.
231
+ const baseFromRecord = parsed.from === undefined
232
+ if (parsed.from !== undefined) {
233
+ const rangeJsonResult = await readRangeJson(deps)
234
+ if (rangeJsonResult.token !== undefined) {
235
+ io.stderr.write(t2['UP-E06']({ reason: rangeJsonResult.token }) + '\n')
236
+ return 1
237
+ }
238
+ if (COMMIT_RE.test(parsed.from)) {
239
+ baseCommit = parsed.from
240
+ } else {
241
+ baseVersion = parsed.from
242
+ }
243
+ } else {
244
+ const rangeJsonResult = await readRangeJson(deps)
245
+ if (rangeJsonResult.absent) {
246
+ io.stderr.write(t2['UP-E04']() + '\n')
247
+ return 1
248
+ }
249
+ if (rangeJsonResult.token !== undefined) {
250
+ io.stderr.write(t2['UP-E06']({ reason: rangeJsonResult.token }) + '\n')
251
+ return 1
252
+ }
253
+ // J4 (inc 14): `upstream` must be a non-null, non-array object with a string `repository`, a
254
+ // `version` matching `VERSION_RE` and a `commit` matching `COMMIT_RE` -- the first broken
255
+ // field is reported, in that order (traceability.md "Base from range.json" J4).
256
+ const { upstream } = rangeJsonResult.value
257
+ let j4Token
258
+ if (upstream === null || typeof upstream !== 'object' || Array.isArray(upstream)) {
259
+ j4Token = 'upstream'
260
+ } else if (typeof upstream.repository !== 'string') {
261
+ j4Token = 'upstream.repository'
262
+ } else if (typeof upstream.version !== 'string' || !VERSION_RE.test(upstream.version)) {
263
+ j4Token = 'upstream.version'
264
+ } else if (typeof upstream.commit !== 'string' || !COMMIT_RE.test(upstream.commit)) {
265
+ j4Token = 'upstream.commit'
266
+ }
267
+ if (j4Token !== undefined) {
268
+ io.stderr.write(t2['UP-E06']({ reason: j4Token }) + '\n')
269
+ return 1
270
+ }
271
+ // J5 (inc 15): the repositories are compared after mapping only ASCII `[A-Z]` to lowercase,
272
+ // not a case-sensitive `!==` (U11(b) pins this).
273
+ if (foldAsciiCase(upstream.repository) !== foldAsciiCase(deps.source.repository)) {
274
+ io.stderr.write(t2['UP-E05']({ rangeRepository: upstream.repository, sourceRepository: deps.source.repository }) + '\n')
275
+ return 1
276
+ }
277
+ baseCommit = upstream.commit
278
+ baseVersion = upstream.version
279
+ }
280
+
281
+ // D15 (OQ-026; T-0061): with or without `--from`, after J1-J5 and before step 6, a `range.json`
282
+ // that is not tracked and that the range's rules ignore is refused -- step 9 would write it, and
283
+ // the restore commands could neither restore nor remove an ignored, untracked file. The path is
284
+ // given as `./range.json` so both commands read it literally. `check-ignore` exits 0 (ignored)
285
+ // or 1 (not ignored); any other result of either command is CW-E14 naming it (D19).
286
+ const recordTracked = await runGitNamed(
287
+ deps,
288
+ guard,
289
+ ['ls-files', '-z', '--', './range.json'],
290
+ { cwd: deps.cwd, env: gitEnv },
291
+ 'ls-files -z -- ./range.json',
292
+ )
293
+ guard.throwIfRaised() // candidate 13 (stage 7): a signal already received wins over interpreting this result
294
+ requireGitSuccess('ls-files -z -- ./range.json', recordTracked)
295
+ // S1 (T-0062): the same "ignored path" check P-5 uses (traceability.md Vocabulary), so the two
296
+ // cannot drift; its own git failures are CW-E14 naming the command (D19).
297
+ if (recordTracked.stdout === '' && (await isIgnoredPath(deps, guard, deps.cwd, 'range.json'))) {
298
+ io.stderr.write(t2['UP-E13']() + '\n')
299
+ return 1
300
+ }
301
+
302
+ // Step 6: gathers the checks written by increments 6 (commit
303
+ // equal), 17 (U50's floor), 16 (U13's downgrade) and 18 (U14's equal-version no-op) into one
304
+ // function, callable again in step 7 once the base's version/commit are actually known
305
+ // (amendment 2, U51/U52). Prints and returns an exit code, or `null` to go on. Amendment 6:
306
+ // UP-I01 always carries the base's own version and commit.
307
+ function checkBaseAgainstTarget({ baseCommit, baseVersion }) {
308
+ if (baseCommit === targetCommit) {
309
+ io.stdout.write(t2['UP-I01']({ version: baseVersion ?? deps.source.version, commit: baseCommit }) + '\n')
310
+ return 0
311
+ }
312
+ if (baseVersion !== undefined && !isAtLeast(versionParts(baseVersion), MINIMUM_BASE_VERSION)) {
313
+ io.stderr.write(t2['UP-E10']({ version: baseVersion }) + '\n')
314
+ return 1
315
+ }
316
+ if (baseVersion !== undefined && isHigherVersion(baseVersion, deps.source.version)) {
317
+ io.stderr.write(t2['UP-E07']({ baseVersion, targetVersion: deps.source.version }) + '\n')
318
+ return 1
319
+ }
320
+ // U14 (inc 18): equal versions, a known different commit, and no override is also a no-op --
321
+ // this does not apply to a version-form `--from` base, whose commit is unknown until step 7
322
+ // resolves its tag (Q4).
323
+ if (baseCommit !== undefined && baseVersion === deps.source.version && !hasOverride) {
324
+ io.stdout.write(t2['UP-I01']({ version: baseVersion, commit: baseCommit }) + '\n')
325
+ return 0
326
+ }
327
+ return null
328
+ }
329
+
330
+ deps.checkpoint('step-6')
331
+ guard.throwIfRaised()
332
+
333
+ // The target commit is resolved -- the override, when given, prints CW-W04 once, before any of
334
+ // the checks above run.
335
+ const targetCommit = hasOverride ? overrideRaw : deps.source.commit
336
+ if (hasOverride) {
337
+ io.stderr.write(t2['CW-W04']({ commit: overrideRaw }) + '\n')
338
+ }
339
+ const preDownloadResult = checkBaseAgainstTarget({ baseCommit, baseVersion })
340
+ if (preDownloadResult !== null) return preDownloadResult
341
+
342
+ deps.checkpoint('step-7')
343
+ guard.throwIfRaised()
344
+
345
+ // Step 7 (bare, inc 10; U08, inc 12; U14, inc 18; J6, T-0061): download the base -- through its
346
+ // tag `refs/tags/v<version>` for a base read from `range.json` (D11) or a `--from <version>`
347
+ // base, by commit for a `--from <commit>` base -- then the target by commit (traceability.md
348
+ // "Step order" row 7);
349
+ // validate both, then build each into its own pristine range under `deps.tmpRoot` (`run-shared.mjs`'s
350
+ // shared functions), base fully before target. The tag-mode comment rule (U16, below) and
351
+ // repeating step 6's checks with the now-known base version/commit (amendment 2, U51/U52,
352
+ // below) both apply here; a null/failing result here returns 1 after the shared function's own
353
+ // message and own cleanup.
354
+ const baseRef = baseFromRecord ? `refs/tags/v${baseVersion}` : (baseCommit ?? `refs/tags/v${baseVersion}`)
355
+ const baseDownload = await downloadArchive({
356
+ io,
357
+ deps,
358
+ t2,
359
+ guard,
360
+ repository: deps.source.repository,
361
+ ref: baseRef,
362
+ // D18: a 404 on the tag download of a base read from range.json carries the remedies.
363
+ remediesOn404: baseFromRecord,
364
+ })
365
+ if (!baseDownload) return 1
366
+ const targetDownload = await downloadArchive({ io, deps, t2, guard, repository: deps.source.repository, ref: targetCommit })
367
+ if (!targetDownload) return 1
368
+
369
+ // U16 (inc 20) and J6 (T-0061): a tag download -- a `--from <version>` base, whose commit is
370
+ // unknown until now, or a base read from `range.json` -- replaces rule 2 with "the comment is 40
371
+ // lowercase hex" (the resolved comment becomes the commit rule 2 compares the rest of the
372
+ // archive's own path prefix against) -- `readArchiveEntries`'s own `tag` parameter. For a base
373
+ // read from `range.json` the comment must also equal the recorded commit (`expectedCommit`, J6,
374
+ // UP-E12), and UP-E08 carries D18's remedies (`remedies`).
375
+ const baseTag = baseFromRecord || baseCommit === undefined ? `v${baseVersion}` : undefined
376
+ const baseResult = await readArchiveEntries({
377
+ io,
378
+ t2,
379
+ body: baseDownload.body,
380
+ // In tag mode `commit` is never consulted; only a `--from <commit>` base passes it.
381
+ commit: baseTag === undefined ? baseCommit : undefined,
382
+ tag: baseTag,
383
+ // J6 (D11): the tag's own comment must equal the commit range.json records.
384
+ expectedCommit: baseFromRecord ? baseCommit : undefined,
385
+ tagVersion: baseVersion,
386
+ // D18: UP-E08 for a base read from range.json carries the remedies.
387
+ remedies: baseFromRecord,
388
+ })
389
+ if (!baseResult) return 1
390
+ // U52 (inc 22): in tag mode `baseResult` is `{ entries, commit }` -- the commit the tag names,
391
+ // read from its own pax comment: a `--from <version>` base learns its commit here, and for a base
392
+ // read from `range.json` it equals the recorded one (J6).
393
+ const baseEntries = baseTag === undefined ? baseResult : baseResult.entries
394
+ if (baseTag !== undefined) baseCommit = baseResult.commit
395
+ const targetEntries = await readArchiveEntries({ io, t2, body: targetDownload.body, commit: targetCommit })
396
+ if (!targetEntries) return 1
397
+
398
+ // Amendment 2 (2026-11-07), inc 21: once both archives are validated, repeat step 6's checks
399
+ // with the base's now-known version/commit, before any directory is created. A commit-form
400
+ // `--from` base has no known version until now; it comes from the base archive's own
401
+ // `package.json`, read before P3. Decision (3): an absent entry, unparsable JSON, or a version
402
+ // not matching `VERSION_RE` is UP-E10 naming the raw value found.
403
+ if (baseVersion === undefined) {
404
+ const packageEntry = baseEntries.find((entry) => entry.rest === 'package.json' && entry.kind === 'file')
405
+ let rawVersion
406
+ if (packageEntry !== undefined) {
407
+ try {
408
+ ;({ version: rawVersion } = JSON.parse(packageEntry.content.toString('utf8')))
409
+ } catch {
410
+ rawVersion = undefined
411
+ }
412
+ }
413
+ if (rawVersion === undefined || typeof rawVersion !== 'string' || !VERSION_RE.test(rawVersion)) {
414
+ io.stderr.write(t2['UP-E10']({ version: rawVersion }) + '\n')
415
+ return 1
416
+ }
417
+ baseVersion = rawVersion
418
+ }
419
+ const postDownloadResult = checkBaseAgainstTarget({ baseCommit, baseVersion })
420
+ if (postDownloadResult !== null) return postDownloadResult
421
+
422
+ // D3 (inc 19): `tmpDirs` (owned by `runUpgrade`, below) is pushed to as soon as each
423
+ // `makeTmpDir` succeeds; every early return below it removes every directory already in the
424
+ // list -- `extractInto`/`applyPristine`'s own cleanup already removed the ONE directory THEY
425
+ // were given, but an earlier, already-fully-built range needs this to be removed too.
426
+ const baseTmpDir = await makeTmpDir({ io, deps, t2, parent: deps.tmpRoot, prefix: 'create-wxl-upgrade-' })
427
+ if (!baseTmpDir) return 1
428
+ tmpDirs.push(baseTmpDir)
429
+ if (!(await extractInto({ io, deps, t2, guard, entries: baseEntries, tmpDir: baseTmpDir }))) {
430
+ await cleanupTmpDirs(deps, tmpDirs)
431
+ return 1
432
+ }
433
+ if (
434
+ !(await applyPristine({
435
+ io,
436
+ deps,
437
+ t2,
438
+ guard,
439
+ tmpDir: baseTmpDir,
440
+ record: { repository: deps.source.repository, version: baseVersion, commit: baseCommit },
441
+ }))
442
+ ) {
443
+ await cleanupTmpDirs(deps, tmpDirs)
444
+ return 1
445
+ }
446
+
447
+ const targetTmpDir = await makeTmpDir({ io, deps, t2, parent: deps.tmpRoot, prefix: 'create-wxl-upgrade-' })
448
+ if (!targetTmpDir) {
449
+ await cleanupTmpDirs(deps, tmpDirs)
450
+ return 1
451
+ }
452
+ tmpDirs.push(targetTmpDir)
453
+ if (!(await extractInto({ io, deps, t2, guard, entries: targetEntries, tmpDir: targetTmpDir }))) {
454
+ await cleanupTmpDirs(deps, tmpDirs)
455
+ return 1
456
+ }
457
+ if (
458
+ !(await applyPristine({
459
+ io,
460
+ deps,
461
+ t2,
462
+ guard,
463
+ tmpDir: targetTmpDir,
464
+ record: { repository: deps.source.repository, version: deps.source.version, commit: targetCommit },
465
+ }))
466
+ ) {
467
+ await cleanupTmpDirs(deps, tmpDirs)
468
+ return 1
469
+ }
470
+
471
+ // Decision (4) (ticket Comments, 2026-11-09) no longer falls through to
472
+ // CW-E14 as of T-0027 increment 1: both pristine ranges exist, so step 8 plans the merge against
473
+ // them (merge-plan.mjs), step 9 applies the plan (merge-apply.mjs), and only then (step 10) are
474
+ // the temporary directories -- including the merge-inputs one merge-plan.mjs may have created
475
+ // lazily -- removed and the report printed.
476
+ deps.checkpoint('step-8')
477
+ guard.throwIfRaised()
478
+ let planResult
479
+ try {
480
+ planResult = await planMerge({
481
+ deps,
482
+ guard,
483
+ rangeRoot: deps.cwd,
484
+ // P-9 (D12, T-0062): the range root's native realpath, as step 3 resolved it.
485
+ realRangeRoot: realCwd,
486
+ baseDir: baseTmpDir,
487
+ targetDir: targetTmpDir,
488
+ baseVersion,
489
+ targetVersion: deps.source.version,
490
+ })
491
+ } catch (err) {
492
+ // `planMerge`'s own catch attaches the merge-inputs directory (if one
493
+ // was created) to ANY error it rethrows -- a `MergeFileError`, a signal, a read error, or a
494
+ // `runGit` rejection -- so it is removed here before the error is handled further, the same
495
+ // way every other early exit's temporary directories are.
496
+ if (err?.mergeDir !== undefined) tmpDirs.push(err.mergeDir)
497
+ // T-0027 increment 18 (U57): `git merge-file` exited outside 0-127 -- planning failed, so
498
+ // nothing from step 9 ever runs; UP-E11 names the path.
499
+ // `PlanningError` is the same planning failure, with the underlying error's own text
500
+ // (`err.detail`) added -- a read error of o/b/t, or a `runGit` rejection for `merge-file`.
501
+ if (err instanceof MergeFileError || err instanceof PlanningError) {
502
+ io.stderr.write(t2['UP-E11']({ path: err.path, detail: err.detail }) + '\n')
503
+ await cleanupTmpDirs(deps, tmpDirs)
504
+ return 1
505
+ }
506
+ throw err
507
+ }
508
+ if (planResult.mergeDir !== undefined) tmpDirs.push(planResult.mergeDir)
509
+
510
+ deps.checkpoint('step-9')
511
+ guard.throwIfRaised()
512
+ const applyResult = await applyMerge({
513
+ deps,
514
+ guard,
515
+ rangeRoot: deps.cwd,
516
+ ops: planResult.ops,
517
+ upstream: { repository: deps.source.repository, version: deps.source.version, commit: targetCommit },
518
+ })
519
+ if (!applyResult.ok) {
520
+ io.stderr.write(
521
+ t2['UP-E09'](
522
+ applyResult.signal !== undefined ? { signal: applyResult.signal } : { path: applyResult.path, detail: applyResult.detail },
523
+ ) + '\n',
524
+ )
525
+ await cleanupTmpDirs(deps, tmpDirs)
526
+ return applyResult.signal !== undefined ? 130 : 1
527
+ }
528
+
529
+ // D8 (traceability.md "Step order"): unlike every earlier checkpoint, `step-10` does not pair with
530
+ // an immediate `guard.throwIfRaised()` -- a signal here still lets the report print in full
531
+ // first; only once it is on stdout does a raised signal turn this run's exit code into 130.
532
+ deps.checkpoint('step-10')
533
+ await cleanupTmpDirs(deps, tmpDirs)
534
+
535
+ // T-0027 increment 21 (U26), traceability.md "Report" R4: "for a range with
536
+ // .github/workflows/deploy.yml" -- read AFTER apply (step 9), so the advice reflects the
537
+ // range's own state once this run is done, not its state before it started. Unlike the parent
538
+ // check's and `existingMode`'s own `lstat` (merge-plan.mjs, merge-apply.mjs), which are
539
+ // fail-closed, this one stays fail-open ON PURPOSE -- R4's own advice is best-effort, so ANY
540
+ // `lstat` error here (not only `ENOENT`) is read as "no deploy.yml", never stopping a run
541
+ // that has already fully applied the plan.
542
+ let hasDeployWorkflow = false
543
+ try {
544
+ const deployStat = await deps.fs.lstat(join(deps.cwd, '.github', 'workflows', 'deploy.yml'))
545
+ hasDeployWorkflow = deployStat.isFile()
546
+ } catch {
547
+ hasDeployWorkflow = false
548
+ }
549
+
550
+ io.stdout.write(
551
+ buildReport({
552
+ lang2,
553
+ toolVersion: deps.packageVersion,
554
+ baseVersion,
555
+ baseCommit,
556
+ targetVersion: deps.source.version,
557
+ targetCommit,
558
+ ops: planResult.ops,
559
+ attention: planResult.attention,
560
+ hasDeployWorkflow,
561
+ t2,
562
+ // D17: R1 marks a `--from <commit>` base as not checked against an upstream tag.
563
+ baseUnchecked: parsed.from !== undefined && COMMIT_RE.test(parsed.from),
564
+ }),
565
+ )
566
+ guard.throwIfRaised()
567
+ return 0
568
+ }
569
+
570
+ /** Mirrors `runSteps`' own catch (run.mjs): a `SignalAbort` or any other error (a bug, printed as
571
+ * CW-E14) removes every temporary directory this run created (D3, inc 19) before returning. */
572
+ export async function runUpgrade(parsed, io, deps, t2, lang2, guard, overrideInfo) {
573
+ const tmpDirs = []
574
+ try {
575
+ return await runUpgradeBody(parsed, io, deps, t2, lang2, guard, overrideInfo, tmpDirs)
576
+ } catch (err) {
577
+ await cleanupTmpDirs(deps, tmpDirs)
578
+ if (err instanceof SignalAbort) return 130
579
+ io.stderr.write(t2['CW-E14']({ message: err?.message ?? String(err) }) + '\n')
580
+ return 1
581
+ }
582
+ }