create-wxl 0.0.0-stage → 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/src/run.mjs ADDED
@@ -0,0 +1,437 @@
1
+ import { dirname, resolve as resolvePath } from 'node:path'
2
+ import { createHash } from 'node:crypto'
3
+ import { messages, cwE12FinishingCommandsList } from './messages.mjs'
4
+ import { jsonValue } from './text.mjs'
5
+ import { selectLanguage } from './language.mjs'
6
+ import { parseArgvStrict, ArgvError, SETUP_FLAG_NAMES } from './argv.mjs'
7
+ import { checkTarget, moveIntoPlace, MoveIntoPlaceError } from './fsops.mjs'
8
+ import { isNodeVersionOk, MINIMUM_NODE_VERSION } from './node-version.mjs'
9
+ import { makeQuestioner, QuestionEofError, buildEngineArgv, missingRequiredFlags, collectInteractiveAnswers } from './questions.mjs'
10
+ import { runGitSteps, orderedPrintedForms } from './git-steps.mjs'
11
+ import { buildNextSteps } from './next-steps.mjs'
12
+ import { createSignalGuard, SignalAbort } from './signals.mjs'
13
+ import { VERSION_RE, COMMIT_RE } from './patterns.mjs'
14
+ import { removeTmpDir, downloadArchive, readArchiveEntries, makeTmpDir, extractInto, applyPristine } from './run-shared.mjs'
15
+ import { runUpgrade } from './upgrade.mjs'
16
+
17
+ /** Step 2's package source record check (D2): `deps.engineFile` is read through `deps.fs` and its
18
+ * SHA-256 compared with `source.engineSha256`. */
19
+ async function validateSourceRecord(deps) {
20
+ const source = deps.source
21
+ if (!source || typeof source !== 'object') return false
22
+ if (source.repository !== deps.engine.UPSTREAM_SLUG) return false
23
+ if (typeof source.version !== 'string' || source.version !== deps.packageVersion) return false
24
+ if (!VERSION_RE.test(source.version)) return false
25
+ if (typeof source.commit !== 'string' || !COMMIT_RE.test(source.commit)) return false
26
+
27
+ let engineBytes
28
+ try {
29
+ engineBytes = await deps.fs.readFile(deps.engineFile)
30
+ } catch {
31
+ return false
32
+ }
33
+ const actualSha256 = createHash('sha256').update(engineBytes).digest('hex')
34
+ return actualSha256 === source.engineSha256
35
+ }
36
+
37
+ /**
38
+ * create-wxl's one exported seam (0005-S1): the Step order table's steps 1-13, every signal
39
+ * window (traceability.md "Signals"), and the full set of failure-path messages (CW-E09,
40
+ * CW-E11, CW-E14, CW-W05). An argv whose first positional is `upgrade` is dispatched to
41
+ * 0006's upgrade path instead (0006-S1, `runUpgrade` in upgrade.mjs).
42
+ */
43
+ export async function run(argv, io, deps) {
44
+ // traceability.md "Signals": "errors writing the messages are ignored". Without a listener, a
45
+ // `Writable`'s own `write` error is an unhandled `'error'` event (an uncaught exception), not a
46
+ // rejection any `await` here could catch; a no-op listener is the only way to ignore it.
47
+ // S-30: removed in `finally` below, so a run never leaves a listener behind on `io`'s streams.
48
+ const ignoreWriteError = () => {}
49
+ io.stdout.on('error', ignoreWriteError)
50
+ io.stderr.on('error', ignoreWriteError)
51
+ try {
52
+ return await runBody(argv, io, deps)
53
+ } finally {
54
+ // A `Writable`'s own `write` error is emitted on a later tick (`process.nextTick`), not
55
+ // synchronously from `write()` -- removing the listener right away could drop it before it
56
+ // fires, turning the very error this listener exists to ignore back into an uncaught
57
+ // exception. One `setImmediate` tick runs after any already-scheduled `nextTick` callback.
58
+ await new Promise((resolve) => setImmediate(resolve))
59
+ io.stdout.removeListener('error', ignoreWriteError)
60
+ io.stderr.removeListener('error', ignoreWriteError)
61
+ }
62
+ }
63
+
64
+ async function runBody(argv, io, deps) {
65
+ // CW-E01 and CW-E02 use the environment language rule only (traceability.md "Language"): they
66
+ // happen before or during parsing, so a `--lang` flag cannot select their table yet.
67
+ const envLang = selectLanguage(deps)
68
+ const t = messages[envLang]
69
+
70
+ // Step 1: Node version, before anything else (including argv parsing).
71
+ if (!isNodeVersionOk(deps.nodeVersion)) {
72
+ io.stderr.write(t['CW-E01']({ running: deps.nodeVersion, minimum: MINIMUM_NODE_VERSION }) + '\n')
73
+ return 1
74
+ }
75
+
76
+ // traceability.md "Signals": "an error that is none of the CW codes (a bug) ... is printed as
77
+ // CW-E14 ... and exits 1" -- steps 1-2 (before `runSteps`, which has its own such catch) need
78
+ // the same fallback, in the environment language (no `--lang` is parsed yet).
79
+ let parsed
80
+ try {
81
+ parsed = parseArgvStrict(argv)
82
+ } catch (err) {
83
+ if (err instanceof ArgvError) {
84
+ io.stderr.write(t['CW-E02']({ value: err.token, usage: err.usage, reason: err.reason }) + '\n')
85
+ return 1
86
+ }
87
+ io.stderr.write(t['CW-E14']({ message: err?.message ?? String(err) }) + '\n')
88
+ return 1
89
+ }
90
+ if (parsed.noSetup) {
91
+ const presentSetupFlag = SETUP_FLAG_NAMES.find((name) => name in parsed.flags)
92
+ if (presentSetupFlag) {
93
+ io.stderr.write(
94
+ t['CW-E02']({ value: `--${presentSetupFlag}`, usage: false, reason: 'conflicts-with-no-setup' }) + '\n',
95
+ )
96
+ return 1
97
+ }
98
+ }
99
+
100
+ const overrideRaw = deps.env.CREATE_WXL_COMMIT
101
+ const hasOverride = typeof overrideRaw === 'string' && overrideRaw !== ''
102
+ if (hasOverride && !COMMIT_RE.test(overrideRaw)) {
103
+ io.stderr.write(t['CW-E02']({ value: 'CREATE_WXL_COMMIT', usage: false, reason: 'bad-env', badValue: overrideRaw }) + '\n')
104
+ return 1
105
+ }
106
+
107
+ if (!(await validateSourceRecord(deps))) {
108
+ io.stderr.write(t['CW-E13']() + '\n')
109
+ return 1
110
+ }
111
+
112
+ // From here on, a valid `--lang` (now that argv has parsed) selects the table.
113
+ const lang2 = selectLanguage(deps, parsed.lang)
114
+ const t2 = messages[lang2]
115
+
116
+ // The guard is created here and passed down (T-0024 ticket Comments, "increment plan
117
+ // confirmed"), so every window below throwing/catching `SignalAbort` shares the same one, and
118
+ // its listeners come off `deps.signals` exactly once, however `runSteps` returns.
119
+ const guard = createSignalGuard(deps.signals, ['SIGINT', 'SIGTERM', 'SIGHUP'])
120
+ const questioner = makeQuestioner(io, deps, guard)
121
+ try {
122
+ if (parsed.mode === 'upgrade') {
123
+ return await runUpgrade(parsed, io, deps, t2, lang2, guard, { hasOverride, overrideRaw })
124
+ }
125
+ return await runSteps(parsed, io, deps, t2, lang2, questioner, guard, { hasOverride, overrideRaw })
126
+ } catch (err) {
127
+ if (err instanceof QuestionEofError) {
128
+ io.stderr.write(t2['CW-E15']() + '\n')
129
+ return 1
130
+ }
131
+ throw err
132
+ } finally {
133
+ questioner.close()
134
+ guard.off()
135
+ }
136
+ }
137
+
138
+ /** Step 3/4's directory question (S-12): asks once, re-asking on its own (printing the `~` reason,
139
+ * traceability.md "Step order" row 3) until it has an answer that does not begin with `~`. Never
140
+ * touches `checkTarget`, so the step-4 re-ask loop that calls this only prints CW-E04 for a
141
+ * rejection `checkTarget` itself returned, never for a stale one left over from a `~` answer
142
+ * (S-13/P-17/C-06). */
143
+ async function askDirectory(io, t2, questioner) {
144
+ while (true) {
145
+ io.stdout.write(t2['Q-DIRECTORY']() + '\n')
146
+ const raw = await questioner.ask()
147
+ const answer = raw.trim() === '' ? 'wxl-range' : raw.trim()
148
+ if (answer.startsWith('~')) {
149
+ io.stdout.write(t2['Q-DIRECTORY-TILDE']() + '\n')
150
+ continue
151
+ }
152
+ return answer
153
+ }
154
+ }
155
+
156
+ async function runSteps(parsed, io, deps, t2, lang2, questioner, guard, { hasOverride, overrideRaw }) {
157
+ // `tmpDir`, once step 8 creates it, is this function's one piece of cleanup state: a signal or a
158
+ // "bug" (an error that is none of the expected CW conditions) caught below removes it, both
159
+ // handled alike (traceability.md "Signals": "an error that is none of the CW codes ... is
160
+ // handled like a signal in its window"). `remainingCommandsFn`, once step 11 starts, tells the
161
+ // catch below which commands a bug in steps 11-13 still has to list (C-07/P-04/S-04: the same
162
+ // decision (3) CW-W05 already follows for a signal in that window).
163
+ let tmpDir = null
164
+ let remainingCommandsFn = null
165
+ try {
166
+ return await runStepsBody(
167
+ parsed,
168
+ io,
169
+ deps,
170
+ t2,
171
+ lang2,
172
+ questioner,
173
+ guard,
174
+ { hasOverride, overrideRaw },
175
+ (dir) => {
176
+ tmpDir = dir
177
+ },
178
+ (fn) => {
179
+ remainingCommandsFn = fn
180
+ },
181
+ )
182
+ } catch (err) {
183
+ if (err instanceof QuestionEofError) throw err
184
+ if (tmpDir) await removeTmpDir(deps, tmpDir)
185
+ if (err instanceof SignalAbort) return 130
186
+ const remainingCommands = remainingCommandsFn ? remainingCommandsFn() : undefined
187
+ io.stderr.write(t2['CW-E14']({ message: err?.message ?? String(err), remainingCommands }) + '\n')
188
+ return 1
189
+ }
190
+ }
191
+
192
+ async function runStepsBody(
193
+ parsed,
194
+ io,
195
+ deps,
196
+ t2,
197
+ lang2,
198
+ questioner,
199
+ guard,
200
+ { hasOverride, overrideRaw },
201
+ setTmpDir,
202
+ setRemainingCommandsFn,
203
+ ) {
204
+ deps.checkpoint('step-3')
205
+ let directory = parsed.directory
206
+ let askedDirectory = false
207
+ if (directory === undefined) {
208
+ if (io.interactive) {
209
+ askedDirectory = true
210
+ directory = await askDirectory(io, t2, questioner)
211
+ } else {
212
+ io.stderr.write(t2['CW-E03']() + '\n')
213
+ return 1
214
+ }
215
+ }
216
+ let target = resolvePath(deps.cwd, directory)
217
+
218
+ deps.checkpoint('step-4')
219
+ guard.throwIfRaised()
220
+
221
+ const printCwE04 = (rejectionToPrint) =>
222
+ io.stderr.write(t2['CW-E04']({ path: target, conditionId: rejectionToPrint.id, detail: rejectionToPrint.detail }) + '\n')
223
+
224
+ let rejection = await checkTarget(deps, target)
225
+ guard.throwIfRaised() // P-17/C-14: a signal already received wins over a rejection found after
226
+ while (rejection && askedDirectory) {
227
+ printCwE04(rejection)
228
+ directory = await askDirectory(io, t2, questioner)
229
+ target = resolvePath(deps.cwd, directory)
230
+ rejection = await checkTarget(deps, target)
231
+ guard.throwIfRaised()
232
+ }
233
+ if (rejection) {
234
+ printCwE04(rejection)
235
+ return 1
236
+ }
237
+
238
+ let setupArgs = null
239
+ let setupArgv = null
240
+ if (!parsed.noSetup) {
241
+ deps.checkpoint('step-5')
242
+ guard.throwIfRaised()
243
+ if (!io.interactive) {
244
+ const missing = missingRequiredFlags(parsed.flags)
245
+ if (missing.length > 0) {
246
+ io.stderr.write(t2['CW-E05']({ missing }) + '\n')
247
+ return 1
248
+ }
249
+ setupArgv = buildEngineArgv(parsed.flags)
250
+ try {
251
+ setupArgs = deps.engine.parseForkInitArgs(setupArgv)
252
+ } catch (err) {
253
+ if (err instanceof deps.engine.ForkInitArgError) {
254
+ io.stderr.write(t2['CW-E06']({ message: err.message }) + '\n')
255
+ return 1
256
+ }
257
+ throw err
258
+ }
259
+ } else {
260
+ ;({ parsedArgs: setupArgs, argv: setupArgv } = await collectInteractiveAnswers(io, deps, t2, parsed.flags, questioner))
261
+ }
262
+ }
263
+
264
+ const commit = hasOverride ? overrideRaw : deps.source.commit
265
+
266
+ deps.checkpoint('step-6')
267
+ guard.throwIfRaised()
268
+ if (hasOverride) {
269
+ io.stderr.write(t2['CW-W04']({ commit }) + '\n')
270
+ }
271
+ // The URL/AbortController/timeout/race/CW-E07 window lives in `run-shared.mjs`'s
272
+ // `downloadArchive`, shared with the upgrade path (traceability.md "Step order" row 7); this
273
+ // call site's own checkpoints and CW-W04 are unchanged.
274
+ const downloaded = await downloadArchive({ io, deps, t2, guard, repository: deps.source.repository, ref: commit })
275
+ if (!downloaded) return 1
276
+ const { body } = downloaded
277
+
278
+ deps.checkpoint('step-7')
279
+ guard.throwIfRaised()
280
+ // Parsing, entry-rule validation, mkdtemp, extraction and P1-P4 live in `run-shared.mjs`,
281
+ // shared with the upgrade path; this call site's own checkpoints, `setTmpDir` and argument
282
+ // shape are unchanged.
283
+ const validatedEntries = await readArchiveEntries({ io, t2, body, commit })
284
+ if (!validatedEntries) return 1
285
+
286
+ deps.checkpoint('step-8')
287
+ guard.throwIfRaised()
288
+ const parentDir = dirname(target)
289
+ const tmpDir = await makeTmpDir({ io, deps, t2, parent: parentDir, prefix: '.create-wxl-' })
290
+ if (!tmpDir) return 1 // mkdtemp itself failed: nothing was created, nothing to clean up
291
+ setTmpDir(tmpDir)
292
+ if (!(await extractInto({ io, deps, t2, guard, entries: validatedEntries, tmpDir }))) return 1
293
+
294
+ deps.checkpoint('step-9')
295
+ guard.throwIfRaised()
296
+ if (
297
+ !(await applyPristine({
298
+ io,
299
+ deps,
300
+ t2,
301
+ guard,
302
+ tmpDir,
303
+ record: { repository: deps.source.repository, version: deps.source.version, commit },
304
+ }))
305
+ ) {
306
+ return 1
307
+ }
308
+
309
+ deps.checkpoint('step-10')
310
+ guard.throwIfRaised() // P-02/C-01
311
+ // Step 10's own re-check (traceability.md "Step order" row 10): another process may have created
312
+ // something at the target since step 4. An empty directory is still accepted (moveIntoPlace's
313
+ // existing-target branch handles it); anything else cleans up the temporary directory and reports
314
+ // CW-E04 as step 4 would, instead of letting `rename` itself fail with an opaque ENOTDIR/ENOTEMPTY.
315
+ const recheck = await checkTarget(deps, target)
316
+ guard.throwIfRaised()
317
+ if (recheck) {
318
+ await removeTmpDir(deps, tmpDir)
319
+ printCwE04(recheck)
320
+ return 1
321
+ }
322
+ try {
323
+ await moveIntoPlace(deps, tmpDir, target, { shouldStop: () => guard.isRaised() })
324
+ } catch (err) {
325
+ if (err instanceof MoveIntoPlaceError) {
326
+ if (err.signaled) {
327
+ // C-02/P-03/S-23: a stranded entry still keeps and names the temporary directory (the
328
+ // same "kept and named" guarantee a non-signaled CW-E11 has); the signal still decides
329
+ // the exit code (130), and `setTmpDir(null)` here stops the outer catch from deleting a
330
+ // temporary directory `moveIntoPlace` deliberately kept.
331
+ if (err.stranded.length > 0) {
332
+ io.stderr.write(
333
+ t2['CW-E11']({ target, detail: err.message, stranded: err.stranded, tmpDir: err.tmpDir }) + '\n',
334
+ )
335
+ setTmpDir(null)
336
+ return 130
337
+ }
338
+ throw new SignalAbort(guard.raised())
339
+ }
340
+ io.stderr.write(
341
+ t2['CW-E11']({ target, detail: err.message, stranded: err.stranded, tmpDir: err.tmpDir }) + '\n',
342
+ )
343
+ return 1
344
+ }
345
+ throw err
346
+ }
347
+ setTmpDir(null) // step 10 succeeded: the range stays on disk whatever happens from here on
348
+
349
+ // Steps 11-13 (traceability.md "Signals"): a signal here is printed directly, inline (CW-W05),
350
+ // instead of being thrown and routed through the generic `SignalAbort` catch above -- there is
351
+ // no tmpDir cleanup left to do, and each of the three steps' CW-W05 names a different set of
352
+ // remaining commands.
353
+ // S-21: `lang2` is the language `selectLanguage` already picked in `run()`; reusing it here
354
+ // avoids re-deriving the same answer from `t2`'s own object identity.
355
+ const lang = lang2
356
+
357
+ if (!parsed.noSetup) {
358
+ deps.checkpoint('step-11')
359
+ setRemainingCommandsFn(() =>
360
+ cwE12FinishingCommandsList({ directory, argv: setupArgv, version: deps.source.version, lang }),
361
+ )
362
+ if (guard.raised()) {
363
+ io.stderr.write(
364
+ t2['CW-W05']({
365
+ remainingCommands: cwE12FinishingCommandsList({
366
+ directory,
367
+ argv: setupArgv,
368
+ version: deps.source.version,
369
+ lang,
370
+ }),
371
+ }) +
372
+ '\n',
373
+ )
374
+ return 130
375
+ }
376
+ // S-12: both CW-E12 call sites (the thrown-error and the non-zero-exitCode path) otherwise
377
+ // repeat the same `{ directory, argv: setupArgv, version: deps.source.version }`.
378
+ const printCwE12 = (message) =>
379
+ io.stderr.write(t2['CW-E12']({ message, directory, argv: setupArgv, version: deps.source.version }) + '\n')
380
+ let result
381
+ try {
382
+ result = deps.engine.runForkInit(setupArgs, { projectRoot: target })
383
+ } catch (err) {
384
+ printCwE12(err?.message ?? String(err))
385
+ return 1
386
+ }
387
+ if (result.exitCode !== 0) {
388
+ printCwE12(result.message)
389
+ return 1
390
+ }
391
+ for (const warning of result.warnings) {
392
+ io.stdout.write(jsonValue(warning) + '\n')
393
+ }
394
+ }
395
+
396
+ let gitResult = { warningId: null }
397
+ if (!parsed.noSetup) {
398
+ deps.checkpoint('step-12')
399
+ setRemainingCommandsFn(() => orderedPrintedForms(deps.source.version))
400
+ if (guard.raised()) {
401
+ io.stderr.write(t2['CW-W05']({ remainingCommands: orderedPrintedForms(deps.source.version) }) + '\n')
402
+ return 130
403
+ }
404
+ gitResult = await runGitSteps({
405
+ runGit: deps.runGit,
406
+ cwd: target,
407
+ env: deps.env,
408
+ version: deps.source.version,
409
+ shouldStop: () => guard.isRaised(),
410
+ })
411
+ if (gitResult.signal) {
412
+ io.stderr.write(t2['CW-W05']({ remainingCommands: gitResult.remainingCommands }) + '\n')
413
+ return 130
414
+ }
415
+ }
416
+ if (gitResult.warningId) {
417
+ io.stderr.write(t2[gitResult.warningId](gitResult) + '\n')
418
+ }
419
+
420
+ deps.checkpoint('step-13')
421
+ setRemainingCommandsFn(() => [])
422
+ if (guard.raised()) {
423
+ io.stderr.write(t2['CW-W05']({ remainingCommands: [] }) + '\n')
424
+ return 130
425
+ }
426
+ const nextSteps = buildNextSteps({
427
+ t: t2,
428
+ directory,
429
+ noSetup: parsed.noSetup,
430
+ parsedArgs: setupArgs,
431
+ gitResult,
432
+ })
433
+ for (const line of nextSteps) {
434
+ io.stdout.write(line + '\n')
435
+ }
436
+ return 0
437
+ }
@@ -0,0 +1,88 @@
1
+ // Signal handling (traceability.md "Signals"): `SIGINT`, `SIGTERM` and `SIGHUP` are handled
2
+ // alike, from step 3 on. `createSignalGuard` listens on `deps.signals` and remembers only the
3
+ // FIRST signal received (further signals during cleanup are ignored, traceability.md "Signals").
4
+ // A pending question's `ask()` can register itself to be rejected the moment a signal arrives
5
+ // (questions.mjs), instead of only being checked at the next `checkpoint`.
6
+
7
+ export class SignalAbort extends Error {
8
+ constructor(signal) {
9
+ super(`aborted by ${signal}`)
10
+ this.name = 'SignalAbort'
11
+ this.signal = signal
12
+ }
13
+ }
14
+
15
+ /** `signals`: the `EventEmitter` this run's `deps.signals` is. `names`: which signal names to
16
+ * listen for; defaults to all three traceability.md "Signals" handles alike. */
17
+ export function createSignalGuard(signals, names = ['SIGINT', 'SIGTERM', 'SIGHUP']) {
18
+ let raised = null
19
+ let pendingReject = null
20
+ const onRaiseCallbacks = []
21
+ const handlers = new Map()
22
+
23
+ function onSignal(name) {
24
+ if (raised) return // one-time: a second signal during cleanup is ignored
25
+ raised = name
26
+ for (const cb of onRaiseCallbacks) cb(name)
27
+ if (pendingReject) {
28
+ const reject = pendingReject
29
+ pendingReject = null
30
+ reject(new SignalAbort(name))
31
+ }
32
+ }
33
+
34
+ for (const name of names) {
35
+ const handler = () => onSignal(name)
36
+ handlers.set(name, handler)
37
+ signals.on(name, handler)
38
+ }
39
+
40
+ return {
41
+ raised() {
42
+ return raised
43
+ },
44
+ // S-18: one name for "has a signal arrived" wherever a caller needs a plain boolean (the
45
+ // `shouldStop` callback `fsops.mjs`/`git-steps.mjs` are given), instead of each call site
46
+ // re-writing `guard.raised() !== null` for itself.
47
+ isRaised() {
48
+ return raised !== null
49
+ },
50
+ throwIfRaised() {
51
+ if (raised) throw new SignalAbort(raised)
52
+ },
53
+ /** Registers `reject` to be called the moment a signal arrives while something else is
54
+ * waiting (a pending question's `ask()`). The caller clears this itself (`clearWait()`) once
55
+ * settled any other way (a line arrived, EOF) -- `waitForSignal` only knows about the signal
56
+ * side of that race, not the other one. */
57
+ waitForSignal(reject) {
58
+ if (raised) {
59
+ reject(new SignalAbort(raised))
60
+ return
61
+ }
62
+ pendingReject = reject
63
+ },
64
+ clearWait() {
65
+ pendingReject = null
66
+ },
67
+ /** Registers `cb` to run once, synchronously, the moment a signal is first raised --
68
+ * immediately when one already has been. Independent of `waitForSignal`'s single pending
69
+ * slot (decision 6: step 6's download aborts its own `AbortController` this way). Returns an
70
+ * unsubscribe function, so a caller whose own window has already closed (step 6 finishing)
71
+ * does not leave a stale callback registered for the rest of the run. */
72
+ onRaise(cb) {
73
+ if (raised) {
74
+ cb(raised)
75
+ return () => {}
76
+ }
77
+ onRaiseCallbacks.push(cb)
78
+ return () => {
79
+ const i = onRaiseCallbacks.indexOf(cb)
80
+ if (i !== -1) onRaiseCallbacks.splice(i, 1)
81
+ }
82
+ },
83
+ off() {
84
+ for (const [name, handler] of handlers) signals.removeListener(name, handler)
85
+ handlers.clear()
86
+ },
87
+ }
88
+ }
package/src/text.mjs ADDED
@@ -0,0 +1,127 @@
1
+ // Shared text helpers: JSON string literals for named values (every C0, DEL and C1 character
2
+ // escaped per traceability.md "Error and warning codes", D10), and POSIX quoting for commands
3
+ // printed for copying (N1, N4, N5, N7, N8, CW-E12, CW-W05).
4
+
5
+ /** S-20: the one definition of "C0, DEL or C1" (traceability.md "Error and warning codes" and
6
+ * AC-24's `hasControlChar`), shared with `escapeChar`'s own, wider "needs `\uXXXX`" rule below
7
+ * (which also covers lone surrogates, a separate reason to escape, review finding C-09 -- NOT a
8
+ * reason to withhold a copyable command, so `hasControlChar` does not call this with that range
9
+ * folded in). */
10
+ function isControlCodePoint(code) {
11
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f)
12
+ }
13
+
14
+ function isLoneSurrogate(code) {
15
+ return code >= 0xd800 && code <= 0xdfff
16
+ }
17
+
18
+ /** D10 (the user-approved reading; stage 6/7 correction P-01/S-02/C-11): a line feed inside a
19
+ * value is the JSON short escape `\n` (the matrix's own value, traceability.md C60); every other
20
+ * C0, DEL, C1 character and lone surrogate (review finding C-09: `JSON.stringify` has written
21
+ * U+D800-U+DFFF as `\uXXXX` since ES2019's well-formed `JSON.stringify`) is written `\uXXXX` --
22
+ * `JSON.stringify`'s own short escapes for tab/CR/backspace/form-feed are NOT used for those,
23
+ * and DEL/C1 (review finding C-05) would otherwise pass through `JSON.stringify` unescaped at
24
+ * all. */
25
+ function escapeChar(ch) {
26
+ if (ch === '"') return '\\"'
27
+ if (ch === '\\') return '\\\\'
28
+ if (ch === '\n') return '\\n'
29
+ const code = ch.codePointAt(0)
30
+ if (isControlCodePoint(code) || isLoneSurrogate(code)) {
31
+ return `\\u${code.toString(16).padStart(4, '0')}`
32
+ }
33
+ return ch
34
+ }
35
+
36
+ /** Prints `value` as a JSON string literal, one code point at a time through `escapeChar` (full
37
+ * control over which escapes it uses, D10). S-35: a non-string value is converted with
38
+ * `String()` first, so one escaping rule applies to every call site -- a number, boolean, etc.
39
+ * never carries a control character in this codebase's actual call sites, but an array or object
40
+ * holding one (e.g. a warning list entry) now escapes the same way a plain string does. */
41
+ export function jsonValue(value) {
42
+ const str = typeof value === 'string' ? value : String(value)
43
+ let out = '"'
44
+ for (const ch of str) out += escapeChar(ch)
45
+ return out + '"'
46
+ }
47
+
48
+ /** Candidate 22 (stage 7, security-T-0026): prints `value` through `JSON.stringify` -- unlike
49
+ * `jsonValue`, without wrapping the result in its own extra quotes, so an array or object prints
50
+ * as itself (`["3.1.0"]`, `{"toString":1}`), not through `String(value)` (which calls `toString`,
51
+ * throwing for a value whose own `toString` property is not callable, and collapsing an array
52
+ * like `["3.1.0"]` into the indistinguishable-from-a-real-version `"3.1.0"`). Any DEL/C1
53
+ * character or lone surrogate `JSON.stringify`'s own algorithm leaves unescaped (it already
54
+ * escapes every C0 character and lone surrogate itself, ES2019) is escaped the same way
55
+ * `escapeChar` would (D10). Returns `undefined` when `JSON.stringify` throws (a `BigInt`, a
56
+ * circular structure) or itself returns `undefined` (a bare `function` or `undefined` value) --
57
+ * the caller falls back to a type name in that case. */
58
+ const SHORT_ESCAPE_CODES = { b: '0008', f: '000c', r: '000d', t: '0009' }
59
+
60
+ export function jsonStringifyEscaped(value) {
61
+ let str
62
+ try {
63
+ str = JSON.stringify(value)
64
+ } catch {
65
+ return undefined
66
+ }
67
+ if (typeof str !== 'string') return undefined
68
+ // D10: JSON.stringify's own short escapes for backspace, form feed, CR and tab become `\uXXXX`
69
+ // (a line feed keeps `\n`); matching escape pairs left to right leaves `\\` followed by a
70
+ // letter untouched.
71
+ str = str.replace(/\\(.)/g, (pair, c) => (c in SHORT_ESCAPE_CODES ? `\\u${SHORT_ESCAPE_CODES[c]}` : pair))
72
+ let out = ''
73
+ for (const ch of str) {
74
+ const code = ch.codePointAt(0)
75
+ out += isControlCodePoint(code) || isLoneSurrogate(code) ? `\\u${code.toString(16).padStart(4, '0')}` : ch
76
+ }
77
+ return out
78
+ }
79
+
80
+ const BARE_RE = /^[A-Za-z0-9._\-/@%+=:,]+$/
81
+
82
+ export function posixQuoteWord(word) {
83
+ if (BARE_RE.test(word)) return word
84
+ return `'${word.replace(/'/g, "'\\''")}'`
85
+ }
86
+
87
+ export function posixCommand(words) {
88
+ return words.map(posixQuoteWord).join(' ')
89
+ }
90
+
91
+ /** A directory beginning with `-` gets the `./` prefix before it is quoted. */
92
+ export function formatDirectoryArg(directory) {
93
+ return directory.startsWith('-') ? `./${directory}` : directory
94
+ }
95
+
96
+ /** S-22: the one place every CW-E09 call site (fsops.mjs, pristine.mjs) tags a caught file-system
97
+ * error with the path `run.mjs` should report, without overwriting one an earlier catch already
98
+ * set (a write's own catch, re-thrown through another wrapper). */
99
+ export function tagReportPath(err, path) {
100
+ if (err && typeof err.reportPath !== 'string') err.reportPath = path
101
+ return err
102
+ }
103
+
104
+ /** traceability.md "Error and warning codes": "When a value to be placed in such a command
105
+ * contains a C0, DEL or C1 character, the command is not printed". True when any code point in
106
+ * `str` is C0 (U+0000-U+001F), DEL (U+007F) or C1 (U+0080-U+009F). */
107
+ export function hasControlChar(str) {
108
+ for (const ch of str) {
109
+ if (isControlCodePoint(ch.codePointAt(0))) return true
110
+ }
111
+ return false
112
+ }
113
+
114
+ /** `\` is Windows' own path separator; comparing after turning `\` into `/` (traceability.md
115
+ * "Step order" row 3, Vocabulary "name on disk") makes a path comparison platform-neutral. Shared
116
+ * by step 3 and P-9 since T-0062. */
117
+ export function toSlash(value) {
118
+ return String(value).replace(/\\/g, '/')
119
+ }
120
+
121
+ /** ASCII-only case folding (`[A-Z]` to lowercase), the way git's own `tolower` and J5's repository
122
+ * comparison fold -- never JavaScript's Unicode-aware lowercasing. Shared by J5 and entry rule 3
123
+ * since T-0062. */
124
+ export function foldAsciiCase(value) {
125
+ return value.replace(/[A-Z]/g, (c) => c.toLowerCase())
126
+ }
127
+