@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
@@ -17,7 +17,8 @@
17
17
  // the tarball's absolute file: spec, because a relative spec breaks after the consumer is itself
18
18
  // packed and installed elsewhere. --override NAME sets overrides[NAME] to $NAME when the consumer
19
19
  // declares NAME, else to the tarball spec of the source named NAME, and a created block lands where
20
- // oxfmt sorts it. The install runs with --ignore-scripts and deletes node_modules/.vite, then the
20
+ // oxfmt sorts it (OVERRIDE_PRECEDERS, proved key by key against the installed formatter). The install
21
+ // runs with --ignore-scripts and deletes node_modules/.vite, then the
21
22
  // proof requires each pinned package, an override-only one included, to be a directory whose file
22
23
  // set is its pack list and whose bytes are its source's, no other copy of it to be installed, and
23
24
  // every other top-level @orkestrel package to be byte-unchanged; a proved pin records the digest of
@@ -33,7 +34,9 @@
33
34
  // ledger archive's full location.
34
35
  // --pin and --restore write a receipt create-only to tmp/units/swap-<consumer>-<time>.json under the
35
36
  // working directory. Every mode prints text lines, or one JSON line with --json.
36
- // Exit 0 on success, 1 when a command or a proof failed, 3 when --status reports drift, 64 on usage.
37
+ // Exit 0 on success, 1 when a command or a proof failed, 3 when --status reports drift, 64 on usage,
38
+ // including a flag the mode does not read and the `--flag=value` form.
39
+ import type { SpawnSyncReturns } from 'node:child_process'
37
40
  import { spawnSync } from 'node:child_process'
38
41
  import { createHash } from 'node:crypto'
39
42
  import {
@@ -45,30 +48,22 @@ import {
45
48
  readFileSync,
46
49
  realpathSync,
47
50
  renameSync,
51
+ rmSync,
48
52
  writeFileSync,
49
53
  } from 'node:fs'
50
54
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
51
- import {
52
- isRecord,
53
- normalizeSlashes,
54
- parseJsonRecord,
55
- readMissingFlags,
56
- readOption,
57
- readOptions,
58
- readString,
59
- removeTree,
60
- runNpm,
61
- scanLocalSpecs,
62
- } from '../../orkestrel-dispatch/scripts/helpers.ts'
63
-
64
- type Manifest = Readonly<Record<string, unknown>>
65
-
66
- // The manifest fields a pin rewrites; `peerDependencies` stays a registry range.
67
- type ManifestField = 'dependencies' | 'devDependencies'
68
-
69
- type SwapMode = 'pin' | 'restore' | 'status'
70
-
71
- interface Command {
55
+
56
+ /** Describes a package manifest as a read-only record. */
57
+ export type Manifest = Readonly<Record<string, unknown>>
58
+
59
+ /** Names the manifest fields a pin rewrites; `peerDependencies` stays a registry range. */
60
+ export type ManifestField = 'dependencies' | 'devDependencies'
61
+
62
+ /** Names the three modes of the script. */
63
+ export type SwapMode = 'pin' | 'restore' | 'status'
64
+
65
+ /** Describes a parsed command line. */
66
+ export interface Command {
72
67
  readonly mode: SwapMode
73
68
  readonly consumer: string
74
69
  readonly sources: readonly string[]
@@ -79,23 +74,26 @@ interface Command {
79
74
  readonly json: boolean
80
75
  }
81
76
 
82
- type CommandResult =
77
+ /** Describes a command line that parsed or the reason it did not. */
78
+ export type CommandResult =
83
79
  | { readonly ok: true; readonly command: Command }
84
80
  | { readonly ok: false; readonly message: string }
85
81
 
86
- interface Source {
82
+ /** Describes a source package a pin packs. */
83
+ export interface Source {
87
84
  readonly directory: string
88
85
  readonly name: string
89
86
  readonly version: string
90
87
  }
91
88
 
92
- interface Declaration {
89
+ /** Describes the field and range a manifest declares a package with. */
90
+ export interface Declaration {
93
91
  readonly field: ManifestField
94
92
  readonly spec: string
95
93
  }
96
94
 
97
- // One source after its pack, or planned with a `pending` hash under --dry-run.
98
- interface Tarball {
95
+ /** Describes one source after its pack, or planned with a `pending` hash under --dry-run. */
96
+ export interface Tarball {
99
97
  readonly source: Source
100
98
  readonly file: string
101
99
  readonly spec: string
@@ -105,7 +103,8 @@ interface Tarball {
105
103
  readonly dirty: boolean | undefined
106
104
  }
107
105
 
108
- interface LedgerEntry {
106
+ /** Describes one package row of the ledger. */
107
+ export interface LedgerEntry {
109
108
  readonly field?: ManifestField
110
109
  readonly replaced?: string
111
110
  readonly tarball: string
@@ -117,32 +116,37 @@ interface LedgerEntry {
117
116
  readonly at: string
118
117
  }
119
118
 
120
- interface OverrideEntry {
119
+ /** Describes one override row of the ledger. */
120
+ export interface OverrideEntry {
121
121
  readonly value: string
122
122
  readonly replaced?: string
123
123
  readonly at: string
124
124
  }
125
125
 
126
- interface Ledger {
126
+ /** Describes the ledger that records every pin. */
127
+ export interface Ledger {
127
128
  readonly packages: Readonly<Record<string, LedgerEntry>>
128
129
  readonly overrides: Readonly<Record<string, OverrideEntry>>
129
130
  readonly blockExisted?: boolean
130
131
  }
131
132
 
132
- interface Change {
133
+ /** Describes one manifest entry a plan changes. */
134
+ export interface Change {
133
135
  readonly block: string
134
136
  readonly name: string
135
137
  readonly from: string | undefined
136
138
  readonly to: string | undefined
137
139
  }
138
140
 
139
- interface PinPlan {
141
+ /** Describes the manifest and ledger a pin writes. */
142
+ export interface PinPlan {
140
143
  readonly manifest: Manifest
141
144
  readonly ledger: Ledger
142
145
  readonly changes: readonly Change[]
143
146
  }
144
147
 
145
- interface RestorePlan {
148
+ /** Describes the manifest and ledger a restore writes and the rows it restores. */
149
+ export interface RestorePlan {
146
150
  readonly manifest: Manifest
147
151
  readonly ledger: Ledger
148
152
  readonly changes: readonly Change[]
@@ -150,51 +154,85 @@ interface RestorePlan {
150
154
  readonly dropped: readonly string[]
151
155
  }
152
156
 
153
- interface Copy {
157
+ /** Describes one installed copy of a named package. */
158
+ export interface Copy {
154
159
  readonly name: string
155
160
  readonly path: string
156
161
  readonly version: string | undefined
157
162
  readonly link: boolean
158
163
  }
159
164
 
160
- interface Version {
165
+ /** Describes a parsed semantic version. */
166
+ export interface Version {
161
167
  readonly major: number
162
168
  readonly minor: number
163
169
  readonly patch: number
164
170
  readonly pre: string | undefined
165
171
  }
166
172
 
167
- interface Comparator {
173
+ /** Describes one comparison a range makes against a version. */
174
+ export interface Comparator {
168
175
  readonly operator: '>=' | '>' | '<=' | '<' | '='
169
176
  readonly version: Version
170
177
  }
171
178
 
172
- const FIELDS: readonly ManifestField[] = ['dependencies', 'devDependencies']
173
- const MODES: readonly SwapMode[] = ['pin', 'restore', 'status']
174
- const TARBALL_DIRECTORY = 'tmp/tarballs'
175
- const LEDGER_FILE = 'pins.json'
176
- const RECEIPT_DIRECTORY = 'tmp/units'
177
- const EXIT_FAILURE = 1
178
- const EXIT_DRIFT = 3
179
- const EXIT_USAGE = 64
180
- const FORMAT_PROBLEM =
179
+ /** Describes how to spawn npm on this host without a shell where the CLI entry is present. */
180
+ export interface NpmCommand {
181
+ readonly file: string
182
+ readonly prefix: readonly string[]
183
+ readonly shell: boolean
184
+ }
185
+
186
+ /** Lists the manifest fields a pin rewrites. */
187
+ export const FIELDS: readonly ManifestField[] = ['dependencies', 'devDependencies']
188
+
189
+ /** Lists the modes in the order the script checks them. */
190
+ export const MODES: readonly SwapMode[] = ['pin', 'restore', 'status']
191
+
192
+ /** Names the consumer directory that holds the tarballs and the ledger. */
193
+ export const TARBALL_DIRECTORY = 'tmp/tarballs'
194
+
195
+ /** Names the ledger file inside the tarball directory. */
196
+ export const LEDGER_FILE = 'pins.json'
197
+
198
+ /** Names the working-directory folder that receives the receipts. */
199
+ export const RECEIPT_DIRECTORY = 'tmp/units'
200
+
201
+ /** Holds the exit code for a command or a proof that failed. */
202
+ export const EXIT_FAILURE = 1
203
+
204
+ /** Holds the exit code for a status that reports drift. */
205
+ export const EXIT_DRIFT = 3
206
+
207
+ /** Holds the exit code for a usage refusal. */
208
+ export const EXIT_USAGE = 64
209
+
210
+ /** Holds the refusal for a manifest the script would reformat. */
211
+ export const FORMAT_PROBLEM =
181
212
  'package.json is not tab-indented JSON with a trailing newline; the script would reformat it'
182
213
 
183
- const VALUE_FLAGS: Readonly<Record<SwapMode, readonly string[]>> = {
214
+ /** Lists the value flags each mode reads. */
215
+ export const VALUE_FLAGS: Readonly<Record<SwapMode, readonly string[]>> = {
184
216
  pin: ['--consumer', '--source', '--override'],
185
217
  restore: ['--consumer', '--package'],
186
218
  status: ['--consumer'],
187
219
  }
188
220
 
189
- const SWITCH_FLAGS: Readonly<Record<SwapMode, readonly string[]>> = {
221
+ /** Lists the switch flags each mode reads. */
222
+ export const SWITCH_FLAGS: Readonly<Record<SwapMode, readonly string[]>> = {
190
223
  pin: ['--pin', '--no-build', '--dry-run', '--json'],
191
224
  restore: ['--restore', '--json'],
192
225
  status: ['--status', '--json'],
193
226
  }
194
227
 
195
- // oxfmt 0.72.0 sorts these `package.json` keys ahead of `overrides`; every other key it knows, and
196
- // every key it does not know, follows it.
197
- const OVERRIDE_PRECEDERS: readonly string[] = [
228
+ /**
229
+ * Lists the `package.json` keys oxfmt sorts ahead of `overrides`.
230
+ *
231
+ * @remarks
232
+ * Derived for the installed oxfmt, 0.74.0; every other key it knows, and every key it does not know,
233
+ * follows `overrides`. `swap.test.ts` proves each key against the installed formatter.
234
+ */
235
+ export const OVERRIDE_PRECEDERS: readonly string[] = [
198
236
  '$schema',
199
237
  'name',
200
238
  'displayName',
@@ -258,29 +296,260 @@ const OVERRIDE_PRECEDERS: readonly string[] = [
258
296
  'resolutions',
259
297
  ]
260
298
 
261
- // An optional `@scope/` and a name of the characters a registry URL carries untouched; a path
262
- // separator beyond the scope's one `/`, an absolute form, and a drive form fall outside it.
263
- const PACKAGE_NAME_PATTERN = /^(?:@[A-Za-z0-9~-][A-Za-z0-9._~-]*\/)?[A-Za-z0-9~-][A-Za-z0-9._~-]*$/u
264
-
265
- const PACKAGE_NAME_LIMIT = 214
266
-
267
- const USAGE = [
299
+ /**
300
+ * Matches a package name.
301
+ *
302
+ * @remarks
303
+ * An optional `@scope/` and a name of the characters a registry URL carries untouched; a path
304
+ * separator beyond the scope's one `/`, an absolute form, and a drive form fall outside it.
305
+ */
306
+ export const PACKAGE_NAME_PATTERN =
307
+ /^(?:@[A-Za-z0-9~-][A-Za-z0-9._~-]*\/)?[A-Za-z0-9~-][A-Za-z0-9._~-]*$/u
308
+
309
+ /** Holds the longest package name a registry accepts. */
310
+ export const PACKAGE_NAME_LIMIT = 214
311
+
312
+ /** Holds the usage text printed on a usage refusal. */
313
+ export const USAGE = [
268
314
  'usage: swap.ts --pin --consumer DIR --source DIR [--source DIR ...] [--override NAME ...] [--no-build] [--dry-run] [--json]',
269
315
  ' swap.ts --restore --consumer DIR [--package NAME ...] [--json]',
270
316
  ' swap.ts --status --consumer DIR [--json]',
271
317
  ].join('\n')
272
318
 
273
- function isPackageName(name: string): boolean {
319
+ /** Names the manifest fields that hold specs, including nested overrides. */
320
+ export const SPEC_FIELDS: readonly string[] = Object.freeze([
321
+ 'dependencies',
322
+ 'devDependencies',
323
+ 'peerDependencies',
324
+ 'optionalDependencies',
325
+ 'overrides',
326
+ ])
327
+
328
+ /** Matches npm local prefixes and path starts, including drive-relative paths. */
329
+ export const LOCAL_SPEC_PATTERN = /^(?:file:|link:|\.|~[\\/]|[\\/]|[A-Za-z]:)/iu
330
+
331
+ /** Matches archive specs with no protocol and a tgz, tar.gz, or tar extension. */
332
+ export const ARCHIVE_SPEC_PATTERN = /^[^:]+\.(?:tgz|tar\.gz|tar)$/iu
333
+
334
+ /** Matches hosted user/repository shorthand even when its name ends in an archive extension. */
335
+ export const SHORTHAND_SPEC_PATTERN = /^[^:@%/\s.-][^:@%/\s]*\/[^:@\s/%]+$/u
336
+
337
+ /**
338
+ * Checks whether a value is a non-null, non-array object.
339
+ *
340
+ * @param value - The value to inspect.
341
+ * @returns True if the value is an object other than null or an array; false otherwise.
342
+ */
343
+ export function isRecord(value: unknown): value is Record<string, unknown> {
344
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
345
+ }
346
+
347
+ /**
348
+ * Checks whether npm resolves a spec from the local host.
349
+ *
350
+ * @param spec - The package spec, with leading whitespace ignored.
351
+ * @returns True if the spec names a local path or archive; false otherwise.
352
+ */
353
+ export function matchesLocalSpec(spec: string): boolean {
354
+ const text = spec.trimStart()
355
+ return (
356
+ LOCAL_SPEC_PATTERN.test(text) ||
357
+ (ARCHIVE_SPEC_PATTERN.test(text) && !SHORTHAND_SPEC_PATTERN.test(text))
358
+ )
359
+ }
360
+
361
+ /**
362
+ * Collects string specs with the dotted path of each nested record member.
363
+ *
364
+ * @param path - The field path that prefixes each finding.
365
+ * @param value - A string or finite, acyclic manifest record; other values contribute no specs.
366
+ * @returns The path and spec pairs in record order.
367
+ */
368
+ export function collectSpecs(
369
+ path: string,
370
+ value: unknown,
371
+ ): ReadonlyArray<readonly [string, string]> {
372
+ if (typeof value === 'string') return [[path, value]]
373
+ if (!isRecord(value)) return []
374
+ return Object.entries(value).flatMap(([key, child]) => collectSpecs(`${path}.${key}`, child))
375
+ }
376
+
377
+ /**
378
+ * Finds local specs in the manifest's spec fields, including nested overrides.
379
+ *
380
+ * @param manifest - A finite, acyclic package manifest record.
381
+ * @returns Each local spec's dotted field path and spec, in field and record order.
382
+ */
383
+ export function scanLocalSpecs(
384
+ manifest: Readonly<Record<string, unknown>>,
385
+ ): ReadonlyArray<readonly [string, string]> {
386
+ return SPEC_FIELDS.flatMap((field) =>
387
+ collectSpecs(field, manifest[field]).filter(([, spec]) => matchesLocalSpec(spec)),
388
+ )
389
+ }
390
+
391
+ /**
392
+ * Removes a tree with bounded retries for transient host removal errors.
393
+ *
394
+ * @param path - The directory to remove; a missing path succeeds.
395
+ * @returns Nothing after the removal succeeds.
396
+ * @throws Thrown when removal fails after the bounded retries or the host refuses the path.
397
+ */
398
+ export function removeTree(path: string): void {
399
+ rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
400
+ }
401
+
402
+ /**
403
+ * Normalizes a host path to forward slashes.
404
+ *
405
+ * @param path - A host path.
406
+ * @returns The same path with every backslash replaced by a slash.
407
+ */
408
+ export function normalizeSlashes(path: string): string {
409
+ return path.split('\\').join('/')
410
+ }
411
+
412
+ /**
413
+ * Reads the value that follows a flag.
414
+ *
415
+ * @remarks
416
+ * Reads `--flag value` pairs: the first occurrence of a flag wins, a following value that opens
417
+ * with `--` is a missing value, and the `--flag=value` form is not read.
418
+ *
419
+ * @param argv - The arguments after the script path.
420
+ * @param name - The flag, such as `--consumer`.
421
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
422
+ * another flag.
423
+ */
424
+ export function readOption(argv: readonly string[], name: string): string | undefined {
425
+ const index = argv.indexOf(name)
426
+ const value = index === -1 ? undefined : argv[index + 1]
427
+ return value === undefined || value.startsWith('--') ? undefined : value
428
+ }
429
+
430
+ /**
431
+ * Names the value flags a caller gave with no value.
432
+ *
433
+ * @param argv - The arguments after the script path.
434
+ * @param names - The flags that take a value.
435
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
436
+ */
437
+ export function readMissingFlags(
438
+ argv: readonly string[],
439
+ names: readonly string[],
440
+ ): readonly string[] {
441
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
442
+ }
443
+
444
+ /**
445
+ * Reads every value that follows a repeatable flag.
446
+ *
447
+ * @param argv - The arguments after the script path.
448
+ * @param name - The flag, such as `--source`.
449
+ * @returns The values in order, empty when the flag never appears.
450
+ */
451
+ export function readOptions(argv: readonly string[], name: string): readonly string[] {
452
+ return argv.flatMap((argument, index) => {
453
+ const value = argv[index + 1]
454
+ return argument === name && value !== undefined && !value.startsWith('--') ? [value] : []
455
+ })
456
+ }
457
+
458
+ /**
459
+ * Parses a whole text as one JSON object.
460
+ *
461
+ * @param text - A JSON document, formatted or not, such as a manifest.
462
+ * @returns The parsed object, or undefined when the text is not a JSON object.
463
+ */
464
+ export function parseJsonRecord(text: string): Record<string, unknown> | undefined {
465
+ try {
466
+ const parsed: unknown = JSON.parse(text)
467
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
468
+ return Object.fromEntries(Object.entries(parsed))
469
+ }
470
+ } catch {}
471
+ return undefined
472
+ }
473
+
474
+ /**
475
+ * Reads a string member of a record.
476
+ *
477
+ * @param record - The record, or undefined.
478
+ * @param key - The member name.
479
+ * @returns The string, or undefined when the member is absent or not a string.
480
+ */
481
+ export function readString(
482
+ record: Record<string, unknown> | undefined,
483
+ key: string,
484
+ ): string | undefined {
485
+ const value = record?.[key]
486
+ return typeof value === 'string' ? value : undefined
487
+ }
488
+
489
+ /**
490
+ * Resolves the npm command for this host.
491
+ *
492
+ * @remarks
493
+ * The `npm-cli.js` entry beside the running Node binary spawns without a shell on every host; the
494
+ * `npm` shim on Windows is a `.cmd` file that needs one, so that path is the fallback alone.
495
+ *
496
+ * @returns The file to spawn, the arguments that precede npm's own, and whether a shell is needed.
497
+ */
498
+ export function resolveNpm(): NpmCommand {
499
+ const cli = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
500
+ if (existsSync(cli)) return { file: process.execPath, prefix: [cli], shell: false }
501
+ return { file: 'npm', prefix: [], shell: process.platform === 'win32' }
502
+ }
503
+
504
+ /**
505
+ * Runs npm with the given arguments and returns the finished child.
506
+ *
507
+ * @param args - The npm arguments, such as `['install']`.
508
+ * @param cwd - The working directory. Default: the current directory.
509
+ * @returns The spawn result with `utf8` stdout and stderr.
510
+ */
511
+ export function runNpm(args: readonly string[], cwd?: string): SpawnSyncReturns<string> {
512
+ const npm = resolveNpm()
513
+ return spawnSync(npm.file, [...npm.prefix, ...args], {
514
+ encoding: 'utf8',
515
+ windowsHide: true,
516
+ shell: npm.shell,
517
+ maxBuffer: 64 * 1024 * 1024,
518
+ ...(cwd === undefined ? {} : { cwd }),
519
+ })
520
+ }
521
+
522
+ /**
523
+ * Checks whether a ledger key is a package name that stays inside `node_modules`.
524
+ *
525
+ * @param name - The ledger key.
526
+ * @returns True if the key is an optional scope and a name of registry-safe characters within the
527
+ * registry's length limit and holding no `..`; false otherwise.
528
+ */
529
+ export function isPackageName(name: string): boolean {
274
530
  return (
275
531
  name.length <= PACKAGE_NAME_LIMIT && !name.includes('..') && PACKAGE_NAME_PATTERN.test(name)
276
532
  )
277
533
  }
278
534
 
279
- function isManifestField(value: unknown): value is ManifestField {
535
+ /**
536
+ * Checks whether a value names a manifest field a pin rewrites.
537
+ *
538
+ * @param value - The value to inspect.
539
+ * @returns True if the value is `dependencies` or `devDependencies`; false otherwise.
540
+ */
541
+ export function isManifestField(value: unknown): value is ManifestField {
280
542
  return value === 'dependencies' || value === 'devDependencies'
281
543
  }
282
544
 
283
- function isLedgerEntry(value: unknown): value is LedgerEntry {
545
+ /**
546
+ * Checks whether a value is a package row of the ledger.
547
+ *
548
+ * @param value - The value to inspect.
549
+ * @returns True if the value carries the tarball, hash, source, and time strings and each optional
550
+ * member in its declared type; false otherwise.
551
+ */
552
+ export function isLedgerEntry(value: unknown): value is LedgerEntry {
284
553
  return (
285
554
  isRecord(value) &&
286
555
  typeof value.tarball === 'string' &&
@@ -295,7 +564,14 @@ function isLedgerEntry(value: unknown): value is LedgerEntry {
295
564
  )
296
565
  }
297
566
 
298
- function isOverrideEntry(value: unknown): value is OverrideEntry {
567
+ /**
568
+ * Checks whether a value is an override row of the ledger.
569
+ *
570
+ * @param value - The value to inspect.
571
+ * @returns True if the value carries the value and time strings and an optional replaced string;
572
+ * false otherwise.
573
+ */
574
+ export function isOverrideEntry(value: unknown): value is OverrideEntry {
299
575
  return (
300
576
  isRecord(value) &&
301
577
  typeof value.value === 'string' &&
@@ -304,8 +580,16 @@ function isOverrideEntry(value: unknown): value is OverrideEntry {
304
580
  )
305
581
  }
306
582
 
307
- // A ledger with one malformed row is refused whole, so a restore never drops a range it cannot read.
308
- function parseLedger(value: unknown): Ledger | undefined {
583
+ /**
584
+ * Parses a ledger record.
585
+ *
586
+ * @remarks
587
+ * A ledger with one malformed row is refused whole, so a restore never drops a range it cannot read.
588
+ *
589
+ * @param value - The parsed ledger file.
590
+ * @returns The ledger, or undefined when a block is missing or any row is malformed.
591
+ */
592
+ export function parseLedger(value: unknown): Ledger | undefined {
309
593
  if (!isRecord(value) || !isRecord(value.packages) || !isRecord(value.overrides)) return undefined
310
594
  const rows = Object.entries(value.packages)
311
595
  const packages = rows.flatMap(([name, entry]): Array<[string, LedgerEntry]> =>
@@ -324,28 +608,66 @@ function parseLedger(value: unknown): Ledger | undefined {
324
608
  }
325
609
  }
326
610
 
327
- function serializeJSON(value: unknown): string {
611
+ /**
612
+ * Serializes a value as every Orkestrel manifest is written.
613
+ *
614
+ * @param value - The value to serialize.
615
+ * @returns Tab-indented JSON with a trailing newline.
616
+ */
617
+ export function serializeJSON(value: unknown): string {
328
618
  return `${JSON.stringify(value, null, '\t')}\n`
329
619
  }
330
620
 
331
- function readBlock(manifest: Manifest, field: string): Readonly<Record<string, unknown>> {
621
+ /**
622
+ * Reads one dependency-style block of a manifest.
623
+ *
624
+ * @param manifest - The manifest.
625
+ * @param field - The block's field name.
626
+ * @returns The block, or an empty record when the field is absent or not a record.
627
+ */
628
+ export function readBlock(manifest: Manifest, field: string): Readonly<Record<string, unknown>> {
332
629
  const block = manifest[field]
333
630
  return isRecord(block) ? block : {}
334
631
  }
335
632
 
336
- function findDeclaration(manifest: Manifest, name: string): Declaration | undefined {
633
+ /**
634
+ * Finds the field that declares a package.
635
+ *
636
+ * @param manifest - The manifest.
637
+ * @param name - The package name.
638
+ * @returns The field and spec of the first of `dependencies` and `devDependencies` that holds a
639
+ * string spec for the name, or undefined when neither does.
640
+ */
641
+ export function findDeclaration(manifest: Manifest, name: string): Declaration | undefined {
337
642
  const field = FIELDS.find((candidate) => typeof readBlock(manifest, candidate)[name] === 'string')
338
643
  const spec = field === undefined ? undefined : readBlock(manifest, field)[name]
339
644
  return field === undefined || typeof spec !== 'string' ? undefined : { field, spec }
340
645
  }
341
646
 
342
- function compareKeys(left: string, right: string): number {
647
+ /**
648
+ * Orders two keys by their code units.
649
+ *
650
+ * @param left - One key.
651
+ * @param right - The other key.
652
+ * @returns A negative number, 0, or a positive number as the left key sorts before, equal to, or
653
+ * after the right.
654
+ */
655
+ export function compareKeys(left: string, right: string): number {
343
656
  return left < right ? -1 : left > right ? 1 : 0
344
657
  }
345
658
 
346
- // A block whose keys are sorted stays sorted, because oxfmt sorts a dependency or override block;
347
- // any other block takes the added keys last.
348
- function mergeEntries(
659
+ /**
660
+ * Merges added entries into a block's kept entries.
661
+ *
662
+ * @remarks
663
+ * A block whose keys are sorted stays sorted, because oxfmt sorts a dependency or override block;
664
+ * any other block takes the added keys last.
665
+ *
666
+ * @param kept - The entries the block keeps, in order.
667
+ * @param added - The entries the block gains.
668
+ * @returns The merged entries.
669
+ */
670
+ export function mergeEntries(
349
671
  kept: ReadonlyArray<readonly [string, unknown]>,
350
672
  added: ReadonlyArray<readonly [string, unknown]>,
351
673
  ): ReadonlyArray<readonly [string, unknown]> {
@@ -356,9 +678,23 @@ function mergeEntries(
356
678
  return sorted ? merged.toSorted(([left], [right]) => compareKeys(left, right)) : merged
357
679
  }
358
680
 
359
- // A created block lands before the first key oxfmt sorts after `overrides`, or last when every key
360
- // sorts ahead of it.
361
- function placeBlock(manifest: Manifest, field: string, block: Record<string, unknown>): Manifest {
681
+ /**
682
+ * Inserts a created block where oxfmt sorts it.
683
+ *
684
+ * @remarks
685
+ * A created block lands before the first key oxfmt sorts after `overrides`, or last when every key
686
+ * sorts ahead of it.
687
+ *
688
+ * @param manifest - The manifest.
689
+ * @param field - The new block's field name.
690
+ * @param block - The new block.
691
+ * @returns The manifest with the block inserted.
692
+ */
693
+ export function placeBlock(
694
+ manifest: Manifest,
695
+ field: string,
696
+ block: Record<string, unknown>,
697
+ ): Manifest {
362
698
  const entries = Object.entries(manifest)
363
699
  const follower = entries.findIndex(([key]) => !OVERRIDE_PRECEDERS.includes(key))
364
700
  const position = follower === -1 ? entries.length : follower
@@ -369,10 +705,21 @@ function placeBlock(manifest: Manifest, field: string, block: Record<string, unk
369
705
  ])
370
706
  }
371
707
 
372
- // A changed key keeps its position, a key mapped to undefined is removed, and an `overrides` block
373
- // left empty is removed unless `keepEmpty` is set. A block that is absent and gains nothing is not
374
- // created.
375
- function rewriteBlock(
708
+ /**
709
+ * Rewrites one block of a manifest.
710
+ *
711
+ * @remarks
712
+ * A changed key keeps its position, a key mapped to undefined is removed, and an `overrides` block
713
+ * left empty is removed unless `keepEmpty` is set. A block that is absent and gains nothing is not
714
+ * created.
715
+ *
716
+ * @param manifest - The manifest.
717
+ * @param field - The block's field name.
718
+ * @param changes - The new spec for each changed key, or undefined to remove the key.
719
+ * @param keepEmpty - If `true`, an `overrides` block left empty stays; if `false`, it is removed.
720
+ * @returns The rewritten manifest.
721
+ */
722
+ export function rewriteBlock(
376
723
  manifest: Manifest,
377
724
  field: string,
378
725
  changes: ReadonlyMap<string, string | undefined>,
@@ -401,12 +748,26 @@ function rewriteBlock(
401
748
  )
402
749
  }
403
750
 
404
- function renderValue(value: unknown): string | undefined {
751
+ /**
752
+ * Renders a manifest spec for a change line.
753
+ *
754
+ * @param value - The spec, or undefined for an absent entry.
755
+ * @returns The string itself, the JSON of any other value, or undefined for undefined.
756
+ */
757
+ export function renderValue(value: unknown): string | undefined {
405
758
  if (value === undefined) return undefined
406
759
  return typeof value === 'string' ? value : JSON.stringify(value)
407
760
  }
408
761
 
409
- function diffManifests(before: Manifest, after: Manifest): readonly Change[] {
762
+ /**
763
+ * Lists the entries that differ between two manifests.
764
+ *
765
+ * @param before - The manifest before.
766
+ * @param after - The manifest after.
767
+ * @returns One change per entry of `dependencies`, `devDependencies`, and `overrides` whose rendered
768
+ * spec differs.
769
+ */
770
+ export function diffManifests(before: Manifest, after: Manifest): readonly Change[] {
410
771
  return ['dependencies', 'devDependencies', 'overrides'].flatMap((block) => {
411
772
  const from = readBlock(before, block)
412
773
  const to = readBlock(after, block)
@@ -419,19 +780,46 @@ function diffManifests(before: Manifest, after: Manifest): readonly Change[] {
419
780
  })
420
781
  }
421
782
 
422
- function buildTarballName(name: string, version: string, sha256: string): string {
783
+ /**
784
+ * Builds a tarball's file name.
785
+ *
786
+ * @param name - The package name.
787
+ * @param version - The package version.
788
+ * @param sha256 - The archive's SHA-256 digest.
789
+ * @returns The scope and name joined by a dash, the version, and the first twelve hex digits of the
790
+ * digest.
791
+ */
792
+ export function buildTarballName(name: string, version: string, sha256: string): string {
423
793
  return `${name.replace(/^@/u, '').replace('/', '-')}-${version}-${sha256.slice(0, 12)}.tgz`
424
794
  }
425
795
 
426
- // npm resolves a dependency's relative file: spec against that dependency's own directory, so only an
427
- // absolute spec resolves inside a packed consumer installed elsewhere.
428
- function buildTarballSpec(consumer: string, file: string): string {
796
+ /**
797
+ * Builds the spec a manifest holds for a tarball.
798
+ *
799
+ * @remarks
800
+ * npm resolves a dependency's relative file: spec against that dependency's own directory, so only
801
+ * an absolute spec resolves inside a packed consumer installed elsewhere.
802
+ *
803
+ * @param consumer - The consumer directory.
804
+ * @param file - The tarball's file name.
805
+ * @returns The absolute `file:` spec with forward slashes.
806
+ */
807
+ export function buildTarballSpec(consumer: string, file: string): string {
429
808
  return `file:${normalizeSlashes(resolve(consumer, ...TARBALL_DIRECTORY.split('/'), file))}`
430
809
  }
431
810
 
432
- // The relative `file:tmp/tarballs/<file>` form is what earlier pins wrote, so a re-pin over it keeps
433
- // the range the first pin replaced.
434
- function isTarballSpec(spec: string): boolean {
811
+ /**
812
+ * Checks whether a spec names a tarball in a tarball directory.
813
+ *
814
+ * @remarks
815
+ * The relative `file:tmp/tarballs/<file>` form is what earlier pins wrote, so a re-pin over it keeps
816
+ * the range the first pin replaced.
817
+ *
818
+ * @param spec - The manifest spec.
819
+ * @returns True if the spec is a `file:` spec into the relative or an absolute tarball directory;
820
+ * false otherwise.
821
+ */
822
+ export function isTarballSpec(spec: string): boolean {
435
823
  if (!spec.startsWith('file:')) return false
436
824
  const path = normalizeSlashes(spec.slice('file:'.length))
437
825
  return (
@@ -440,7 +828,16 @@ function isTarballSpec(spec: string): boolean {
440
828
  )
441
829
  }
442
830
 
443
- function buildLedgerEntry(
831
+ /**
832
+ * Builds the ledger row for one packed source.
833
+ *
834
+ * @param manifest - The consumer manifest before the pin.
835
+ * @param ledger - The ledger before the pin.
836
+ * @param tarball - The packed source.
837
+ * @param at - The time of the pin.
838
+ * @returns The row; a re-pin over a tarball spec keeps the range the first pin replaced.
839
+ */
840
+ export function buildLedgerEntry(
444
841
  manifest: Manifest,
445
842
  ledger: Ledger,
446
843
  tarball: Tarball,
@@ -463,11 +860,23 @@ function buildLedgerEntry(
463
860
  }
464
861
  }
465
862
 
466
- // The replaced range is kept from the first pin while the manifest still holds a tarball spec, and a
467
- // package no field declares is recorded without a field and takes effect through its override alone.
468
- // The first pin records whether the manifest held an overrides block, so a restore keeps one that
469
- // existed and removes one the pin created.
470
- function planPin(
863
+ /**
864
+ * Plans a pin.
865
+ *
866
+ * @remarks
867
+ * The replaced range is kept from the first pin while the manifest still holds a tarball spec, and a
868
+ * package no field declares is recorded without a field and takes effect through its override alone.
869
+ * The first pin records whether the manifest held an overrides block, so a restore keeps one that
870
+ * existed and removes one the pin created.
871
+ *
872
+ * @param manifest - The consumer manifest.
873
+ * @param ledger - The ledger before the pin.
874
+ * @param tarballs - The packed or planned sources.
875
+ * @param overrides - The names to override.
876
+ * @param at - The time of the pin.
877
+ * @returns The manifest and ledger to write and the changes between the manifests.
878
+ */
879
+ export function planPin(
471
880
  manifest: Manifest,
472
881
  ledger: Ledger,
473
882
  tarballs: readonly Tarball[],
@@ -531,8 +940,22 @@ function planPin(
531
940
  }
532
941
  }
533
942
 
534
- // An empty name list restores every recorded package and override.
535
- function planRestore(manifest: Manifest, ledger: Ledger, names: readonly string[]): RestorePlan {
943
+ /**
944
+ * Plans a restore.
945
+ *
946
+ * @remarks
947
+ * An empty name list restores every recorded package and override.
948
+ *
949
+ * @param manifest - The consumer manifest.
950
+ * @param ledger - The ledger before the restore.
951
+ * @param names - The packages to restore, or none for every one.
952
+ * @returns The manifest and ledger to write, the restored rows, and the overrides dropped.
953
+ */
954
+ export function planRestore(
955
+ manifest: Manifest,
956
+ ledger: Ledger,
957
+ names: readonly string[],
958
+ ): RestorePlan {
536
959
  const targets = names.length === 0 ? Object.keys(ledger.packages) : names
537
960
  const selected = new Set(targets)
538
961
  const rows = targets.flatMap((name): Array<{ name: string; entry: LedgerEntry }> => {
@@ -590,7 +1013,17 @@ function planRestore(manifest: Manifest, ledger: Ledger, names: readonly string[
590
1013
  }
591
1014
  }
592
1015
 
593
- function scanPinRequest(
1016
+ /**
1017
+ * Lists the problems that refuse a pin request.
1018
+ *
1019
+ * @param text - The manifest file's text.
1020
+ * @param manifest - The parsed manifest.
1021
+ * @param ledger - The ledger before the pin.
1022
+ * @param sources - The sources to pin.
1023
+ * @param overrides - The names to override.
1024
+ * @returns One message per problem, empty when the request is usable.
1025
+ */
1026
+ export function scanPinRequest(
594
1027
  text: string,
595
1028
  manifest: Manifest,
596
1029
  ledger: Ledger,
@@ -632,7 +1065,14 @@ function scanPinRequest(
632
1065
  ]
633
1066
  }
634
1067
 
635
- function parseVersion(text: string): Version | undefined {
1068
+ /**
1069
+ * Parses a full semantic version.
1070
+ *
1071
+ * @param text - A version such as `1.2.3`, `v1.2.3-rc.1`, or `1.2.3+build`, with surrounding
1072
+ * whitespace ignored.
1073
+ * @returns The version, or undefined when the text is not a full version. Build metadata is dropped.
1074
+ */
1075
+ export function parseVersion(text: string): Version | undefined {
636
1076
  const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(
637
1077
  text.trim(),
638
1078
  )
@@ -645,7 +1085,16 @@ function parseVersion(text: string): Version | undefined {
645
1085
  }
646
1086
  }
647
1087
 
648
- function comparePrerelease(left: string, right: string): number {
1088
+ /**
1089
+ * Orders two prerelease identifier lists.
1090
+ *
1091
+ * @param left - One prerelease text, such as `rc.1`.
1092
+ * @param right - The other.
1093
+ * @returns A negative number, 0, or a positive number as the left sorts before, equal to, or after
1094
+ * the right: numeric identifiers compare as numbers and sort before alphanumeric ones, and a longer
1095
+ * list sorts after its prefix.
1096
+ */
1097
+ export function comparePrerelease(left: string, right: string): number {
649
1098
  const leftParts = left.split('.')
650
1099
  const rightParts = right.split('.')
651
1100
  const first = leftParts
@@ -661,7 +1110,15 @@ function comparePrerelease(left: string, right: string): number {
661
1110
  return first ?? leftParts.length - rightParts.length
662
1111
  }
663
1112
 
664
- function compareVersions(left: Version, right: Version): number {
1113
+ /**
1114
+ * Orders two versions.
1115
+ *
1116
+ * @param left - One version.
1117
+ * @param right - The other.
1118
+ * @returns A negative number, 0, or a positive number as the left sorts before, equal to, or after
1119
+ * the right; a release sorts after its prerelease.
1120
+ */
1121
+ export function compareVersions(left: Version, right: Version): number {
665
1122
  const core = [left.major - right.major, left.minor - right.minor, left.patch - right.patch].find(
666
1123
  (order) => order !== 0,
667
1124
  )
@@ -672,9 +1129,17 @@ function compareVersions(left: Version, right: Version): number {
672
1129
  return comparePrerelease(left.pre, right.pre)
673
1130
  }
674
1131
 
675
- // Reads `*`, `x`, an exact version, `^`, `~`, and the `>=`, `>`, `<=`, `<`, and `=` operators on full
676
- // versions; an empty list matches every version.
677
- function parseComparators(token: string): readonly Comparator[] | undefined {
1132
+ /**
1133
+ * Parses one token of a range into comparators.
1134
+ *
1135
+ * @remarks
1136
+ * Reads `*`, `x`, an exact version, `^`, `~`, and the `>=`, `>`, `<=`, `<`, and `=` operators on full
1137
+ * versions; an empty list matches every version.
1138
+ *
1139
+ * @param token - One whitespace-free token of a range.
1140
+ * @returns The comparators the token means, or undefined when the token is not in the subset.
1141
+ */
1142
+ export function parseComparators(token: string): readonly Comparator[] | undefined {
678
1143
  if (token === '*' || token === 'x' || token === 'X' || token === '') return []
679
1144
  const operator = /^(\^|~|>=|<=|>|<|=)?(.+)$/u.exec(token)
680
1145
  const version = operator === null ? undefined : parseVersion(operator[2] ?? '')
@@ -707,7 +1172,14 @@ function parseComparators(token: string): readonly Comparator[] | undefined {
707
1172
  return [{ operator: '=', version }]
708
1173
  }
709
1174
 
710
- function matchesComparator(version: Version, comparator: Comparator): boolean {
1175
+ /**
1176
+ * Checks whether a version satisfies one comparator.
1177
+ *
1178
+ * @param version - The version.
1179
+ * @param comparator - The comparator.
1180
+ * @returns True if the version stands in the comparator's relation to its bound; false otherwise.
1181
+ */
1182
+ export function matchesComparator(version: Version, comparator: Comparator): boolean {
711
1183
  const order = compareVersions(version, comparator.version)
712
1184
  if (comparator.operator === '>=') return order >= 0
713
1185
  if (comparator.operator === '>') return order > 0
@@ -716,7 +1188,14 @@ function matchesComparator(version: Version, comparator: Comparator): boolean {
716
1188
  return order === 0
717
1189
  }
718
1190
 
719
- function supportsRange(range: string): boolean {
1191
+ /**
1192
+ * Checks whether the script reads every token of a range.
1193
+ *
1194
+ * @param range - A registry range.
1195
+ * @returns True if every token of every `||` set is in the subset {@link parseComparators} reads;
1196
+ * false otherwise.
1197
+ */
1198
+ export function supportsRange(range: string): boolean {
720
1199
  return range.split('||').every((set) =>
721
1200
  set
722
1201
  .trim()
@@ -725,9 +1204,19 @@ function supportsRange(range: string): boolean {
725
1204
  )
726
1205
  }
727
1206
 
728
- // A prerelease satisfies a set only when one of its comparators names a prerelease of the same
729
- // `major.minor.patch`, and an unreadable range is satisfied by nothing.
730
- function matchesRange(text: string, range: string): boolean {
1207
+ /**
1208
+ * Checks whether a version satisfies a range.
1209
+ *
1210
+ * @remarks
1211
+ * A prerelease satisfies a set only when one of its comparators names a prerelease of the same
1212
+ * `major.minor.patch`, and an unreadable range is satisfied by nothing.
1213
+ *
1214
+ * @param text - The version.
1215
+ * @param range - A registry range.
1216
+ * @returns True if the version parses and satisfies every comparator of at least one `||` set;
1217
+ * false otherwise.
1218
+ */
1219
+ export function matchesRange(text: string, range: string): boolean {
731
1220
  const version = parseVersion(text)
732
1221
  if (version === undefined) return false
733
1222
  return range.split('||').some((set) => {
@@ -750,7 +1239,28 @@ function matchesRange(text: string, range: string): boolean {
750
1239
  })
751
1240
  }
752
1241
 
753
- function parseCommand(argv: readonly string[]): CommandResult {
1242
+ /**
1243
+ * Parses the command line.
1244
+ *
1245
+ * @param argv - The arguments after the script path.
1246
+ * @returns The command, or the reason the line is refused: no mode or two modes, a flag the mode does
1247
+ * not read (the `--flag=value` form included), a flag with no value, no consumer, or a pin with no
1248
+ * source.
1249
+ */
1250
+ export function parseCommand(argv: readonly string[]): CommandResult {
1251
+ const known = MODES.flatMap((candidate) => [
1252
+ `--${candidate}`,
1253
+ ...VALUE_FLAGS[candidate],
1254
+ ...SWITCH_FLAGS[candidate],
1255
+ ])
1256
+ // A bare `--` ends the arguments the script reads; nothing after it is a flag of this script.
1257
+ const end = argv.indexOf('--')
1258
+ const foreign = (end === -1 ? argv : argv.slice(0, end)).filter(
1259
+ (token) => token.startsWith('--') && !known.includes(token),
1260
+ )
1261
+ if (foreign.length > 0) {
1262
+ return { ok: false, message: `${foreign.join(', ')} is not a flag this script reads` }
1263
+ }
754
1264
  const given = MODES.filter((candidate) => argv.includes(`--${candidate}`))
755
1265
  const mode = MODES.find((candidate) => argv.includes(`--${candidate}`))
756
1266
  if (mode === undefined)
@@ -796,23 +1306,53 @@ function parseCommand(argv: readonly string[]): CommandResult {
796
1306
  }
797
1307
  }
798
1308
 
799
- function computeHash(value: Uint8Array): string {
1309
+ /**
1310
+ * Computes the SHA-256 digest of bytes.
1311
+ *
1312
+ * @param value - The bytes.
1313
+ * @returns The digest as lowercase hexadecimal.
1314
+ */
1315
+ export function computeHash(value: Uint8Array): string {
800
1316
  return createHash('sha256').update(value).digest('hex')
801
1317
  }
802
1318
 
803
- function loadJSONRecord(path: string): Record<string, unknown> | undefined {
1319
+ /**
1320
+ * Loads a JSON file as a record.
1321
+ *
1322
+ * @param path - The file.
1323
+ * @returns The record, or undefined when the file is absent or not a JSON object.
1324
+ */
1325
+ export function loadJSONRecord(path: string): Record<string, unknown> | undefined {
804
1326
  return existsSync(path) ? parseJsonRecord(readFileSync(path, 'utf8')) : undefined
805
1327
  }
806
1328
 
807
- function loadSource(directory: string): Source | undefined {
1329
+ /**
1330
+ * Loads a source package's name and version.
1331
+ *
1332
+ * @param directory - The source directory.
1333
+ * @returns The source, or undefined when its manifest lacks a name or a version.
1334
+ */
1335
+ export function loadSource(directory: string): Source | undefined {
808
1336
  const manifest = loadJSONRecord(join(directory, 'package.json'))
809
1337
  const name = readString(manifest, 'name')
810
1338
  const version = readString(manifest, 'version')
811
1339
  return name === undefined || version === undefined ? undefined : { directory, name, version }
812
1340
  }
813
1341
 
814
- // npm 11 prints an array and npm 12 an object keyed by package name.
815
- function readPack(text: string): { readonly filename: string; readonly files: readonly string[] } {
1342
+ /**
1343
+ * Reads the archive name and file list out of `npm pack --json` output.
1344
+ *
1345
+ * @remarks
1346
+ * npm 11 prints an array and npm 12 an object keyed by package name.
1347
+ *
1348
+ * @param text - npm's standard output.
1349
+ * @returns The archive's file name and its sorted, forward-slash file paths.
1350
+ * @throws Thrown when the output is not JSON or lacks a file name or a file list.
1351
+ */
1352
+ export function readPack(text: string): {
1353
+ readonly filename: string
1354
+ readonly files: readonly string[]
1355
+ } {
816
1356
  const parsed: unknown = JSON.parse(text)
817
1357
  const entry: unknown = Array.isArray(parsed)
818
1358
  ? parsed[0]
@@ -831,7 +1371,15 @@ function readPack(text: string): { readonly filename: string; readonly files: re
831
1371
  return { filename: entry.filename, files: files.toSorted() }
832
1372
  }
833
1373
 
834
- function invokeNpm(args: readonly string[], cwd: string): string {
1374
+ /**
1375
+ * Runs npm and returns its standard output.
1376
+ *
1377
+ * @param args - The npm arguments.
1378
+ * @param cwd - The working directory.
1379
+ * @returns npm's standard output.
1380
+ * @throws Thrown when npm cannot start or exits nonzero, naming the command and the stderr tail.
1381
+ */
1382
+ export function invokeNpm(args: readonly string[], cwd: string): string {
835
1383
  const result = runNpm(args, cwd)
836
1384
  if (result.error !== undefined) {
837
1385
  throw new Error(`npm ${args.join(' ')} could not start in ${cwd}: ${result.error.message}`)
@@ -844,7 +1392,13 @@ function invokeNpm(args: readonly string[], cwd: string): string {
844
1392
  return result.stdout
845
1393
  }
846
1394
 
847
- function inspectCheckout(directory: string): {
1395
+ /**
1396
+ * Reads a checkout's commit and whether it is dirty.
1397
+ *
1398
+ * @param directory - The checkout.
1399
+ * @returns The HEAD commit and the dirty flag, each undefined when git cannot answer.
1400
+ */
1401
+ export function inspectCheckout(directory: string): {
848
1402
  readonly commit: string | undefined
849
1403
  readonly dirty: boolean | undefined
850
1404
  } {
@@ -864,20 +1418,46 @@ function inspectCheckout(directory: string): {
864
1418
  }
865
1419
  }
866
1420
 
867
- function replaceFile(path: string, text: string): void {
1421
+ /**
1422
+ * Replaces a file by renaming a temporary file onto it.
1423
+ *
1424
+ * @param path - The file.
1425
+ * @param text - Its new text.
1426
+ * @returns Nothing after the rename.
1427
+ * @throws Thrown when the write or the rename fails.
1428
+ */
1429
+ export function replaceFile(path: string, text: string): void {
868
1430
  const temporary = `${path}.${String(process.pid)}.tmp`
869
1431
  writeFileSync(temporary, text)
870
1432
  renameSync(temporary, path)
871
1433
  }
872
1434
 
873
- // Vite keys its pre-bundle on the installed version, so a same-version tarball would serve stale bytes.
874
- function installConsumer(consumer: string): void {
1435
+ /**
1436
+ * Installs a consumer's dependencies and clears the Vite pre-bundle.
1437
+ *
1438
+ * @remarks
1439
+ * Vite keys its pre-bundle on the installed version, so a same-version tarball would serve stale bytes.
1440
+ *
1441
+ * @param consumer - The consumer directory.
1442
+ * @returns Nothing after the install.
1443
+ * @throws Thrown when the install fails.
1444
+ */
1445
+ export function installConsumer(consumer: string): void {
875
1446
  invokeNpm(['install', '--ignore-scripts', '--no-audit', '--no-fund'], consumer)
876
1447
  removeTree(join(consumer, 'node_modules', '.vite'))
877
1448
  }
878
1449
 
879
- // The package's own `node_modules` is skipped, and a link is listed as `path (link)`.
880
- function collectFiles(directory: string, prefix = ''): readonly string[] {
1450
+ /**
1451
+ * Lists the files of an installed package.
1452
+ *
1453
+ * @remarks
1454
+ * The package's own `node_modules` is skipped, and a link is listed as `path (link)`.
1455
+ *
1456
+ * @param directory - The package directory.
1457
+ * @param prefix - The path of `directory` inside the package. Default: none.
1458
+ * @returns The sorted forward-slash paths.
1459
+ */
1460
+ export function collectFiles(directory: string, prefix = ''): readonly string[] {
881
1461
  return readdirSync(directory, { withFileTypes: true })
882
1462
  .flatMap((entry): readonly string[] => {
883
1463
  const path = prefix === '' ? entry.name : `${prefix}/${entry.name}`
@@ -892,7 +1472,13 @@ function collectFiles(directory: string, prefix = ''): readonly string[] {
892
1472
  .toSorted()
893
1473
  }
894
1474
 
895
- function computeInventory(directory: string): string {
1475
+ /**
1476
+ * Computes the digest of an installed package's bytes.
1477
+ *
1478
+ * @param directory - The package directory.
1479
+ * @returns The SHA-256 of the sorted path and file-digest lines.
1480
+ */
1481
+ export function computeInventory(directory: string): string {
896
1482
  const lines = collectFiles(directory).map((path) => {
897
1483
  const file = join(directory, ...path.split('/'))
898
1484
  return `${path}\t${existsSync(file) ? computeHash(readFileSync(file)) : 'unreadable'}`
@@ -900,7 +1486,14 @@ function computeInventory(directory: string): string {
900
1486
  return computeHash(Buffer.from(lines.join('\n')))
901
1487
  }
902
1488
 
903
- function computeScope(
1489
+ /**
1490
+ * Computes the digest of every top-level `@orkestrel` package the pin does not name.
1491
+ *
1492
+ * @param consumer - The consumer directory.
1493
+ * @param excluded - The package names to leave out.
1494
+ * @returns The digest of each package by name, `link` for a link, empty when the scope is absent.
1495
+ */
1496
+ export function computeScope(
904
1497
  consumer: string,
905
1498
  excluded: ReadonlySet<string>,
906
1499
  ): Readonly<Record<string, string>> {
@@ -916,7 +1509,14 @@ function computeScope(
916
1509
  )
917
1510
  }
918
1511
 
919
- function describeMoved(
1512
+ /**
1513
+ * Names the packages an install added, removed, or changed.
1514
+ *
1515
+ * @param before - The scope digests before the install.
1516
+ * @param after - The scope digests after.
1517
+ * @returns One message per package whose digest differs.
1518
+ */
1519
+ export function describeMoved(
920
1520
  before: Readonly<Record<string, string>>,
921
1521
  after: Readonly<Record<string, string>>,
922
1522
  ): readonly string[] {
@@ -929,8 +1529,17 @@ function describeMoved(
929
1529
  })
930
1530
  }
931
1531
 
932
- // A link is reported and not entered.
933
- function scanModules(directory: string, names: ReadonlySet<string>): readonly Copy[] {
1532
+ /**
1533
+ * Finds every installed copy of the named packages.
1534
+ *
1535
+ * @remarks
1536
+ * A link is reported and not entered.
1537
+ *
1538
+ * @param directory - A `node_modules` directory.
1539
+ * @param names - The package names to find.
1540
+ * @returns Each copy at any depth of nested `node_modules`.
1541
+ */
1542
+ export function scanModules(directory: string, names: ReadonlySet<string>): readonly Copy[] {
934
1543
  if (!existsSync(directory)) return []
935
1544
  return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
936
1545
  if (entry.name.startsWith('.')) return []
@@ -959,14 +1568,32 @@ function scanModules(directory: string, names: ReadonlySet<string>): readonly Co
959
1568
  })
960
1569
  }
961
1570
 
962
- function locateInstalled(consumer: string, name: string): string {
1571
+ /**
1572
+ * Locates a package's top-level install path.
1573
+ *
1574
+ * @param consumer - The consumer directory.
1575
+ * @param name - The package name.
1576
+ * @returns The path under the consumer's `node_modules`.
1577
+ */
1578
+ export function locateInstalled(consumer: string, name: string): string {
963
1579
  return join(consumer, 'node_modules', ...name.split('/'))
964
1580
  }
965
1581
 
966
- // A source that is itself pinned was packed with its registry ranges, so its installed manifest is
967
- // compared to those bytes rather than to the source's own manifest. Every pinned source must be
968
- // installed at the top level, so an override that selects no installed dependency fails the proof.
969
- function proveInstalled(
1582
+ /**
1583
+ * Lists the problems with one pinned package's install.
1584
+ *
1585
+ * @remarks
1586
+ * A source that is itself pinned was packed with its registry ranges, so its installed manifest is
1587
+ * compared to those bytes rather than to the source's own manifest. Every pinned source must be
1588
+ * installed at the top level, so an override that selects no installed dependency fails the proof.
1589
+ *
1590
+ * @param consumer - The consumer directory.
1591
+ * @param tarball - The packed source.
1592
+ * @param declared - If `true`, the consumer declares the package; if `false`, only an override names it.
1593
+ * @param copies - Every installed copy of the pinned packages.
1594
+ * @returns One message per problem, empty when the install proves the pin.
1595
+ */
1596
+ export function proveInstalled(
970
1597
  consumer: string,
971
1598
  tarball: Tarball,
972
1599
  declared: boolean,
@@ -1008,16 +1635,33 @@ function proveInstalled(
1008
1635
  ]
1009
1636
  }
1010
1637
 
1011
- function loadResolved(consumer: string, name: string): string | undefined {
1638
+ /**
1639
+ * Reads the `resolved` address the lockfile records for a package.
1640
+ *
1641
+ * @param consumer - The consumer directory.
1642
+ * @param name - The package name.
1643
+ * @returns The address, or undefined when the lockfile is absent or records none.
1644
+ */
1645
+ export function loadResolved(consumer: string, name: string): string | undefined {
1012
1646
  const packages = loadJSONRecord(join(consumer, 'package-lock.json'))?.packages
1013
1647
  const entry = isRecord(packages) ? packages[`node_modules/${name}`] : undefined
1014
1648
  return readString(isRecord(entry) ? entry : undefined, 'resolved')
1015
1649
  }
1016
1650
 
1017
- // A registry range needs a real directory whose version the range accepts and a lockfile `resolved`
1018
- // that is a registry URL; a `file:` directory range needs a link to that directory or a directory of
1019
- // its version; any other spec is reported as unproven.
1020
- function proveRestored(consumer: string, name: string, range: string): readonly string[] {
1651
+ /**
1652
+ * Lists the problems with one restored package's install.
1653
+ *
1654
+ * @remarks
1655
+ * A registry range needs a real directory whose version the range accepts and a lockfile `resolved`
1656
+ * that is a registry URL; a `file:` directory range needs a link to that directory or a directory of
1657
+ * its version; any other spec is reported as unproven.
1658
+ *
1659
+ * @param consumer - The consumer directory.
1660
+ * @param name - The package name.
1661
+ * @param range - The restored range.
1662
+ * @returns One message per problem, empty when the install proves the restore.
1663
+ */
1664
+ export function proveRestored(consumer: string, name: string, range: string): readonly string[] {
1021
1665
  const installed = locateInstalled(consumer, name)
1022
1666
  if (!existsSync(installed)) return [`${name} is not installed`]
1023
1667
  const link = lstatSync(installed).isSymbolicLink()
@@ -1044,29 +1688,65 @@ function proveRestored(consumer: string, name: string, range: string): readonly
1044
1688
  ]
1045
1689
  }
1046
1690
 
1047
- function locateLedger(consumer: string): string {
1691
+ /**
1692
+ * Locates a consumer's ledger file.
1693
+ *
1694
+ * @param consumer - The consumer directory.
1695
+ * @returns The ledger's path.
1696
+ */
1697
+ export function locateLedger(consumer: string): string {
1048
1698
  return join(consumer, ...TARBALL_DIRECTORY.split('/'), LEDGER_FILE)
1049
1699
  }
1050
1700
 
1051
- // An absent ledger is an empty one; a malformed ledger is undefined.
1052
- function loadLedger(consumer: string): Ledger | undefined {
1701
+ /**
1702
+ * Loads a consumer's ledger.
1703
+ *
1704
+ * @remarks
1705
+ * An absent ledger is an empty one; a malformed ledger is undefined.
1706
+ *
1707
+ * @param consumer - The consumer directory.
1708
+ * @returns The ledger, or undefined when the file is malformed.
1709
+ */
1710
+ export function loadLedger(consumer: string): Ledger | undefined {
1053
1711
  const path = locateLedger(consumer)
1054
1712
  if (!existsSync(path)) return { packages: {}, overrides: {} }
1055
1713
  return parseLedger(loadJSONRecord(path))
1056
1714
  }
1057
1715
 
1058
- function readLedger(consumer: string): Ledger {
1716
+ /**
1717
+ * Reads a consumer's ledger.
1718
+ *
1719
+ * @param consumer - The consumer directory.
1720
+ * @returns The ledger; an absent file reads as an empty one.
1721
+ * @throws Thrown when the ledger file is malformed.
1722
+ */
1723
+ export function readLedger(consumer: string): Ledger {
1059
1724
  const ledger = loadLedger(consumer)
1060
1725
  if (ledger === undefined)
1061
1726
  throw new Error(`${normalizeSlashes(locateLedger(consumer))} is malformed`)
1062
1727
  return ledger
1063
1728
  }
1064
1729
 
1065
- function renderStamp(at: string): string {
1730
+ /**
1731
+ * Renders a time as a file-name stamp.
1732
+ *
1733
+ * @param at - An ISO time.
1734
+ * @returns The time with each colon and dot replaced by a dash.
1735
+ */
1736
+ export function renderStamp(at: string): string {
1066
1737
  return at.replace(/[:.]/gu, '-')
1067
1738
  }
1068
1739
 
1069
- function writeReceipt(
1740
+ /**
1741
+ * Writes a receipt create-only under the working directory.
1742
+ *
1743
+ * @param consumer - The consumer directory, whose name the file carries.
1744
+ * @param at - The time of the run.
1745
+ * @param receipt - The receipt record.
1746
+ * @returns The receipt's forward-slash path.
1747
+ * @throws Thrown when the file already exists or cannot be written.
1748
+ */
1749
+ export function writeReceipt(
1070
1750
  consumer: string,
1071
1751
  at: string,
1072
1752
  receipt: Readonly<Record<string, unknown>>,
@@ -1077,7 +1757,15 @@ function writeReceipt(
1077
1757
  return normalizeSlashes(path)
1078
1758
  }
1079
1759
 
1080
- function printReport(
1760
+ /**
1761
+ * Prints a report.
1762
+ *
1763
+ * @param json - If `true`, prints the report as one JSON line; if `false`, prints the text lines.
1764
+ * @param lines - The text lines.
1765
+ * @param report - The JSON report.
1766
+ * @returns Nothing after the write.
1767
+ */
1768
+ export function printReport(
1081
1769
  json: boolean,
1082
1770
  lines: readonly string[],
1083
1771
  report: Readonly<Record<string, unknown>>,
@@ -1085,43 +1773,86 @@ function printReport(
1085
1773
  process.stdout.write(json ? `${JSON.stringify(report)}\n` : `${lines.join('\n')}\n`)
1086
1774
  }
1087
1775
 
1088
- function reportUsage(message: string): number {
1776
+ /**
1777
+ * Prints a usage refusal.
1778
+ *
1779
+ * @param message - What was refused.
1780
+ * @returns The usage exit code.
1781
+ */
1782
+ export function reportUsage(message: string): number {
1089
1783
  process.stderr.write(`swap: ${message}\n${USAGE}\n`)
1090
1784
  return EXIT_USAGE
1091
1785
  }
1092
1786
 
1093
- function hasPins(ledger: Ledger): boolean {
1787
+ /**
1788
+ * Checks whether a ledger records any pin.
1789
+ *
1790
+ * @param ledger - The ledger.
1791
+ * @returns True if it holds a package or an override row; false otherwise.
1792
+ */
1793
+ export function hasPins(ledger: Ledger): boolean {
1094
1794
  return Object.keys(ledger.packages).length > 0 || Object.keys(ledger.overrides).length > 0
1095
1795
  }
1096
1796
 
1097
- // The manifest a source is packed with: its own, with the ranges and overrides its ledger replaced
1098
- // put back when it carries pins.
1099
- function loadPackedManifest(directory: string): Manifest {
1797
+ /**
1798
+ * Loads the manifest a source is packed with.
1799
+ *
1800
+ * @remarks
1801
+ * It is the source's own, with the ranges and overrides its ledger replaced put back when it carries
1802
+ * pins.
1803
+ *
1804
+ * @param directory - The source directory.
1805
+ * @returns The manifest to pack.
1806
+ * @throws Thrown when the manifest is unreadable or the ledger is malformed.
1807
+ */
1808
+ export function loadPackedManifest(directory: string): Manifest {
1100
1809
  const manifest = loadJSONRecord(join(directory, 'package.json'))
1101
1810
  if (manifest === undefined) throw new Error(`${directory}/package.json is unreadable`)
1102
1811
  const ledger = readLedger(directory)
1103
1812
  return hasPins(ledger) ? planRestore(manifest, ledger, []).manifest : manifest
1104
1813
  }
1105
1814
 
1106
- // The text a pinned source's manifest is rewritten to for the pack; undefined when the source carries
1107
- // no pins and packs its own manifest.
1108
- function readPackedManifest(directory: string): string | undefined {
1815
+ /**
1816
+ * Reads the text a pinned source's manifest is rewritten to for the pack.
1817
+ *
1818
+ * @param directory - The source directory.
1819
+ * @returns The text, or undefined when the source carries no pins and packs its own manifest.
1820
+ */
1821
+ export function readPackedManifest(directory: string): string | undefined {
1109
1822
  return hasPins(readLedger(directory)) ? serializeJSON(loadPackedManifest(directory)) : undefined
1110
1823
  }
1111
1824
 
1112
- // A source that packs a local dependency spec ships a tarball that resolves only on this host, whether
1113
- // or not a ledger recorded the spec.
1114
- function scanPackedSpecs(source: Source): readonly string[] {
1825
+ /**
1826
+ * Lists the local dependency specs a source would pack.
1827
+ *
1828
+ * @remarks
1829
+ * A source that packs a local dependency spec ships a tarball that resolves only on this host,
1830
+ * whether or not a ledger recorded the spec.
1831
+ *
1832
+ * @param source - The source.
1833
+ * @returns One message per local spec in the manifest the source packs.
1834
+ */
1835
+ export function scanPackedSpecs(source: Source): readonly string[] {
1115
1836
  const manifest = loadPackedManifest(source.directory)
1116
1837
  return scanLocalSpecs(manifest).map(
1117
1838
  ([path, spec]) => `${source.name} would pack a local dependency spec: ${path} is ${spec}`,
1118
1839
  )
1119
1840
  }
1120
1841
 
1121
- // A source that carries a ledger of its own has its manifest rewritten to the recorded ranges for the
1122
- // pack and restored byte for byte whether the pack succeeds or throws, so a consumer never receives a
1123
- // tarball spec that resolves only inside the source.
1124
- function packSource(directory: string, scratch: string): string {
1842
+ /**
1843
+ * Packs a source.
1844
+ *
1845
+ * @remarks
1846
+ * A source that carries a ledger of its own has its manifest rewritten to the recorded ranges for
1847
+ * the pack and restored byte for byte whether the pack succeeds or throws, so a consumer never
1848
+ * receives a tarball spec that resolves only inside the source.
1849
+ *
1850
+ * @param directory - The source directory.
1851
+ * @param scratch - The directory that receives the archive.
1852
+ * @returns npm's `pack --json` output.
1853
+ * @throws Thrown when the pack fails.
1854
+ */
1855
+ export function packSource(directory: string, scratch: string): string {
1125
1856
  const args = ['pack', '--ignore-scripts', '--json', '--pack-destination', scratch]
1126
1857
  const packed = readPackedManifest(directory)
1127
1858
  if (packed === undefined) return invokeNpm(args, directory)
@@ -1135,7 +1866,16 @@ function packSource(directory: string, scratch: string): string {
1135
1866
  }
1136
1867
  }
1137
1868
 
1138
- function packSources(
1869
+ /**
1870
+ * Builds and packs each source into the consumer's tarball directory.
1871
+ *
1872
+ * @param sources - The sources.
1873
+ * @param build - If `true`, runs `npm run build` in each source first; if `false`, packs it as is.
1874
+ * @param consumer - The consumer directory.
1875
+ * @returns One packed tarball per source.
1876
+ * @throws Thrown when a build or a pack fails.
1877
+ */
1878
+ export function packSources(
1139
1879
  sources: readonly Source[],
1140
1880
  build: boolean,
1141
1881
  consumer: string,
@@ -1165,7 +1905,14 @@ function packSources(
1165
1905
  }
1166
1906
  }
1167
1907
 
1168
- function planTarballs(sources: readonly Source[], consumer: string): readonly Tarball[] {
1908
+ /**
1909
+ * Plans the tarballs of a dry run without building or packing.
1910
+ *
1911
+ * @param sources - The sources.
1912
+ * @param consumer - The consumer directory.
1913
+ * @returns One planned tarball per source, with a pending hash and no files.
1914
+ */
1915
+ export function planTarballs(sources: readonly Source[], consumer: string): readonly Tarball[] {
1169
1916
  return sources.map((source) => {
1170
1917
  const file = buildTarballName(source.name, source.version, '<sha256:12>')
1171
1918
  return {
@@ -1179,12 +1926,32 @@ function planTarballs(sources: readonly Source[], consumer: string): readonly Ta
1179
1926
  })
1180
1927
  }
1181
1928
 
1182
- function renderChange(change: Change): string {
1929
+ /**
1930
+ * Renders a manifest change as a text line.
1931
+ *
1932
+ * @param change - The change.
1933
+ * @returns The line, naming the block, the package, and both specs.
1934
+ */
1935
+ export function renderChange(change: Change): string {
1183
1936
  return `swap: ${change.block}.${change.name} ${change.from ?? '(absent)'} -> ${change.to ?? '(absent)'}`
1184
1937
  }
1185
1938
 
1186
- // A proved pin records the digest of its installed bytes, which `--status` compares the install to.
1187
- function recordInventory(consumer: string, ledger: Ledger, tarballs: readonly Tarball[]): Ledger {
1939
+ /**
1940
+ * Records the digest of each proved install in the ledger.
1941
+ *
1942
+ * @remarks
1943
+ * A proved pin records the digest of its installed bytes, which `--status` compares the install to.
1944
+ *
1945
+ * @param consumer - The consumer directory.
1946
+ * @param ledger - The ledger.
1947
+ * @param tarballs - The pinned tarballs.
1948
+ * @returns The ledger with an inventory on each package that is installed as a real directory.
1949
+ */
1950
+ export function recordInventory(
1951
+ consumer: string,
1952
+ ledger: Ledger,
1953
+ tarballs: readonly Tarball[],
1954
+ ): Ledger {
1188
1955
  const proved = new Map(
1189
1956
  tarballs.flatMap((tarball): Array<[string, string]> => {
1190
1957
  const top = locateInstalled(consumer, tarball.source.name)
@@ -1204,7 +1971,14 @@ function recordInventory(consumer: string, ledger: Ledger, tarballs: readonly Ta
1204
1971
  }
1205
1972
  }
1206
1973
 
1207
- function executePin(command: Command): number {
1974
+ /**
1975
+ * Runs the pin mode.
1976
+ *
1977
+ * @param command - The parsed command.
1978
+ * @returns 0 when the pin installed and proved, 1 when a proof failed, 64 on a usage refusal.
1979
+ * @throws Thrown when a build, a pack, or the install fails.
1980
+ */
1981
+ export function executePin(command: Command): number {
1208
1982
  const manifestPath = join(command.consumer, 'package.json')
1209
1983
  if (!existsSync(manifestPath)) return reportUsage(`${command.consumer} has no package.json`)
1210
1984
  const text = readFileSync(manifestPath, 'utf8')
@@ -1308,9 +2082,18 @@ function executePin(command: Command): number {
1308
2082
  return failures.length === 0 ? 0 : EXIT_FAILURE
1309
2083
  }
1310
2084
 
1311
- // The re-pin arguments name every source the restored rows recorded and every override they
1312
- // dropped, so the pins can come back after a verb that needed the registry ranges.
1313
- function buildRepin(consumer: string, plan: RestorePlan): readonly string[] {
2085
+ /**
2086
+ * Builds the arguments that pin again what a restore undid.
2087
+ *
2088
+ * @remarks
2089
+ * The re-pin arguments name every source the restored rows recorded and every override they
2090
+ * dropped, so the pins can come back after a verb that needed the registry ranges.
2091
+ *
2092
+ * @param consumer - The consumer directory.
2093
+ * @param plan - The restore plan.
2094
+ * @returns The `--pin` argument list.
2095
+ */
2096
+ export function buildRepin(consumer: string, plan: RestorePlan): readonly string[] {
1314
2097
  return [
1315
2098
  '--pin',
1316
2099
  '--consumer',
@@ -1320,10 +2103,19 @@ function buildRepin(consumer: string, plan: RestorePlan): readonly string[] {
1320
2103
  ]
1321
2104
  }
1322
2105
 
1323
- // npm keeps a lock entry whose version satisfies the restored range, so the tarball `resolved` of a
1324
- // pinned package outlives the restore; removing the entry and the installed copy makes the install
1325
- // resolve the package again from the registry.
1326
- function removePinnedInstalls(consumer: string, names: readonly string[]): void {
2106
+ /**
2107
+ * Removes the lock entries and installed copies of the named packages.
2108
+ *
2109
+ * @remarks
2110
+ * npm keeps a lock entry whose version satisfies the restored range, so the tarball `resolved` of a
2111
+ * pinned package outlives the restore; removing the entry and the installed copy makes the install
2112
+ * resolve the package again from the registry.
2113
+ *
2114
+ * @param consumer - The consumer directory.
2115
+ * @param names - The package names.
2116
+ * @returns Nothing after the removal.
2117
+ */
2118
+ export function removePinnedInstalls(consumer: string, names: readonly string[]): void {
1327
2119
  const path = join(consumer, 'package-lock.json')
1328
2120
  const text = existsSync(path) ? readFileSync(path, 'utf8') : undefined
1329
2121
  const lock = text === undefined ? undefined : parseJsonRecord(text)
@@ -1340,9 +2132,13 @@ function removePinnedInstalls(consumer: string, names: readonly string[]): void
1340
2132
  for (const name of names) removeTree(locateInstalled(consumer, name))
1341
2133
  }
1342
2134
 
1343
- // The packages whose lock entry and installed copy the restore removes: each restored row that
1344
- // returns to a registry range, and each dropped override.
1345
- function listRemovals(plan: RestorePlan): readonly string[] {
2135
+ /**
2136
+ * Lists the packages whose lock entry and installed copy a restore removes.
2137
+ *
2138
+ * @param plan - The restore plan.
2139
+ * @returns Each restored row that returns to a registry range, and each dropped override.
2140
+ */
2141
+ export function listRemovals(plan: RestorePlan): readonly string[] {
1346
2142
  return [
1347
2143
  ...new Set([
1348
2144
  ...plan.rows.flatMap((row) =>
@@ -1357,24 +2153,49 @@ function listRemovals(plan: RestorePlan): readonly string[] {
1357
2153
  ]
1358
2154
  }
1359
2155
 
1360
- // Every ledger key becomes a manifest key, a lock key, and an install path, so each must be a package
1361
- // name before the restore writes anything.
1362
- function scanLedgerNames(ledger: Ledger): readonly string[] {
2156
+ /**
2157
+ * Lists the ledger keys that are not package names.
2158
+ *
2159
+ * @remarks
2160
+ * Every ledger key becomes a manifest key, a lock key, and an install path, so each must be a package
2161
+ * name before the restore writes anything.
2162
+ *
2163
+ * @param ledger - The ledger.
2164
+ * @returns One message per package or override key that is not a package name.
2165
+ */
2166
+ export function scanLedgerNames(ledger: Ledger): readonly string[] {
1363
2167
  return [
1364
2168
  ...scanNames('packages', Object.keys(ledger.packages)),
1365
2169
  ...scanNames('overrides', Object.keys(ledger.overrides)),
1366
2170
  ]
1367
2171
  }
1368
2172
 
1369
- function scanNames(block: string, names: readonly string[]): readonly string[] {
2173
+ /**
2174
+ * Lists the keys of one ledger block that are not package names.
2175
+ *
2176
+ * @param block - The block's name.
2177
+ * @param names - The block's keys.
2178
+ * @returns One message per key that is not a package name, with the key JSON-encoded.
2179
+ */
2180
+ export function scanNames(block: string, names: readonly string[]): readonly string[] {
1370
2181
  return names
1371
2182
  .filter((name) => !isPackageName(name))
1372
2183
  .map((name) => `The ledger ${block} key ${JSON.stringify(name)} is not a package name`)
1373
2184
  }
1374
2185
 
1375
- // A name that passes the package-name rules still resolves outside `node_modules` when a scope
1376
- // directory on its way is a link, so the target's real parent is compared to the real `node_modules`.
1377
- function isContained(root: string, target: string): boolean {
2186
+ /**
2187
+ * Checks whether a target stays inside a root.
2188
+ *
2189
+ * @remarks
2190
+ * A name that passes the package-name rules still resolves outside `node_modules` when a scope
2191
+ * directory on its way is a link, so the target's real parent is compared to the real `node_modules`.
2192
+ *
2193
+ * @param root - The `node_modules` directory.
2194
+ * @param target - The path to remove.
2195
+ * @returns True if the target's real location is strictly inside the root's real location; false
2196
+ * otherwise.
2197
+ */
2198
+ export function isContained(root: string, target: string): boolean {
1378
2199
  const parent = dirname(target)
1379
2200
  const inside = relative(
1380
2201
  existsSync(root) ? realpathSync(root) : resolve(root),
@@ -1383,14 +2204,28 @@ function isContained(root: string, target: string): boolean {
1383
2204
  return inside !== '' && !isAbsolute(inside) && inside.split(/[\\/]/u)[0] !== '..'
1384
2205
  }
1385
2206
 
1386
- function scanRemovals(consumer: string, names: readonly string[]): readonly string[] {
2207
+ /**
2208
+ * Lists the removals that would leave `node_modules`.
2209
+ *
2210
+ * @param consumer - The consumer directory.
2211
+ * @param names - The packages to remove.
2212
+ * @returns One message per package whose install path resolves outside `node_modules`.
2213
+ */
2214
+ export function scanRemovals(consumer: string, names: readonly string[]): readonly string[] {
1387
2215
  const root = join(consumer, 'node_modules')
1388
2216
  return names
1389
2217
  .filter((name) => !isContained(root, locateInstalled(consumer, name)))
1390
2218
  .map((name) => `The install of ${name} resolves outside node_modules`)
1391
2219
  }
1392
2220
 
1393
- function executeRestore(command: Command): number {
2221
+ /**
2222
+ * Runs the restore mode.
2223
+ *
2224
+ * @param command - The parsed command.
2225
+ * @returns 0 when the restore installed and proved, 1 when a proof failed, 64 on a usage refusal.
2226
+ * @throws Thrown when the ledger is malformed or the install fails.
2227
+ */
2228
+ export function executeRestore(command: Command): number {
1394
2229
  const manifestPath = join(command.consumer, 'package.json')
1395
2230
  if (!existsSync(manifestPath)) return reportUsage(`${command.consumer} has no package.json`)
1396
2231
  const text = readFileSync(manifestPath, 'utf8')
@@ -1408,8 +2243,8 @@ function executeRestore(command: Command): number {
1408
2243
  const at = new Date().toISOString()
1409
2244
  const plan = planRestore(manifest, ledger, command.packages)
1410
2245
  const removals = listRemovals(plan)
1411
- const escaping = scanRemovals(command.consumer, removals)
1412
- if (escaping.length > 0) return reportUsage(escaping.join('\n'))
2246
+ const refusals = scanRemovals(command.consumer, removals)
2247
+ if (refusals.length > 0) return reportUsage(refusals.join('\n'))
1413
2248
  replaceFile(manifestPath, serializeJSON(plan.manifest))
1414
2249
  replaceFile(locateLedger(command.consumer), serializeJSON(plan.ledger))
1415
2250
  removePinnedInstalls(command.consumer, removals)
@@ -1461,13 +2296,32 @@ function executeRestore(command: Command): number {
1461
2296
  return failures.length === 0 ? 0 : EXIT_FAILURE
1462
2297
  }
1463
2298
 
1464
- // The manifest spec of a tarball resolved to the location it names, relative specs against the
1465
- // consumer, so a spec holds its tarball only when it names the ledger archive itself.
1466
- function locateSpecTarget(consumer: string, spec: string): string {
2299
+ /**
2300
+ * Resolves a manifest tarball spec to the location it names.
2301
+ *
2302
+ * @remarks
2303
+ * Relative specs resolve against the consumer, so a spec holds its tarball only when it names the
2304
+ * ledger archive itself.
2305
+ *
2306
+ * @param consumer - The consumer directory.
2307
+ * @param spec - A `file:` spec.
2308
+ * @returns The location with forward slashes.
2309
+ */
2310
+ export function locateSpecTarget(consumer: string, spec: string): string {
1467
2311
  return normalizeSlashes(resolve(consumer, spec.slice('file:'.length)))
1468
2312
  }
1469
2313
 
1470
- function describeStatus(
2314
+ /**
2315
+ * Lists the drifts one pinned package reports.
2316
+ *
2317
+ * @param name - The package name.
2318
+ * @param entry - Its ledger row.
2319
+ * @param archive - The ledger archive's forward-slash location.
2320
+ * @param report - What the status read: whether the manifest holds the archive, whether the archive
2321
+ * matches its recorded digest, how the package is installed, and whether its source commit moved.
2322
+ * @returns One message per drift, empty when the pin holds.
2323
+ */
2324
+ export function describeStatus(
1471
2325
  name: string,
1472
2326
  entry: LedgerEntry,
1473
2327
  archive: string,
@@ -1497,7 +2351,14 @@ function describeStatus(
1497
2351
  ]
1498
2352
  }
1499
2353
 
1500
- function executeStatus(command: Command): number {
2354
+ /**
2355
+ * Runs the status mode.
2356
+ *
2357
+ * @param command - The parsed command.
2358
+ * @returns 0 when nothing drifted, 3 when something did, 64 on a usage refusal.
2359
+ * @throws Thrown when the ledger is malformed.
2360
+ */
2361
+ export function executeStatus(command: Command): number {
1501
2362
  const manifest = loadJSONRecord(join(command.consumer, 'package.json'))
1502
2363
  if (manifest === undefined) return reportUsage(`${command.consumer} has no readable package.json`)
1503
2364
  const ledger = readLedger(command.consumer)
@@ -1595,7 +2456,14 @@ function executeStatus(command: Command): number {
1595
2456
  return problems.length === 0 ? 0 : EXIT_DRIFT
1596
2457
  }
1597
2458
 
1598
- function main(argv: readonly string[]): number {
2459
+ /**
2460
+ * Runs the mode the arguments name.
2461
+ *
2462
+ * @param argv - The arguments after the script path.
2463
+ * @returns The exit code: 0 on success, 1 when a command or a proof failed, 3 when the status reports
2464
+ * drift, 64 on usage.
2465
+ */
2466
+ export function main(argv: readonly string[]): number {
1599
2467
  const parsed = parseCommand(argv)
1600
2468
  if (!parsed.ok) return reportUsage(parsed.message)
1601
2469
  try {
@@ -1608,4 +2476,6 @@ function main(argv: readonly string[]): number {
1608
2476
  }
1609
2477
  }
1610
2478
 
1611
- process.exitCode = main(process.argv.slice(2))
2479
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
2480
+ process.exitCode = main(process.argv.slice(2))
2481
+ }