@orkestrel/scaffold 0.0.108 → 0.0.109

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.
Files changed (157) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/bench.js +326 -91
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +110 -14
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/chain.js +280 -0
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +188 -26
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +142 -43
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +240 -62
  7. package/dist/agents/skills/orkestrel-dispatch/scripts/patch.js +323 -0
  8. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +169 -32
  9. package/dist/agents/skills/orkestrel-dispatch/scripts/sweep.js +290 -69
  10. package/dist/agents/skills/orkestrel-falsify/scripts/mutate.js +560 -0
  11. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +1052 -205
  12. package/dist/agents/skills/orkestrel-harden/scripts/measure.js +361 -0
  13. package/dist/agents/skills/orkestrel-harden/scripts/titles.js +455 -80
  14. package/dist/agents/skills/orkestrel-parallel/scripts/cases.js +327 -0
  15. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +298 -51
  16. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +170 -13
  17. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +956 -191
  18. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +707 -112
  19. package/dist/agents/skills/orkestrel-publish/scripts/window.js +235 -41
  20. package/dist/agents/skills/orkestrel-scout/scripts/map.js +341 -54
  21. package/dist/agents/templates/brief.md +1 -1
  22. package/dist/bin/main.js +51 -46
  23. package/dist/bin/main.js.map +1 -1
  24. package/dist/host/AGENTS.md +23 -21
  25. package/dist/host/agents/orchestration.md +43 -18
  26. package/dist/host/agents/platforms/windows.md +3 -3
  27. package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
  28. package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
  29. package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
  30. package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
  31. package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
  32. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
  33. package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
  34. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
  35. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
  36. package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +4 -6
  37. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +31 -20
  38. package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +3 -4
  39. package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
  40. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
  41. package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
  42. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
  43. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
  44. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
  45. package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
  46. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
  47. package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +330 -69
  48. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
  49. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
  50. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
  51. package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
  52. package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
  53. package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
  54. package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
  55. package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
  56. package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
  57. package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
  58. package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
  59. package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
  60. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
  61. package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
  62. package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
  63. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
  64. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
  65. package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
  66. package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
  67. package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
  68. package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +28 -24
  69. package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +15 -13
  70. package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +1 -0
  71. package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +13 -13
  72. package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
  73. package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
  74. package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
  75. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
  76. package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
  77. package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
  78. package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
  79. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +10 -9
  80. package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
  81. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
  82. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
  83. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
  84. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
  85. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
  86. package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
  87. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
  88. package/dist/host/agents/templates/brief.md +1 -1
  89. package/dist/host/agents/transports/claude-cli.md +3 -3
  90. package/dist/host/agents/transports/codex.md +2 -7
  91. package/dist/host/agents/transports/cursor.md +2 -3
  92. package/dist/host/claude/AGENTS.md +2 -2
  93. package/dist/host/claude/agents/analyst.md +3 -3
  94. package/dist/host/claude/agents/astra.md +3 -3
  95. package/dist/host/claude/agents/builder.md +1 -1
  96. package/dist/host/claude/agents/checker.md +1 -1
  97. package/dist/host/claude/agents/distiller.md +1 -1
  98. package/dist/host/claude/agents/grok.md +1 -1
  99. package/dist/host/claude/agents/opus.md +2 -2
  100. package/dist/host/claude/agents/orkestrel.md +57 -90
  101. package/dist/host/claude/agents/researcher.md +1 -1
  102. package/dist/host/claude/agents/reviewer.md +4 -0
  103. package/dist/host/claude/agents/scout.md +1 -1
  104. package/dist/host/claude/agents/verifier.md +1 -1
  105. package/dist/host/claude/rules/application.md +14 -21
  106. package/dist/host/claude/rules/architecture.md +34 -44
  107. package/dist/host/claude/rules/browser.md +3 -4
  108. package/dist/host/claude/rules/campaign.md +2 -2
  109. package/dist/host/claude/rules/documentation.md +25 -12
  110. package/dist/host/claude/rules/names.md +9 -4
  111. package/dist/host/claude/rules/patterns.md +6 -7
  112. package/dist/host/claude/rules/portability.md +1 -1
  113. package/dist/host/claude/rules/quality.md +4 -5
  114. package/dist/host/claude/rules/styles.md +2 -2
  115. package/dist/host/claude/rules/tests.md +57 -114
  116. package/dist/host/claude/rules/typescript.md +7 -9
  117. package/dist/host/claude/rules/workspace.md +21 -19
  118. package/dist/host/claude/rules/writing.md +2 -5
  119. package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
  120. package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
  121. package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
  122. package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
  123. package/dist/host/codex/agents/analyst.toml +9 -3
  124. package/dist/host/codex/agents/astra.toml +3 -4
  125. package/dist/host/codex/agents/builder.toml +1 -1
  126. package/dist/host/codex/agents/opus.toml +3 -4
  127. package/dist/host/codex/agents/orkestrel.toml +1 -1
  128. package/dist/host/codex/agents/researcher.toml +2 -2
  129. package/dist/host/codex/agents/verifier.toml +1 -1
  130. package/dist/host/configs/helpers.ts +21 -1
  131. package/dist/host/configs/policy.ts +299 -15
  132. package/dist/host/dotfiles/oxlintrc.json +137 -3
  133. package/dist/host/guides/agent.md +111 -134
  134. package/dist/host/guides/capture.md +801 -0
  135. package/dist/host/guides/probe.md +27 -8
  136. package/dist/host/guides/scaffold.md +96 -18
  137. package/dist/host/guides/study.md +534 -440
  138. package/dist/host/guides/test.md +111 -44
  139. package/dist/host/manifest.json +160 -240
  140. package/dist/host/tests/config.test.ts +971 -187
  141. package/dist/host/tests/policy.test.ts +127 -1
  142. package/dist/host/tests/setupPolicy.ts +893 -33
  143. package/dist/src/core/index.cjs +214 -86
  144. package/dist/src/core/index.cjs.map +1 -1
  145. package/dist/src/core/index.d.cts +122 -38
  146. package/dist/src/core/index.d.ts +122 -38
  147. package/dist/src/core/index.js +209 -85
  148. package/dist/src/core/index.js.map +1 -1
  149. package/dist/src/server/index.cjs +20 -27
  150. package/dist/src/server/index.cjs.map +1 -1
  151. package/dist/src/server/index.js +21 -28
  152. package/dist/src/server/index.js.map +1 -1
  153. package/package.json +11 -11
  154. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
  155. package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
  156. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
  157. package/dist/host/guides/supervisor.md +0 -4890
@@ -18,8 +18,10 @@
18
18
  // the `note` a JSON verb prints on stdout, else the last stderr lines), the bump ruling, and a
19
19
  // Markdown row for `.orkestrel/release.md`. --dry-run prints the commands and runs nothing, so its
20
20
  // ruling is unanswered. --plan reads the marker-bounded catalog table in
21
- // `.claude/agents/orkestrel.md` and prints the packages by layer. Exit 0 when every step passed, 1
22
- // when a step failed, 2 when a reading failed, 64 on usage, including --plan --regenerate.
21
+ // `.claude/agents/orkestrel.md` and prints the packages by layer. The pins and compare steps run
22
+ // `pins.ts` and `compare.ts` as child commands. Exit 0 when every step passed, 1 when a step
23
+ // failed, 2 when a reading failed, 64 on usage, including --plan --regenerate, a flag the script
24
+ // does not read, and the `--flag=value` form.
23
25
  import type { SpawnSyncReturns } from 'node:child_process'
24
26
  import { spawnSync } from 'node:child_process'
25
27
  import { createHash } from 'node:crypto'
@@ -31,25 +33,15 @@ import {
31
33
  mkdirSync,
32
34
  realpathSync,
33
35
  renameSync,
36
+ rmSync,
34
37
  unlinkSync,
35
38
  writeFileSync,
36
39
  } from 'node:fs'
37
40
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
38
41
  import { fileURLToPath } from 'node:url'
39
- import {
40
- parseJsonRecord,
41
- isRecord,
42
- normalizeSlashes,
43
- removeTree,
44
- scanLocalSpecs,
45
- readJsonObject,
46
- readMissingFlags,
47
- readOption,
48
- readString,
49
- runNpm,
50
- } from '../../orkestrel-dispatch/scripts/helpers.ts'
51
-
52
- const STEPS: readonly string[] = [
42
+
43
+ /** Lists the steps of a visit in the order they run. */
44
+ export const STEPS: readonly string[] = [
53
45
  'pin',
54
46
  'commit',
55
47
  'overwrite',
@@ -61,27 +53,105 @@ const STEPS: readonly string[] = [
61
53
  'gates',
62
54
  'compare',
63
55
  ]
64
- const GATES: readonly string[] = ['format:check', 'lint:check', 'check', 'build', 'test']
65
- const RUNTIME_FIELDS: readonly string[] = ['dependencies', 'peerDependencies']
66
- const RANGE_FIELDS: readonly string[] = [...RUNTIME_FIELDS, 'devDependencies']
67
- const CATALOG = '.claude/agents/orkestrel.md'
68
- const OFFLINE_REFUSAL = "'catalog' does not take --offline"
69
- // The vendored host is staged from this package's checkout, so overwriting it from the host deletes
70
- // and replaces its own canon.
71
- const SCAFFOLD = '@orkestrel/scaffold'
72
- const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u
73
- // The checkout runs the `.ts` sources and a built twin under `dist/agents` runs `.js` siblings.
74
- const EXTENSION = extname(fileURLToPath(import.meta.url))
75
- const COMPARE = fileURLToPath(new URL(`./compare${EXTENSION}`, import.meta.url))
76
- const PINS = fileURLToPath(new URL(`./pins${EXTENSION}`, import.meta.url))
77
-
78
- interface Release {
56
+
57
+ /** Lists the package scripts the gates step runs, in order. */
58
+ export const GATES: readonly string[] = ['format:check', 'lint:check', 'check', 'build', 'test']
59
+
60
+ /** Lists the manifest fields whose ranges ship to a consumer. */
61
+ export const RUNTIME_FIELDS: readonly string[] = ['dependencies', 'peerDependencies']
62
+
63
+ /** Lists the manifest fields whose ranges a visit re-pins. */
64
+ export const RANGE_FIELDS: readonly string[] = [...RUNTIME_FIELDS, 'devDependencies']
65
+
66
+ /** Names the agent file that carries the catalog table. */
67
+ export const CATALOG = '.claude/agents/orkestrel.md'
68
+
69
+ /** Holds the text an offline overwrite refusal names. */
70
+ export const OFFLINE_REFUSAL = "'catalog' does not take --offline"
71
+
72
+ /**
73
+ * Names the package whose checkout the vendored host is staged from.
74
+ *
75
+ * @remarks
76
+ * The vendored host is staged from this package's checkout, so overwriting it from the host deletes
77
+ * and replaces its own canon.
78
+ */
79
+ export const SCAFFOLD = '@orkestrel/scaffold'
80
+
81
+ /** Matches a plain version, optionally with a prerelease. */
82
+ export const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u
83
+
84
+ /**
85
+ * Names the extension of the running file.
86
+ *
87
+ * @remarks
88
+ * The checkout runs the `.ts` sources and a built twin under `dist/agents` runs `.js` siblings.
89
+ */
90
+ export const EXTENSION = extname(fileURLToPath(import.meta.url))
91
+
92
+ /** Holds the path of the sibling `compare` script. */
93
+ export const COMPARE = fileURLToPath(new URL(`./compare${EXTENSION}`, import.meta.url))
94
+
95
+ /** Holds the path of the sibling `pins` script. */
96
+ export const PINS = fileURLToPath(new URL(`./pins${EXTENSION}`, import.meta.url))
97
+
98
+ /**
99
+ * Matches one entry of `git status --porcelain=v1 -z` output.
100
+ *
101
+ * @remarks
102
+ * A rename or copy entry carries its original path as a second NUL-terminated field. The first
103
+ * group holds a rename's new path, the second its original, and the third any other entry's path.
104
+ */
105
+ export const DIRTY_ENTRY =
106
+ /(?=[RC][^\0]|[^\0][RC])[^\0]{2} ([^\0]*)\0([^\0]*)(?:\0|$)|[^\0]{2} ([^\0]*)(?:\0|$)/gu
107
+
108
+ /** Lists every flag the script reads, value flags and switches alike. */
109
+ export const FLAGS: readonly string[] = [
110
+ '--visit',
111
+ '--plan',
112
+ '--target',
113
+ '--offline',
114
+ '--regenerate',
115
+ '--from',
116
+ '--to',
117
+ '--prior',
118
+ '--dry-run',
119
+ '--json',
120
+ '--out',
121
+ '--catalog',
122
+ ]
123
+
124
+ /** Holds the usage line the script prints when no mode was given. */
125
+ export const USAGE =
126
+ 'usage: wave.ts --visit [--target DIR] [--offline] [--regenerate] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE] | --plan [--catalog PATH] [--json]'
127
+
128
+ /** Names the manifest fields that hold specs, including nested overrides. */
129
+ export const SPEC_FIELDS: readonly string[] = Object.freeze([
130
+ 'dependencies',
131
+ 'devDependencies',
132
+ 'peerDependencies',
133
+ 'optionalDependencies',
134
+ 'overrides',
135
+ ])
136
+
137
+ /** Matches npm local prefixes and path starts, including drive-relative paths. */
138
+ export const LOCAL_SPEC_PATTERN = /^(?:file:|link:|\.|~[\\/]|[\\/]|[A-Za-z]:)/iu
139
+
140
+ /** Matches archive specs with no protocol and a tgz, tar.gz, or tar extension. */
141
+ export const ARCHIVE_SPEC_PATTERN = /^[^:]+\.(?:tgz|tar\.gz|tar)$/iu
142
+
143
+ /** Matches hosted user/repository shorthand even when its name ends in an archive extension. */
144
+ export const SHORTHAND_SPEC_PATTERN = /^[^:@%/\s.-][^:@%/\s]*\/[^:@\s/%]+$/u
145
+
146
+ /** Describes one dependency the audit read a release for. */
147
+ export interface Release {
79
148
  readonly name: string
80
149
  readonly range: string
81
150
  readonly latest: string | undefined
82
151
  }
83
152
 
84
- interface StepResult {
153
+ /** Describes one row of a visit's log. */
154
+ export interface StepResult {
85
155
  readonly step: string
86
156
  readonly command: string
87
157
  readonly exit: number | undefined
@@ -91,25 +161,29 @@ interface StepResult {
91
161
  readonly superseded?: true
92
162
  }
93
163
 
94
- interface Verdict {
164
+ /** Describes the exit and note a judge reads from a finished command. */
165
+ export interface Verdict {
95
166
  readonly exit: number | undefined
96
167
  readonly note: string | undefined
97
168
  readonly superseded?: true
98
169
  }
99
170
 
100
- interface RangeMove {
171
+ /** Describes the ranges after a re-pin and the moves that made them. */
172
+ export interface RangeMove {
101
173
  readonly ranges: Record<string, unknown>
102
174
  readonly moved: readonly string[]
103
175
  }
104
176
 
105
- interface Ruling {
177
+ /** Describes the bump ruling a visit reaches. */
178
+ export interface Ruling {
106
179
  readonly distMoved: boolean | undefined
107
180
  readonly rangesMoved: boolean | undefined
108
181
  readonly ranges: readonly string[] | undefined
109
182
  readonly hits: number | undefined
110
183
  }
111
184
 
112
- interface Visit {
185
+ /** Describes a finished visit. */
186
+ export interface Visit {
113
187
  readonly target: string
114
188
  readonly name: string | undefined
115
189
  readonly version: string | undefined
@@ -118,7 +192,8 @@ interface Visit {
118
192
  readonly row: string
119
193
  }
120
194
 
121
- interface Runner {
195
+ /** Describes the state one visit runs with. */
196
+ export interface Runner {
122
197
  readonly target: string
123
198
  readonly dry: boolean
124
199
  readonly offline: boolean
@@ -127,7 +202,8 @@ interface Runner {
127
202
  readonly steps: StepResult[]
128
203
  }
129
204
 
130
- interface OverwriteReceipt {
205
+ /** Describes the receipt the overwrite step leaves for the record step. */
206
+ export interface OverwriteReceipt {
131
207
  readonly head: string
132
208
  readonly version: string
133
209
  readonly offline: boolean
@@ -135,12 +211,14 @@ interface OverwriteReceipt {
135
211
  readonly removed: readonly string[]
136
212
  }
137
213
 
138
- interface Journal {
214
+ /** Describes the outcome of writing a journal file. */
215
+ export interface Journal {
139
216
  readonly path?: string
140
217
  readonly failure?: string
141
218
  }
142
219
 
143
- interface Regeneration {
220
+ /** Describes the outcome of regenerating a lock and its tree. */
221
+ export interface Regeneration {
144
222
  readonly exit: number
145
223
  readonly note: string
146
224
  readonly installed?: SpawnSyncReturns<string>
@@ -148,7 +226,233 @@ interface Regeneration {
148
226
  readonly duration?: number
149
227
  }
150
228
 
151
- function readManifest(target: string): Record<string, unknown> | undefined {
229
+ /** Describes how to spawn npm on this host without a shell where the CLI entry is present. */
230
+ export interface NpmCommand {
231
+ readonly file: string
232
+ readonly prefix: readonly string[]
233
+ readonly shell: boolean
234
+ }
235
+
236
+ /**
237
+ * Checks whether a value is a non-null, non-array object.
238
+ *
239
+ * @param value - The value to inspect.
240
+ * @returns True if the value is an object other than null or an array; false otherwise.
241
+ */
242
+ export function isRecord(value: unknown): value is Record<string, unknown> {
243
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
244
+ }
245
+
246
+ /**
247
+ * Checks whether npm resolves a spec from the local host.
248
+ *
249
+ * @param spec - The package spec, with leading whitespace ignored.
250
+ * @returns True if the spec names a local path or archive; false otherwise.
251
+ */
252
+ export function matchesLocalSpec(spec: string): boolean {
253
+ const text = spec.trimStart()
254
+ return (
255
+ LOCAL_SPEC_PATTERN.test(text) ||
256
+ (ARCHIVE_SPEC_PATTERN.test(text) && !SHORTHAND_SPEC_PATTERN.test(text))
257
+ )
258
+ }
259
+
260
+ /**
261
+ * Collects string specs with the dotted path of each nested record member.
262
+ *
263
+ * @param path - The field path that prefixes each finding.
264
+ * @param value - A string or finite, acyclic manifest record; other values contribute no specs.
265
+ * @returns The path and spec pairs in record order.
266
+ */
267
+ export function collectSpecs(
268
+ path: string,
269
+ value: unknown,
270
+ ): ReadonlyArray<readonly [string, string]> {
271
+ if (typeof value === 'string') return [[path, value]]
272
+ if (!isRecord(value)) return []
273
+ return Object.entries(value).flatMap(([key, child]) => collectSpecs(`${path}.${key}`, child))
274
+ }
275
+
276
+ /**
277
+ * Finds local specs in the manifest's spec fields, including nested overrides.
278
+ *
279
+ * @param manifest - A finite, acyclic package manifest record.
280
+ * @returns Each local spec's dotted field path and spec, in field and record order.
281
+ */
282
+ export function scanLocalSpecs(
283
+ manifest: Readonly<Record<string, unknown>>,
284
+ ): ReadonlyArray<readonly [string, string]> {
285
+ return SPEC_FIELDS.flatMap((field) =>
286
+ collectSpecs(field, manifest[field]).filter(([, spec]) => matchesLocalSpec(spec)),
287
+ )
288
+ }
289
+
290
+ /**
291
+ * Removes a tree with bounded retries for transient host removal errors.
292
+ *
293
+ * @param path - The directory to remove; a missing path succeeds.
294
+ * @returns Nothing after the removal succeeds.
295
+ * @throws Thrown when removal fails after the bounded retries or the host refuses the path.
296
+ */
297
+ export function removeTree(path: string): void {
298
+ rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
299
+ }
300
+
301
+ /**
302
+ * Normalizes a host path to forward slashes.
303
+ *
304
+ * @param path - A host path.
305
+ * @returns The same path with every backslash replaced by a slash.
306
+ */
307
+ export function normalizeSlashes(path: string): string {
308
+ return path.split('\\').join('/')
309
+ }
310
+
311
+ /**
312
+ * Reads the value that follows a flag.
313
+ *
314
+ * @remarks
315
+ * Reads `--flag value` pairs: the first occurrence of a flag wins, a following value that opens
316
+ * with `--` is a missing value, and the `--flag=value` form is not read.
317
+ *
318
+ * @param argv - The arguments after the script path.
319
+ * @param name - The flag, such as `--target`.
320
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
321
+ * another flag.
322
+ */
323
+ export function readOption(argv: readonly string[], name: string): string | undefined {
324
+ const index = argv.indexOf(name)
325
+ const value = index === -1 ? undefined : argv[index + 1]
326
+ return value === undefined || value.startsWith('--') ? undefined : value
327
+ }
328
+
329
+ /**
330
+ * Names the value flags a caller gave with no value.
331
+ *
332
+ * @param argv - The arguments after the script path.
333
+ * @param names - The flags that take a value.
334
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
335
+ */
336
+ export function readMissingFlags(
337
+ argv: readonly string[],
338
+ names: readonly string[],
339
+ ): readonly string[] {
340
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
341
+ }
342
+
343
+ /**
344
+ * Names the flags a caller gave that the script does not read.
345
+ *
346
+ * @remarks
347
+ * Reads the arguments before a bare `--`, which opens a command a script passes on. A `--flag=value`
348
+ * argument is unknown, because the script reads no such form.
349
+ *
350
+ * @param argv - The arguments after the script path.
351
+ * @param flags - Every flag the script reads, value flags and switches alike.
352
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
353
+ */
354
+ export function readUnknownFlags(
355
+ argv: readonly string[],
356
+ flags: readonly string[],
357
+ ): readonly string[] {
358
+ const end = argv.indexOf('--')
359
+ return (end === -1 ? argv : argv.slice(0, end)).filter(
360
+ (argument) => argument.startsWith('--') && !flags.includes(argument),
361
+ )
362
+ }
363
+
364
+ /**
365
+ * Reads the last non-empty line of a text as a JSON object.
366
+ *
367
+ * @param text - Text whose last line a script printed as JSON.
368
+ * @returns The parsed object, or undefined when the last line is not a JSON object.
369
+ */
370
+ export function parseJsonLine(text: string): Record<string, unknown> | undefined {
371
+ const line = text
372
+ .split(/\r\n|\n/)
373
+ .filter((candidate) => candidate.trim() !== '')
374
+ .at(-1)
375
+ if (line === undefined) return undefined
376
+ try {
377
+ const parsed: unknown = JSON.parse(line)
378
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
379
+ return Object.fromEntries(Object.entries(parsed))
380
+ }
381
+ } catch {}
382
+ return undefined
383
+ }
384
+
385
+ /**
386
+ * Parses a whole text as one JSON object.
387
+ *
388
+ * @param text - A JSON document, formatted or not, such as a manifest or `npm view --json` output.
389
+ * @returns The parsed object, or undefined when the text is not a JSON object.
390
+ */
391
+ export function parseJsonRecord(text: string): Record<string, unknown> | undefined {
392
+ try {
393
+ const parsed: unknown = JSON.parse(text)
394
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
395
+ return Object.fromEntries(Object.entries(parsed))
396
+ }
397
+ } catch {}
398
+ return undefined
399
+ }
400
+
401
+ /**
402
+ * Reads a string member of a record.
403
+ *
404
+ * @param record - The record, or undefined.
405
+ * @param key - The member name.
406
+ * @returns The string, or undefined when the member is absent or not a string.
407
+ */
408
+ export function readString(
409
+ record: Record<string, unknown> | undefined,
410
+ key: string,
411
+ ): string | undefined {
412
+ const value = record?.[key]
413
+ return typeof value === 'string' ? value : undefined
414
+ }
415
+
416
+ /**
417
+ * Resolves the npm command for this host.
418
+ *
419
+ * @remarks
420
+ * The `npm-cli.js` entry beside the running Node binary spawns without a shell on every host; the
421
+ * `npm` shim on Windows is a `.cmd` file that needs one, so that path is the fallback alone.
422
+ *
423
+ * @returns The file to spawn, the arguments that precede npm's own, and whether a shell is needed.
424
+ */
425
+ export function resolveNpm(): NpmCommand {
426
+ const cli = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
427
+ if (existsSync(cli)) return { file: process.execPath, prefix: [cli], shell: false }
428
+ return { file: 'npm', prefix: [], shell: process.platform === 'win32' }
429
+ }
430
+
431
+ /**
432
+ * Runs npm with the given arguments and returns the finished child.
433
+ *
434
+ * @param args - The npm arguments, such as `['install']`.
435
+ * @param cwd - The working directory. Default: the current directory.
436
+ * @returns The spawn result with `utf8` stdout and stderr.
437
+ */
438
+ export function runNpm(args: readonly string[], cwd?: string): SpawnSyncReturns<string> {
439
+ const npm = resolveNpm()
440
+ return spawnSync(npm.file, [...npm.prefix, ...args], {
441
+ encoding: 'utf8',
442
+ windowsHide: true,
443
+ shell: npm.shell,
444
+ maxBuffer: 64 * 1024 * 1024,
445
+ ...(cwd === undefined ? {} : { cwd }),
446
+ })
447
+ }
448
+
449
+ /**
450
+ * Reads a target's package manifest.
451
+ *
452
+ * @param target - The target directory.
453
+ * @returns The manifest record, or undefined when it is absent, unreadable, or not a JSON object.
454
+ */
455
+ export function readManifest(target: string): Record<string, unknown> | undefined {
152
456
  try {
153
457
  return parseJsonRecord(readFileSync(join(target, 'package.json'), 'utf8'))
154
458
  } catch {
@@ -156,7 +460,14 @@ function readManifest(target: string): Record<string, unknown> | undefined {
156
460
  }
157
461
  }
158
462
 
159
- function readRanges(
463
+ /**
464
+ * Reads the ranges a manifest declares in the named fields.
465
+ *
466
+ * @param manifest - The manifest record.
467
+ * @param fields - The fields to read.
468
+ * @returns Each string range keyed by `field name`.
469
+ */
470
+ export function readRanges(
160
471
  manifest: Record<string, unknown>,
161
472
  fields: readonly string[],
162
473
  ): ReadonlyMap<string, string> {
@@ -171,7 +482,14 @@ function readRanges(
171
482
  )
172
483
  }
173
484
 
174
- function compareRanges(
485
+ /**
486
+ * Lists how the final ranges differ from the published ones.
487
+ *
488
+ * @param published - The ranges the published manifest declares.
489
+ * @param final - The ranges the target declares now.
490
+ * @returns One message per range added, removed, or changed, sorted by key.
491
+ */
492
+ export function compareRanges(
175
493
  published: ReadonlyMap<string, string>,
176
494
  final: ReadonlyMap<string, string>,
177
495
  ): readonly string[] {
@@ -186,7 +504,14 @@ function compareRanges(
186
504
  })
187
505
  }
188
506
 
189
- function resolveScaffold(target: string): readonly string[] {
507
+ /**
508
+ * Resolves the scaffold executable a target runs.
509
+ *
510
+ * @param target - The target directory.
511
+ * @returns Node and the installed scaffold entry, else Node and the target's own built entry, else
512
+ * none.
513
+ */
514
+ export function resolveScaffold(target: string): readonly string[] {
190
515
  const installed = join(target, 'node_modules', '@orkestrel', 'scaffold', 'dist', 'bin', 'main.js')
191
516
  if (existsSync(installed)) return [process.execPath, installed]
192
517
  const own = join(target, 'dist', 'bin', 'main.js')
@@ -194,8 +519,15 @@ function resolveScaffold(target: string): readonly string[] {
194
519
  return []
195
520
  }
196
521
 
197
- function readReleases(text: string): readonly Release[] | undefined {
198
- const releases = readJsonObject(text)?.releases
522
+ /**
523
+ * Reads the release readings out of an audit's standard output.
524
+ *
525
+ * @param text - The audit's standard output, whose last line is the JSON envelope.
526
+ * @returns One release per reading that names a dependency and its range, or undefined when the
527
+ * envelope carries no `releases` list.
528
+ */
529
+ export function readReleases(text: string): readonly Release[] | undefined {
530
+ const releases = parseJsonLine(text)?.releases
199
531
  if (!Array.isArray(releases)) return undefined
200
532
  return releases.flatMap((entry) => {
201
533
  if (typeof entry !== 'object' || entry === null) return []
@@ -207,15 +539,30 @@ function readReleases(text: string): readonly Release[] | undefined {
207
539
  })
208
540
  }
209
541
 
210
- function describeCommand(file: string, args: readonly string[]): string {
542
+ /**
543
+ * Renders a command as the line a step row carries.
544
+ *
545
+ * @param file - The executable.
546
+ * @param args - Its arguments.
547
+ * @returns The command line, naming the running Node binary as `node`.
548
+ */
549
+ export function renderCommand(file: string, args: readonly string[]): string {
211
550
  return [file === process.execPath ? 'node' : file, ...args].join(' ')
212
551
  }
213
552
 
214
- // A JSON verb writes its refusal envelope or its partial-run note on stdout, so the stderr tail
215
- // alone reads empty for exactly the failures the operator needs named.
216
- function describeFailure(result: SpawnSyncReturns<string>): string | undefined {
553
+ /**
554
+ * Names why a finished command failed.
555
+ *
556
+ * @remarks
557
+ * A JSON verb writes its refusal envelope or its partial-run note on stdout, so the stderr tail
558
+ * alone reads empty for exactly the failures the operator needs named.
559
+ *
560
+ * @param result - The finished command.
561
+ * @returns Nothing for exit 0; else the `code: message` envelope, the `note`, or the last stderr lines.
562
+ */
563
+ export function describeFailure(result: SpawnSyncReturns<string>): string | undefined {
217
564
  if (result.status === 0) return undefined
218
- const value = readJsonObject(result.stdout)
565
+ const value = parseJsonLine(result.stdout)
219
566
  const error = value?.error
220
567
  if (typeof error === 'object' && error !== null && !Array.isArray(error)) {
221
568
  const envelope = Object.fromEntries(Object.entries(error))
@@ -232,13 +579,33 @@ function describeFailure(result: SpawnSyncReturns<string>): string | undefined {
232
579
  .join(' | ')
233
580
  }
234
581
 
235
- function readVerdict(result: SpawnSyncReturns<string>): Verdict {
582
+ /**
583
+ * Reads the verdict of a finished command by its exit alone.
584
+ *
585
+ * @param result - The finished command.
586
+ * @returns The exit and the failure note.
587
+ */
588
+ export function readVerdict(result: SpawnSyncReturns<string>): Verdict {
236
589
  return { exit: result.status ?? undefined, note: describeFailure(result) }
237
590
  }
238
591
 
239
- // A judge reads the finished command and answers the exit and note its one log entry carries, so no
240
- // entry is amended after it is appended.
241
- function runStep(
592
+ /**
593
+ * Runs a command as one step of the visit and logs its row.
594
+ *
595
+ * @remarks
596
+ * A judge reads the finished command and answers the exit and note its one log entry carries, so no
597
+ * entry is amended after it is appended.
598
+ *
599
+ * @param runner - The visit's state.
600
+ * @param step - The step's name.
601
+ * @param file - The executable.
602
+ * @param args - Its arguments.
603
+ * @param cwd - The working directory.
604
+ * @param judge - Reads the exit and note from the result. Default: {@link readVerdict}.
605
+ * @param input - Standard input for the command. Default: none.
606
+ * @returns The finished command, or undefined under --dry-run.
607
+ */
608
+ export function runStep(
242
609
  runner: Runner,
243
610
  step: string,
244
611
  file: string,
@@ -247,7 +614,7 @@ function runStep(
247
614
  judge: (result: SpawnSyncReturns<string>) => Verdict = readVerdict,
248
615
  input?: string,
249
616
  ): SpawnSyncReturns<string> | undefined {
250
- const command = describeCommand(file, args)
617
+ const command = renderCommand(file, args)
251
618
  if (runner.dry) {
252
619
  runner.steps.push({ step, command, exit: undefined, durationMs: 0, note: 'dry run' })
253
620
  return undefined
@@ -266,8 +633,17 @@ function runStep(
266
633
  return result
267
634
  }
268
635
 
269
- // The audit exits 1 on the drift the pin step exists to close, so its exit is a reading here.
270
- function judgePin(result: SpawnSyncReturns<string>): Verdict {
636
+ /**
637
+ * Judges the pin step's audit.
638
+ *
639
+ * @remarks
640
+ * The audit exits 1 on the drift the pin step exists to close, so its exit is a reading here.
641
+ *
642
+ * @param result - The audit.
643
+ * @returns Exit 0 with the reading count when the audit printed releases and exited 0 or 1; else a
644
+ * failing exit with the reason.
645
+ */
646
+ export function judgePin(result: SpawnSyncReturns<string>): Verdict {
271
647
  const releases = readReleases(result.stdout)
272
648
  if (releases === undefined || (result.status !== 0 && result.status !== 1)) {
273
649
  return {
@@ -279,16 +655,34 @@ function judgePin(result: SpawnSyncReturns<string>): Verdict {
279
655
  return { exit: 0, note: `${releases.length} release reading(s)` }
280
656
  }
281
657
 
282
- function judgeOverwrite(result: SpawnSyncReturns<string>, offline: boolean): Verdict {
658
+ /**
659
+ * Judges the overwrite command.
660
+ *
661
+ * @param result - The overwrite.
662
+ * @param offline - If `true`, an exit of 1 whose note names the catalog refusal passes; if `false`,
663
+ * the exit alone decides.
664
+ * @returns The verdict.
665
+ */
666
+ export function judgeOverwrite(result: SpawnSyncReturns<string>, offline: boolean): Verdict {
283
667
  if (!offline || result.status !== 1) return readVerdict(result)
284
- const note = readString(readJsonObject(result.stdout), 'note')
668
+ const note = readString(parseJsonLine(result.stdout), 'note')
285
669
  return note !== undefined && note.includes(OFFLINE_REFUSAL)
286
670
  ? { exit: 0, note: 'offline overwrite skipped the catalog step by design' }
287
671
  : { exit: 1, note: describeFailure(result) }
288
672
  }
289
673
 
290
- // The audit reads the canonical sources of scaffold's own checkout as foreign paths and exits 1.
291
- function judgeVerify(result: SpawnSyncReturns<string>, own: boolean): Verdict {
674
+ /**
675
+ * Judges the verify step's audit.
676
+ *
677
+ * @remarks
678
+ * The audit reads the canonical sources of scaffold's own checkout as foreign paths and exits 1.
679
+ *
680
+ * @param result - The audit.
681
+ * @param own - If `true`, the target is scaffold's own checkout and an exit of 1 passes; if `false`,
682
+ * the exit alone decides.
683
+ * @returns The verdict; an audit that printed no releases envelope fails.
684
+ */
685
+ export function judgeVerify(result: SpawnSyncReturns<string>, own: boolean): Verdict {
292
686
  if (readReleases(result.stdout) === undefined) {
293
687
  return {
294
688
  exit: result.status === null || result.status === 0 ? 2 : result.status,
@@ -300,7 +694,16 @@ function judgeVerify(result: SpawnSyncReturns<string>, own: boolean): Verdict {
300
694
  : readVerdict(result)
301
695
  }
302
696
 
303
- function runNpmStep(
697
+ /**
698
+ * Runs npm as one step of the visit and logs its row.
699
+ *
700
+ * @param runner - The visit's state.
701
+ * @param step - The step's name.
702
+ * @param args - The npm arguments.
703
+ * @param judge - Reads the exit and note from the result. Default: {@link readVerdict}.
704
+ * @returns The finished command, or undefined under --dry-run.
705
+ */
706
+ export function runNpmStep(
304
707
  runner: Runner,
305
708
  step: string,
306
709
  args: readonly string[],
@@ -326,7 +729,19 @@ function runNpmStep(
326
729
  return result
327
730
  }
328
731
 
329
- function writeJournal(
732
+ /**
733
+ * Writes one step's full output to the visit's journal directory.
734
+ *
735
+ * @param runner - The visit's state.
736
+ * @param step - The step's name.
737
+ * @param command - The command line.
738
+ * @param exit - The exit code.
739
+ * @param stdout - The standard output.
740
+ * @param stderr - The standard error.
741
+ * @returns The journal path, the failure that stopped the write, or neither when the visit keeps no
742
+ * journal.
743
+ */
744
+ export function writeJournal(
330
745
  runner: Runner,
331
746
  step: string,
332
747
  command: string,
@@ -353,7 +768,18 @@ function writeJournal(
353
768
  }
354
769
  }
355
770
 
356
- function appendResult(
771
+ /**
772
+ * Appends a finished command's row to the visit's log.
773
+ *
774
+ * @param runner - The visit's state.
775
+ * @param step - The step's name.
776
+ * @param command - The command line.
777
+ * @param result - The finished command.
778
+ * @param durationMs - How long it ran.
779
+ * @param verdict - The exit and note to record; a journal failure turns the exit into 1.
780
+ * @returns Nothing after the row is appended.
781
+ */
782
+ export function appendResult(
357
783
  runner: Runner,
358
784
  step: string,
359
785
  command: string,
@@ -383,7 +809,23 @@ function appendResult(
383
809
  })
384
810
  }
385
811
 
386
- function appendStep(runner: Runner, step: string, command: string, note: string, exit = 0): void {
812
+ /**
813
+ * Appends a row for a step the script ran itself.
814
+ *
815
+ * @param runner - The visit's state.
816
+ * @param step - The step's name.
817
+ * @param command - The label the row carries as its command.
818
+ * @param note - What the step found or did.
819
+ * @param exit - The exit the row records. Default: 0.
820
+ * @returns Nothing after the row is appended.
821
+ */
822
+ export function appendStep(
823
+ runner: Runner,
824
+ step: string,
825
+ command: string,
826
+ note: string,
827
+ exit = 0,
828
+ ): void {
387
829
  const journal = runner.dry ? {} : writeJournal(runner, step, command, exit, note, '')
388
830
  runner.steps.push({
389
831
  step,
@@ -395,8 +837,19 @@ function appendStep(runner: Runner, step: string, command: string, note: string,
395
837
  })
396
838
  }
397
839
 
398
- function failed(runner: Runner): boolean {
399
- return runner.steps.some(
840
+ /**
841
+ * Checks whether a visit's log holds a failure.
842
+ *
843
+ * @remarks
844
+ * The one predicate the visit's steps and the script's exit code share. A superseded row, a row with
845
+ * no exit (a dry run), and a pins or compare row are readings, not failures.
846
+ *
847
+ * @param steps - The rows logged so far.
848
+ * @returns True if a row exited nonzero, was not superseded, and is not a pins or compare row; false
849
+ * otherwise.
850
+ */
851
+ export function failed(steps: readonly StepResult[]): boolean {
852
+ return steps.some(
400
853
  (entry) =>
401
854
  entry.superseded !== true &&
402
855
  entry.exit !== undefined &&
@@ -406,7 +859,14 @@ function failed(runner: Runner): boolean {
406
859
  )
407
860
  }
408
861
 
409
- function extractConflict(result: SpawnSyncReturns<string>): string | undefined {
862
+ /**
863
+ * Reads the resolution conflict an install reported.
864
+ *
865
+ * @param result - The finished install.
866
+ * @returns The conflict line npm printed, a generic reason when it printed none, or undefined when
867
+ * the install did not fail with ERESOLVE.
868
+ */
869
+ export function extractConflict(result: SpawnSyncReturns<string>): string | undefined {
410
870
  const output = `${result.stdout}\n${result.stderr}`
411
871
  if (!/\bcode ERESOLVE\b/u.test(output)) return undefined
412
872
  const lines = output.split(/\r\n|\n/)
@@ -418,7 +878,14 @@ function extractConflict(result: SpawnSyncReturns<string>): string | undefined {
418
878
  )
419
879
  }
420
880
 
421
- function runInstall(runner: Runner, step: string): void {
881
+ /**
882
+ * Runs the install and regenerates the lock when asked and the install cannot resolve.
883
+ *
884
+ * @param runner - The visit's state.
885
+ * @param step - The step the rows belong to.
886
+ * @returns Nothing after the rows are appended.
887
+ */
888
+ export function runInstall(runner: Runner, step: string): void {
422
889
  // npm can unlink a node_modules junction before reporting its resolution failure.
423
890
  if (
424
891
  !runner.dry &&
@@ -454,7 +921,14 @@ function runInstall(runner: Runner, step: string): void {
454
921
  regenerateLock(runner, step, installed)
455
922
  }
456
923
 
457
- function refuseLocalSpecs(runner: Runner, step: string): boolean {
924
+ /**
925
+ * Refuses a manifest that holds local dependency specs.
926
+ *
927
+ * @param runner - The visit's state.
928
+ * @param step - The step that refuses.
929
+ * @returns True if a failing row was appended; false when the manifest holds none.
930
+ */
931
+ export function refuseLocalSpecs(runner: Runner, step: string): boolean {
458
932
  const specs = scanLocalSpecs(readManifest(runner.target) ?? {})
459
933
  if (specs.length === 0) return false
460
934
  appendStep(
@@ -467,7 +941,14 @@ function refuseLocalSpecs(runner: Runner, step: string): boolean {
467
941
  return true
468
942
  }
469
943
 
470
- function readRegenerationRefusal(runner: Runner): string | undefined {
944
+ /**
945
+ * Names why the lock must not be regenerated.
946
+ *
947
+ * @param runner - The visit's state.
948
+ * @returns The first refusal, or undefined when the lock is tracked and clean, the manifest holds no
949
+ * local spec or workspace, `tmp/` is ignored, and the target is its work tree's root.
950
+ */
951
+ export function readRegenerationRefusal(runner: Runner): string | undefined {
471
952
  if (
472
953
  runStep(
473
954
  runner,
@@ -511,7 +992,16 @@ function readRegenerationRefusal(runner: Runner): string | undefined {
511
992
  return undefined
512
993
  }
513
994
 
514
- function moveModules(source: string, target: string, attempt = 0): void {
995
+ /**
996
+ * Renames a directory, retrying while the host holds it.
997
+ *
998
+ * @param source - The directory to move.
999
+ * @param target - Its new path.
1000
+ * @param attempt - The retries spent so far. Default: 0.
1001
+ * @returns Nothing after the rename.
1002
+ * @throws Thrown when the rename fails for another reason or after the retries.
1003
+ */
1004
+ export function moveModules(source: string, target: string, attempt = 0): void {
515
1005
  try {
516
1006
  renameSync(source, target)
517
1007
  } catch (error) {
@@ -522,7 +1012,19 @@ function moveModules(source: string, target: string, attempt = 0): void {
522
1012
  }
523
1013
  }
524
1014
 
525
- function regenerateLock(runner: Runner, step: string, conflict: SpawnSyncReturns<string>): void {
1015
+ /**
1016
+ * Regenerates the lock and the installed tree from the manifest.
1017
+ *
1018
+ * @param runner - The visit's state.
1019
+ * @param step - The step the rows belong to.
1020
+ * @param conflict - The install that failed with ERESOLVE.
1021
+ * @returns Nothing after the rows are appended; a failed regeneration restores the tree and lock.
1022
+ */
1023
+ export function regenerateLock(
1024
+ runner: Runner,
1025
+ step: string,
1026
+ conflict: SpawnSyncReturns<string>,
1027
+ ): void {
526
1028
  const refusal = readRegenerationRefusal(runner)
527
1029
  if (refusal !== undefined || runner.directory === undefined) {
528
1030
  appendStep(
@@ -599,8 +1101,18 @@ function regenerateLock(runner: Runner, step: string, conflict: SpawnSyncReturns
599
1101
  appendResult(runner, step, 'npm ls --all --json', result.listed, 0, readVerdict(result.listed))
600
1102
  }
601
1103
 
602
- // Journal writes and backup disposal cannot enter the transaction's rollback boundary.
603
- function regenerateTree(target: string, manifest: Buffer, before: string): Regeneration {
1104
+ /**
1105
+ * Installs from a manifest with no lock and proves the tree.
1106
+ *
1107
+ * @remarks
1108
+ * Journal writes and backup disposal cannot enter the transaction's rollback boundary.
1109
+ *
1110
+ * @param target - The target directory.
1111
+ * @param manifest - The manifest bytes before the install.
1112
+ * @param before - The SHA-256 of the lock before.
1113
+ * @returns Exit 0 when the install, the tree listing, and the unchanged manifest prove it; else exit 1.
1114
+ */
1115
+ export function regenerateTree(target: string, manifest: Buffer, before: string): Regeneration {
604
1116
  const lock = join(target, 'package-lock.json')
605
1117
  try {
606
1118
  unlinkSync(lock)
@@ -625,7 +1137,15 @@ function regenerateTree(target: string, manifest: Buffer, before: string): Regen
625
1137
  }
626
1138
  }
627
1139
 
628
- function discardBackup(target: string, moved: string, backup: string): string | undefined {
1140
+ /**
1141
+ * Discards the backups after a proved regeneration.
1142
+ *
1143
+ * @param target - The target directory.
1144
+ * @param moved - The moved previous tree.
1145
+ * @param backup - The saved lock.
1146
+ * @returns Nothing on success, else the failure with the backup paths kept.
1147
+ */
1148
+ export function discardBackup(target: string, moved: string, backup: string): string | undefined {
629
1149
  try {
630
1150
  for (const path of [moved, backup, join(target, 'node_modules')]) validateWavePath(target, path)
631
1151
  removeTree(moved)
@@ -635,7 +1155,16 @@ function discardBackup(target: string, moved: string, backup: string): string |
635
1155
  }
636
1156
  }
637
1157
 
638
- function restoreLock(target: string, moved: string, backup: string, before: string): string {
1158
+ /**
1159
+ * Restores the previous tree and lock after a failed regeneration.
1160
+ *
1161
+ * @param target - The target directory.
1162
+ * @param moved - The moved previous tree.
1163
+ * @param backup - The saved lock.
1164
+ * @param before - The SHA-256 the saved lock must still have.
1165
+ * @returns `tree and lock restored`, else the failure with the backup paths kept.
1166
+ */
1167
+ export function restoreLock(target: string, moved: string, backup: string, before: string): string {
639
1168
  try {
640
1169
  for (const path of [
641
1170
  moved,
@@ -658,7 +1187,16 @@ function restoreLock(target: string, moved: string, backup: string, before: stri
658
1187
  }
659
1188
  }
660
1189
 
661
- function validateWavePath(target: string, destination: string): void {
1190
+ /**
1191
+ * Checks that a destination stays inside the target and crosses no link.
1192
+ *
1193
+ * @param target - The target directory.
1194
+ * @param destination - The path to write or remove.
1195
+ * @returns Nothing when the path is safe.
1196
+ * @throws Thrown when the path resolves outside the target, an ancestor is a link or not a
1197
+ * directory, or an ancestor's real location leaves the target's.
1198
+ */
1199
+ export function validateWavePath(target: string, destination: string): void {
662
1200
  const root = resolve(target)
663
1201
  const path = resolve(destination)
664
1202
  const location = relative(root, path)
@@ -681,19 +1219,29 @@ function validateWavePath(target: string, destination: string): void {
681
1219
  }
682
1220
  }
683
1221
 
684
- function readDirtyPaths(text: string): readonly string[] {
685
- const [entry, ...rest] = text.split('\0')
686
- if (entry === undefined || entry === '') return []
687
- const renamed = /[RC]/u.test(entry.slice(0, 2))
688
- const original = renamed ? rest[0] : undefined
689
- return [
690
- entry.slice(3),
691
- ...(original === undefined ? [] : [original]),
692
- ...readDirtyPaths((renamed ? rest.slice(1) : rest).join('\0')),
693
- ]
1222
+ /**
1223
+ * Reads the dirty paths out of `git status --porcelain=v1 -z` output.
1224
+ *
1225
+ * @remarks
1226
+ * One pass over the entries with {@link DIRTY_ENTRY}, so the cost grows with the text and no entry
1227
+ * recurses.
1228
+ *
1229
+ * @param text - The command's standard output.
1230
+ * @returns Each entry's path; a rename or copy also lists its original path.
1231
+ */
1232
+ export function readDirtyPaths(text: string): readonly string[] {
1233
+ return [...text.matchAll(DIRTY_ENTRY)].flatMap((match) =>
1234
+ match[1] === undefined ? [match[3] ?? ''] : [match[1], match[2] ?? ''],
1235
+ )
694
1236
  }
695
1237
 
696
- function readReceipt(target: string): OverwriteReceipt | undefined {
1238
+ /**
1239
+ * Reads the receipt the overwrite step left.
1240
+ *
1241
+ * @param target - The target directory.
1242
+ * @returns The receipt, or undefined when it is absent, unreadable, or lacks a field in its type.
1243
+ */
1244
+ export function readReceipt(target: string): OverwriteReceipt | undefined {
697
1245
  try {
698
1246
  const value = parseJsonRecord(
699
1247
  readFileSync(join(target, 'tmp', 'units', 'wave', 'overwrite.json'), 'utf8'),
@@ -721,7 +1269,15 @@ function readReceipt(target: string): OverwriteReceipt | undefined {
721
1269
  }
722
1270
  }
723
1271
 
724
- function runRecord(runner: Runner, name: string | undefined): void {
1272
+ /**
1273
+ * Runs the record step.
1274
+ *
1275
+ * @param runner - The visit's state.
1276
+ * @param name - The package name the manifest declares.
1277
+ * @returns Nothing after the rows are appended; the overwrite's paths are committed when the receipt
1278
+ * attributes every dirty path to them.
1279
+ */
1280
+ export function runRecord(runner: Runner, name: string | undefined): void {
725
1281
  if (name === SCAFFOLD) {
726
1282
  appendStep(runner, 'record', 'git commit', `skipped: ${SCAFFOLD}`)
727
1283
  return
@@ -811,7 +1367,7 @@ function runRecord(runner: Runner, name: string | undefined): void {
811
1367
  readVerdict,
812
1368
  `${dirty.join('\0')}\0`,
813
1369
  )
814
- if (failed(runner)) return
1370
+ if (failed(runner.steps)) return
815
1371
  runStep(
816
1372
  runner,
817
1373
  'record',
@@ -819,7 +1375,7 @@ function runRecord(runner: Runner, name: string | undefined): void {
819
1375
  ['commit', '-m', `Overwrite with scaffold ${receipt.version}`],
820
1376
  runner.target,
821
1377
  )
822
- if (failed(runner)) return
1378
+ if (failed(runner.steps)) return
823
1379
  runStep(runner, 'record', 'git', ['status', '--porcelain'], runner.target, (result) =>
824
1380
  result.status === 0 && result.stdout.trim() !== ''
825
1381
  ? { exit: 1, note: `tree remains dirty: ${result.stdout}` }
@@ -827,8 +1383,20 @@ function runRecord(runner: Runner, name: string | undefined): void {
827
1383
  )
828
1384
  }
829
1385
 
830
- // A later release of the same name reads the range the earlier reading wrote.
831
- function computeRanges(ranges: Record<string, unknown>, releases: readonly Release[]): RangeMove {
1386
+ /**
1387
+ * Moves each named dependency to the registry caret of its latest release.
1388
+ *
1389
+ * @remarks
1390
+ * A later release of the same name reads the range the earlier reading wrote.
1391
+ *
1392
+ * @param ranges - The block's ranges by name.
1393
+ * @param releases - The audit's release readings.
1394
+ * @returns The ranges after the moves and one message per move.
1395
+ */
1396
+ export function computeRanges(
1397
+ ranges: Record<string, unknown>,
1398
+ releases: readonly Release[],
1399
+ ): RangeMove {
832
1400
  return releases.reduce<RangeMove>(
833
1401
  (state, release) => {
834
1402
  if (release.latest === undefined || !(release.name in state.ranges)) return state
@@ -844,7 +1412,14 @@ function computeRanges(ranges: Record<string, unknown>, releases: readonly Relea
844
1412
  )
845
1413
  }
846
1414
 
847
- function writeRanges(target: string, releases: readonly Release[]): readonly string[] {
1415
+ /**
1416
+ * Writes the re-pinned ranges into the target's manifest.
1417
+ *
1418
+ * @param target - The target directory.
1419
+ * @param releases - The audit's release readings.
1420
+ * @returns One message per range moved; the manifest is rewritten only when a range moved.
1421
+ */
1422
+ export function writeRanges(target: string, releases: readonly Release[]): readonly string[] {
848
1423
  const manifest = readManifest(target)
849
1424
  if (manifest === undefined) return []
850
1425
  const fields = RANGE_FIELDS.flatMap((field) => {
@@ -863,7 +1438,16 @@ function writeRanges(target: string, releases: readonly Release[]): readonly str
863
1438
  return moved
864
1439
  }
865
1440
 
866
- function runPin(
1441
+ /**
1442
+ * Runs the pin step.
1443
+ *
1444
+ * @param runner - The visit's state.
1445
+ * @param scaffoldFile - The scaffold executable.
1446
+ * @param scaffoldPrefix - The arguments that precede scaffold's own.
1447
+ * @param offline - The `--offline` argument, or none.
1448
+ * @returns Nothing after the rows are appended.
1449
+ */
1450
+ export function runPin(
867
1451
  runner: Runner,
868
1452
  scaffoldFile: string,
869
1453
  scaffoldPrefix: readonly string[],
@@ -893,7 +1477,13 @@ function runPin(
893
1477
  runInstall(runner, 'pin')
894
1478
  }
895
1479
 
896
- function runCommit(runner: Runner): void {
1480
+ /**
1481
+ * Runs the commit step.
1482
+ *
1483
+ * @param runner - The visit's state.
1484
+ * @returns Nothing after the rows are appended.
1485
+ */
1486
+ export function runCommit(runner: Runner): void {
897
1487
  if (refuseLocalSpecs(runner, 'commit')) return
898
1488
  const status = runStep(
899
1489
  runner,
@@ -923,7 +1513,16 @@ function runCommit(runner: Runner): void {
923
1513
  }
924
1514
  }
925
1515
 
926
- function runOverwrite(
1516
+ /**
1517
+ * Runs the overwrite step.
1518
+ *
1519
+ * @param runner - The visit's state.
1520
+ * @param scaffoldFile - The scaffold executable.
1521
+ * @param scaffoldPrefix - The arguments that precede scaffold's own.
1522
+ * @param offline - The `--offline` argument, or none.
1523
+ * @returns Nothing after the rows are appended and the receipt is written.
1524
+ */
1525
+ export function runOverwrite(
927
1526
  runner: Runner,
928
1527
  scaffoldFile: string,
929
1528
  scaffoldPrefix: readonly string[],
@@ -937,8 +1536,8 @@ function runOverwrite(
937
1536
  runner.target,
938
1537
  (result) => judgeOverwrite(result, runner.offline),
939
1538
  )
940
- if (overwrite !== undefined && !failed(runner) && runner.directory !== undefined) {
941
- const receipt = readJsonObject(overwrite.stdout)
1539
+ if (overwrite !== undefined && !failed(runner.steps) && runner.directory !== undefined) {
1540
+ const receipt = parseJsonLine(overwrite.stdout)
942
1541
  const head = runStep(runner, 'overwrite', 'git', ['rev-parse', 'HEAD'], runner.target)
943
1542
  const installed = readManifest(join(runner.target, 'node_modules', '@orkestrel', 'scaffold'))
944
1543
  const version = readString(installed, 'version')
@@ -964,7 +1563,7 @@ function runOverwrite(
964
1563
  }
965
1564
  }
966
1565
  }
967
- if (!failed(runner)) {
1566
+ if (!failed(runner.steps)) {
968
1567
  runStep(
969
1568
  runner,
970
1569
  'overwrite',
@@ -975,7 +1574,17 @@ function runOverwrite(
975
1574
  }
976
1575
  }
977
1576
 
978
- function runVerify(
1577
+ /**
1578
+ * Runs the verify step.
1579
+ *
1580
+ * @param runner - The visit's state.
1581
+ * @param scaffoldFile - The scaffold executable.
1582
+ * @param scaffoldPrefix - The arguments that precede scaffold's own.
1583
+ * @param offline - The `--offline` argument, or none.
1584
+ * @param own - If `true`, the target is scaffold's own checkout; if `false`, it is another package.
1585
+ * @returns Nothing after the rows are appended.
1586
+ */
1587
+ export function runVerify(
979
1588
  runner: Runner,
980
1589
  scaffoldFile: string,
981
1590
  scaffoldPrefix: readonly string[],
@@ -1009,7 +1618,15 @@ function runVerify(
1009
1618
  )
1010
1619
  }
1011
1620
 
1012
- function runPins(
1621
+ /**
1622
+ * Runs the pins step.
1623
+ *
1624
+ * @param runner - The visit's state.
1625
+ * @param before - The ranges the manifest declared before the visit.
1626
+ * @param version - The version to sweep for, or undefined when none is known.
1627
+ * @returns The number of hit lines, or undefined when the sweep did not run.
1628
+ */
1629
+ export function runPins(
1013
1630
  runner: Runner,
1014
1631
  before: ReadonlyMap<string, string>,
1015
1632
  version: string | undefined,
@@ -1034,7 +1651,14 @@ function runPins(
1034
1651
  : swept.stdout.split(/\r\n|\n/).filter((line) => /^\S+:\d+: /u.test(line)).length
1035
1652
  }
1036
1653
 
1037
- function runCompare(runner: Runner, name: string | undefined): Ruling {
1654
+ /**
1655
+ * Runs the compare step.
1656
+ *
1657
+ * @param runner - The visit's state.
1658
+ * @param name - The package name the manifest declares.
1659
+ * @returns The bump ruling the two readings reach.
1660
+ */
1661
+ export function runCompare(runner: Runner, name: string | undefined): Ruling {
1038
1662
  const compared = runStep(runner, 'compare', process.execPath, [COMPARE, '--json'], runner.target)
1039
1663
  const distMoved =
1040
1664
  compared === undefined
@@ -1061,11 +1685,30 @@ function runCompare(runner: Runner, name: string | undefined): Ruling {
1061
1685
  return { distMoved, rangesMoved: ranges.length > 0, ranges, hits: undefined }
1062
1686
  }
1063
1687
 
1064
- function describeReading(moved: boolean | undefined, movedWord: string, sameWord: string): string {
1688
+ /**
1689
+ * Words one reading of the ruling.
1690
+ *
1691
+ * @param moved - The reading, or undefined when unanswered.
1692
+ * @param movedWord - The word for a moved reading.
1693
+ * @param sameWord - The word for an unmoved reading.
1694
+ * @returns `unanswered`, the moved word, or the same word.
1695
+ */
1696
+ export function describeChange(
1697
+ moved: boolean | undefined,
1698
+ movedWord: string,
1699
+ sameWord: string,
1700
+ ): string {
1065
1701
  return moved === undefined ? 'unanswered' : moved ? movedWord : sameWord
1066
1702
  }
1067
1703
 
1068
- function runVisit(argv: readonly string[]): Visit | undefined {
1704
+ /**
1705
+ * Runs one repository's visit.
1706
+ *
1707
+ * @param argv - The arguments after the script path.
1708
+ * @returns The finished visit, or undefined when the target carries no readable manifest or no
1709
+ * scaffold executable.
1710
+ */
1711
+ export function runVisit(argv: readonly string[]): Visit | undefined {
1069
1712
  const target = readOption(argv, '--target') ?? process.cwd()
1070
1713
  if (!existsSync(join(target, 'package.json'))) {
1071
1714
  console.error(`wave: ${target} carries no package.json`)
@@ -1109,36 +1752,38 @@ function runVisit(argv: readonly string[]): Visit | undefined {
1109
1752
  }
1110
1753
  const before = readRanges(manifest, RANGE_FIELDS)
1111
1754
  const offline = runner.offline ? ['--offline'] : []
1112
- if (selected.has('pin') && !failed(runner)) runPin(runner, scaffoldFile, scaffoldPrefix, offline)
1113
- if (selected.has('commit') && !failed(runner)) runCommit(runner)
1114
- if (selected.has('overwrite') && !failed(runner)) {
1755
+ if (selected.has('pin') && !failed(runner.steps))
1756
+ runPin(runner, scaffoldFile, scaffoldPrefix, offline)
1757
+ if (selected.has('commit') && !failed(runner.steps)) runCommit(runner)
1758
+ if (selected.has('overwrite') && !failed(runner.steps)) {
1115
1759
  if (name === SCAFFOLD) {
1116
1760
  appendStep(
1117
1761
  runner,
1118
1762
  'overwrite',
1119
- describeCommand(scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline]),
1763
+ renderCommand(scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline]),
1120
1764
  `skipped with its audit: ${SCAFFOLD} is the package whose checkout the vendored host is staged from`,
1121
1765
  )
1122
1766
  } else runOverwrite(runner, scaffoldFile, scaffoldPrefix, offline)
1123
1767
  }
1124
- if (selected.has('verify') && !failed(runner)) {
1768
+ if (selected.has('verify') && !failed(runner.steps)) {
1125
1769
  runVerify(runner, scaffoldFile, scaffoldPrefix, offline, name === SCAFFOLD)
1126
1770
  }
1127
- if (selected.has('install') && !failed(runner)) runInstall(runner, 'install')
1771
+ if (selected.has('install') && !failed(runner.steps)) runInstall(runner, 'install')
1128
1772
  const hits =
1129
- selected.has('pins') && !failed(runner)
1773
+ selected.has('pins') && !failed(runner.steps)
1130
1774
  ? runPins(runner, before, readOption(argv, '--prior') ?? version)
1131
1775
  : undefined
1132
- if (selected.has('format') && !failed(runner)) runNpmStep(runner, 'format', ['run', 'format'])
1133
- if (selected.has('record') && !failed(runner)) runRecord(runner, name)
1134
- if (selected.has('gates') && !failed(runner)) {
1776
+ if (selected.has('format') && !failed(runner.steps))
1777
+ runNpmStep(runner, 'format', ['run', 'format'])
1778
+ if (selected.has('record') && !failed(runner.steps)) runRecord(runner, name)
1779
+ if (selected.has('gates') && !failed(runner.steps)) {
1135
1780
  for (const gate of GATES) {
1136
- if (failed(runner)) break
1781
+ if (failed(runner.steps)) break
1137
1782
  runNpmStep(runner, 'gates', ['run', gate])
1138
1783
  }
1139
1784
  }
1140
1785
  const compared: Ruling =
1141
- selected.has('compare') && !failed(runner)
1786
+ selected.has('compare') && !failed(runner.steps)
1142
1787
  ? runCompare(runner, name)
1143
1788
  : { distMoved: undefined, rangesMoved: undefined, ranges: undefined, hits: undefined }
1144
1789
  const ruling: Ruling = { ...compared, hits }
@@ -1148,18 +1793,25 @@ function runVisit(argv: readonly string[]): Visit | undefined {
1148
1793
  : ruling.distMoved === undefined || ruling.rangesMoved === undefined
1149
1794
  ? 'unanswered'
1150
1795
  : 'no bump'
1151
- const reading = `dist ${describeReading(ruling.distMoved, 'moved', 'same')}, ranges ${describeReading(ruling.rangesMoved, 'moved', 'same')}${hits === undefined ? '' : `, ${hits} self-pin hit(s)`}`
1796
+ const changes = `dist ${describeChange(ruling.distMoved, 'moved', 'same')}, ranges ${describeChange(ruling.rangesMoved, 'moved', 'same')}${hits === undefined ? '' : `, ${hits} self-pin hit(s)`}`
1152
1797
  return {
1153
1798
  target,
1154
1799
  name,
1155
1800
  version,
1156
1801
  steps: runner.steps,
1157
1802
  ruling,
1158
- row: `| ${name ?? target} | ${version ?? '?'} | ${bump} | ${reading} | round |`,
1803
+ row: `| ${name ?? target} | ${version ?? '?'} | ${bump} | ${changes} | round |`,
1159
1804
  }
1160
1805
  }
1161
1806
 
1162
- function readJournalDirectory(target: string): string | undefined {
1807
+ /**
1808
+ * Chooses the directory a visit journals its steps in.
1809
+ *
1810
+ * @param target - The target directory.
1811
+ * @returns A new stamped directory under the target's `tmp/units/wave/` when the target is inside a
1812
+ * work tree that ignores `tmp/`, else undefined.
1813
+ */
1814
+ export function readJournalDirectory(target: string): string | undefined {
1163
1815
  const inside = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
1164
1816
  cwd: target,
1165
1817
  encoding: 'utf8',
@@ -1176,7 +1828,16 @@ function readJournalDirectory(target: string): string | undefined {
1176
1828
  : undefined
1177
1829
  }
1178
1830
 
1179
- function readLayers(catalogPath: string): ReadonlyMap<string, readonly string[]> | undefined {
1831
+ /**
1832
+ * Reads the packages by layer out of the catalog table.
1833
+ *
1834
+ * @param catalogPath - The agent file that carries the marker-bounded table.
1835
+ * @returns Each layer's `name range` members, layers in numeric order, or undefined when the file or
1836
+ * its markers are absent.
1837
+ */
1838
+ export function readLayers(
1839
+ catalogPath: string,
1840
+ ): ReadonlyMap<string, readonly string[]> | undefined {
1180
1841
  if (!existsSync(catalogPath)) return undefined
1181
1842
  const text = readFileSync(catalogPath, 'utf8')
1182
1843
  const start = text.indexOf('<!-- orkestrel:catalog -->')
@@ -1200,20 +1861,40 @@ function readLayers(catalogPath: string): ReadonlyMap<string, readonly string[]>
1200
1861
  )
1201
1862
  }
1202
1863
 
1203
- function renderVisit(visit: Visit): string {
1864
+ /**
1865
+ * Renders a visit as text lines.
1866
+ *
1867
+ * @param visit - The finished visit.
1868
+ * @returns One line per step, the ruling, each moved range, and the release row.
1869
+ */
1870
+ export function renderVisit(visit: Visit): string {
1204
1871
  const lines = [
1205
1872
  ...visit.steps.map(
1206
1873
  (entry) =>
1207
1874
  `wave: ${entry.step.padEnd(9)} exit=${entry.exit === undefined ? 'dry' : String(entry.exit)} ${String(entry.durationMs).padStart(7)} ms ${entry.command}${entry.note === undefined ? '' : ` (${entry.note})`}${entry.journal === undefined ? '' : ` journal=${entry.journal}`}`,
1208
1875
  ),
1209
- `wave: ruling dist ${describeReading(visit.ruling.distMoved, 'moved', 'same')}, ranges ${describeReading(visit.ruling.rangesMoved, 'moved', 'same')}${visit.ruling.hits === undefined ? '' : `, ${visit.ruling.hits} self-pin hit(s)`}`,
1876
+ `wave: ruling dist ${describeChange(visit.ruling.distMoved, 'moved', 'same')}, ranges ${describeChange(visit.ruling.rangesMoved, 'moved', 'same')}${visit.ruling.hits === undefined ? '' : `, ${visit.ruling.hits} self-pin hit(s)`}`,
1210
1877
  ...(visit.ruling.ranges ?? []).map((range) => `wave: range ${range}`),
1211
1878
  visit.row,
1212
1879
  ]
1213
1880
  return `${lines.join('\n')}\n`
1214
1881
  }
1215
1882
 
1216
- function main(argv: readonly string[]): number {
1883
+ /**
1884
+ * Runs the mode the arguments name.
1885
+ *
1886
+ * @param argv - The arguments after the script path.
1887
+ * @returns The exit code: 0 when every step passed, 1 when one failed, 2 when a reading failed, 64
1888
+ * on usage.
1889
+ */
1890
+ export function main(argv: readonly string[]): number {
1891
+ const unknown = readUnknownFlags(argv, FLAGS)
1892
+ if (unknown.length > 0) {
1893
+ console.error(
1894
+ `wave: unknown flag ${unknown.join(', ')}; give each flag and its value as separate arguments\n${USAGE}`,
1895
+ )
1896
+ return 64
1897
+ }
1217
1898
  const missing = readMissingFlags(argv, [
1218
1899
  '--target',
1219
1900
  '--from',
@@ -1245,9 +1926,7 @@ function main(argv: readonly string[]): number {
1245
1926
  return 0
1246
1927
  }
1247
1928
  if (!argv.includes('--visit')) {
1248
- console.error(
1249
- 'usage: wave.ts --visit [--target DIR] [--offline] [--regenerate] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE] | --plan [--catalog PATH] [--json]',
1250
- )
1929
+ console.error(USAGE)
1251
1930
  return 64
1252
1931
  }
1253
1932
  const from = STEPS.indexOf(readOption(argv, '--from') ?? 'pin')
@@ -1273,16 +1952,9 @@ function main(argv: readonly string[]): number {
1273
1952
  writeFileSync(out, output)
1274
1953
  console.log(`wave: wrote ${out}`)
1275
1954
  }
1276
- return visit.steps.some(
1277
- (entry) =>
1278
- entry.superseded !== true &&
1279
- entry.exit !== undefined &&
1280
- entry.exit !== 0 &&
1281
- entry.step !== 'pins' &&
1282
- entry.step !== 'compare',
1283
- )
1284
- ? 1
1285
- : 0
1955
+ return failed(visit.steps) ? 1 : 0
1286
1956
  }
1287
1957
 
1288
- process.exitCode = main(process.argv.slice(2))
1958
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
1959
+ process.exitCode = main(process.argv.slice(2))
1960
+ }