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.
- package/bin/create-wxl.mjs +2 -0
- package/dist/fork-init.mjs +15 -14
- package/dist/source.json +3 -3
- package/package.json +1 -1
- package/src/archive-rules.mjs +19 -3
- package/src/argv.mjs +59 -2
- package/src/base-version.mjs +10 -0
- package/src/git-named.mjs +43 -0
- package/src/git-runner.mjs +27 -14
- package/src/git-version.mjs +5 -3
- package/src/merge-apply.mjs +156 -0
- package/src/merge-plan.mjs +660 -0
- package/src/messages.mjs +232 -8
- package/src/patterns.mjs +6 -0
- package/src/questions.mjs +3 -3
- package/src/run-shared.mjs +207 -0
- package/src/run.mjs +36 -136
- package/src/text.mjs +47 -0
- package/src/upgrade-report.mjs +134 -0
- package/src/upgrade.mjs +582 -0
package/src/upgrade.mjs
ADDED
|
@@ -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
|
+
}
|