@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
@@ -1,10 +1,10 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { copyFileSync, existsSync, lstatSync, readFileSync, mkdirSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { copyFileSync, existsSync, lstatSync, readFileSync, mkdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { parseJsonRecord, isRecord, normalizeSlashes, removeTree, scanLocalSpecs, readJsonObject, readMissingFlags, readOption, readString, runNpm, } from "../../orkestrel-dispatch/scripts/helpers.js";
7
- const STEPS = [
6
+ /** Lists the steps of a visit in the order they run. */
7
+ export const STEPS = [
8
8
  'pin',
9
9
  'commit',
10
10
  'overwrite',
@@ -16,20 +16,268 @@ const STEPS = [
16
16
  'gates',
17
17
  'compare',
18
18
  ];
19
- const GATES = ['format:check', 'lint:check', 'check', 'build', 'test'];
20
- const RUNTIME_FIELDS = ['dependencies', 'peerDependencies'];
21
- const RANGE_FIELDS = [...RUNTIME_FIELDS, 'devDependencies'];
22
- const CATALOG = '.claude/agents/orkestrel.md';
23
- const OFFLINE_REFUSAL = "'catalog' does not take --offline";
24
- // The vendored host is staged from this package's checkout, so overwriting it from the host deletes
25
- // and replaces its own canon.
26
- const SCAFFOLD = '@orkestrel/scaffold';
27
- const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
28
- // The checkout runs the `.ts` sources and a built twin under `dist/agents` runs `.js` siblings.
29
- const EXTENSION = extname(fileURLToPath(import.meta.url));
30
- const COMPARE = fileURLToPath(new URL(`./compare${EXTENSION}`, import.meta.url));
31
- const PINS = fileURLToPath(new URL(`./pins${EXTENSION}`, import.meta.url));
32
- function readManifest(target) {
19
+ /** Lists the package scripts the gates step runs, in order. */
20
+ export const GATES = ['format:check', 'lint:check', 'check', 'build', 'test'];
21
+ /** Lists the manifest fields whose ranges ship to a consumer. */
22
+ export const RUNTIME_FIELDS = ['dependencies', 'peerDependencies'];
23
+ /** Lists the manifest fields whose ranges a visit re-pins. */
24
+ export const RANGE_FIELDS = [...RUNTIME_FIELDS, 'devDependencies'];
25
+ /** Names the agent file that carries the catalog table. */
26
+ export const CATALOG = '.claude/agents/orkestrel.md';
27
+ /** Holds the text an offline overwrite refusal names. */
28
+ export const OFFLINE_REFUSAL = "'catalog' does not take --offline";
29
+ /**
30
+ * Names the package whose checkout the vendored host is staged from.
31
+ *
32
+ * @remarks
33
+ * The vendored host is staged from this package's checkout, so overwriting it from the host deletes
34
+ * and replaces its own canon.
35
+ */
36
+ export const SCAFFOLD = '@orkestrel/scaffold';
37
+ /** Matches a plain version, optionally with a prerelease. */
38
+ export const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
39
+ /**
40
+ * Names the extension of the running file.
41
+ *
42
+ * @remarks
43
+ * The checkout runs the `.ts` sources and a built twin under `dist/agents` runs `.js` siblings.
44
+ */
45
+ export const EXTENSION = extname(fileURLToPath(import.meta.url));
46
+ /** Holds the path of the sibling `compare` script. */
47
+ export const COMPARE = fileURLToPath(new URL(`./compare${EXTENSION}`, import.meta.url));
48
+ /** Holds the path of the sibling `pins` script. */
49
+ export const PINS = fileURLToPath(new URL(`./pins${EXTENSION}`, import.meta.url));
50
+ /**
51
+ * Matches one entry of `git status --porcelain=v1 -z` output.
52
+ *
53
+ * @remarks
54
+ * A rename or copy entry carries its original path as a second NUL-terminated field. The first
55
+ * group holds a rename's new path, the second its original, and the third any other entry's path.
56
+ */
57
+ export const DIRTY_ENTRY = /(?=[RC][^\0]|[^\0][RC])[^\0]{2} ([^\0]*)\0([^\0]*)(?:\0|$)|[^\0]{2} ([^\0]*)(?:\0|$)/gu;
58
+ /** Lists every flag the script reads, value flags and switches alike. */
59
+ export const FLAGS = [
60
+ '--visit',
61
+ '--plan',
62
+ '--target',
63
+ '--offline',
64
+ '--regenerate',
65
+ '--from',
66
+ '--to',
67
+ '--prior',
68
+ '--dry-run',
69
+ '--json',
70
+ '--out',
71
+ '--catalog',
72
+ ];
73
+ /** Holds the usage line the script prints when no mode was given. */
74
+ export const USAGE = 'usage: wave.ts --visit [--target DIR] [--offline] [--regenerate] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE] | --plan [--catalog PATH] [--json]';
75
+ /** Names the manifest fields that hold specs, including nested overrides. */
76
+ export const SPEC_FIELDS = Object.freeze([
77
+ 'dependencies',
78
+ 'devDependencies',
79
+ 'peerDependencies',
80
+ 'optionalDependencies',
81
+ 'overrides',
82
+ ]);
83
+ /** Matches npm local prefixes and path starts, including drive-relative paths. */
84
+ export const LOCAL_SPEC_PATTERN = /^(?:file:|link:|\.|~[\\/]|[\\/]|[A-Za-z]:)/iu;
85
+ /** Matches archive specs with no protocol and a tgz, tar.gz, or tar extension. */
86
+ export const ARCHIVE_SPEC_PATTERN = /^[^:]+\.(?:tgz|tar\.gz|tar)$/iu;
87
+ /** Matches hosted user/repository shorthand even when its name ends in an archive extension. */
88
+ export const SHORTHAND_SPEC_PATTERN = /^[^:@%/\s.-][^:@%/\s]*\/[^:@\s/%]+$/u;
89
+ /**
90
+ * Checks whether a value is a non-null, non-array object.
91
+ *
92
+ * @param value - The value to inspect.
93
+ * @returns True if the value is an object other than null or an array; false otherwise.
94
+ */
95
+ export function isRecord(value) {
96
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
97
+ }
98
+ /**
99
+ * Checks whether npm resolves a spec from the local host.
100
+ *
101
+ * @param spec - The package spec, with leading whitespace ignored.
102
+ * @returns True if the spec names a local path or archive; false otherwise.
103
+ */
104
+ export function matchesLocalSpec(spec) {
105
+ const text = spec.trimStart();
106
+ return (LOCAL_SPEC_PATTERN.test(text) ||
107
+ (ARCHIVE_SPEC_PATTERN.test(text) && !SHORTHAND_SPEC_PATTERN.test(text)));
108
+ }
109
+ /**
110
+ * Collects string specs with the dotted path of each nested record member.
111
+ *
112
+ * @param path - The field path that prefixes each finding.
113
+ * @param value - A string or finite, acyclic manifest record; other values contribute no specs.
114
+ * @returns The path and spec pairs in record order.
115
+ */
116
+ export function collectSpecs(path, value) {
117
+ if (typeof value === 'string')
118
+ return [[path, value]];
119
+ if (!isRecord(value))
120
+ return [];
121
+ return Object.entries(value).flatMap(([key, child]) => collectSpecs(`${path}.${key}`, child));
122
+ }
123
+ /**
124
+ * Finds local specs in the manifest's spec fields, including nested overrides.
125
+ *
126
+ * @param manifest - A finite, acyclic package manifest record.
127
+ * @returns Each local spec's dotted field path and spec, in field and record order.
128
+ */
129
+ export function scanLocalSpecs(manifest) {
130
+ return SPEC_FIELDS.flatMap((field) => collectSpecs(field, manifest[field]).filter(([, spec]) => matchesLocalSpec(spec)));
131
+ }
132
+ /**
133
+ * Removes a tree with bounded retries for transient host removal errors.
134
+ *
135
+ * @param path - The directory to remove; a missing path succeeds.
136
+ * @returns Nothing after the removal succeeds.
137
+ * @throws Thrown when removal fails after the bounded retries or the host refuses the path.
138
+ */
139
+ export function removeTree(path) {
140
+ rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
141
+ }
142
+ /**
143
+ * Normalizes a host path to forward slashes.
144
+ *
145
+ * @param path - A host path.
146
+ * @returns The same path with every backslash replaced by a slash.
147
+ */
148
+ export function normalizeSlashes(path) {
149
+ return path.split('\\').join('/');
150
+ }
151
+ /**
152
+ * Reads the value that follows a flag.
153
+ *
154
+ * @remarks
155
+ * Reads `--flag value` pairs: the first occurrence of a flag wins, a following value that opens
156
+ * with `--` is a missing value, and the `--flag=value` form is not read.
157
+ *
158
+ * @param argv - The arguments after the script path.
159
+ * @param name - The flag, such as `--target`.
160
+ * @returns The value after the flag, or undefined when the flag is absent, last, or followed by
161
+ * another flag.
162
+ */
163
+ export function readOption(argv, name) {
164
+ const index = argv.indexOf(name);
165
+ const value = index === -1 ? undefined : argv[index + 1];
166
+ return value === undefined || value.startsWith('--') ? undefined : value;
167
+ }
168
+ /**
169
+ * Names the value flags a caller gave with no value.
170
+ *
171
+ * @param argv - The arguments after the script path.
172
+ * @param names - The flags that take a value.
173
+ * @returns Each named flag that appears last or is followed by another flag, in argument order.
174
+ */
175
+ export function readMissingFlags(argv, names) {
176
+ return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined);
177
+ }
178
+ /**
179
+ * Names the flags a caller gave that the script does not read.
180
+ *
181
+ * @remarks
182
+ * Reads the arguments before a bare `--`, which opens a command a script passes on. A `--flag=value`
183
+ * argument is unknown, because the script reads no such form.
184
+ *
185
+ * @param argv - The arguments after the script path.
186
+ * @param flags - Every flag the script reads, value flags and switches alike.
187
+ * @returns Each argument that opens with `--` and is not a read flag, in argument order.
188
+ */
189
+ export function readUnknownFlags(argv, flags) {
190
+ const end = argv.indexOf('--');
191
+ return (end === -1 ? argv : argv.slice(0, end)).filter((argument) => argument.startsWith('--') && !flags.includes(argument));
192
+ }
193
+ /**
194
+ * Reads the last non-empty line of a text as a JSON object.
195
+ *
196
+ * @param text - Text whose last line a script printed as JSON.
197
+ * @returns The parsed object, or undefined when the last line is not a JSON object.
198
+ */
199
+ export function parseJsonLine(text) {
200
+ const line = text
201
+ .split(/\r\n|\n/)
202
+ .filter((candidate) => candidate.trim() !== '')
203
+ .at(-1);
204
+ if (line === undefined)
205
+ return undefined;
206
+ try {
207
+ const parsed = JSON.parse(line);
208
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
209
+ return Object.fromEntries(Object.entries(parsed));
210
+ }
211
+ }
212
+ catch { }
213
+ return undefined;
214
+ }
215
+ /**
216
+ * Parses a whole text as one JSON object.
217
+ *
218
+ * @param text - A JSON document, formatted or not, such as a manifest or `npm view --json` output.
219
+ * @returns The parsed object, or undefined when the text is not a JSON object.
220
+ */
221
+ export function parseJsonRecord(text) {
222
+ try {
223
+ const parsed = JSON.parse(text);
224
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
225
+ return Object.fromEntries(Object.entries(parsed));
226
+ }
227
+ }
228
+ catch { }
229
+ return undefined;
230
+ }
231
+ /**
232
+ * Reads a string member of a record.
233
+ *
234
+ * @param record - The record, or undefined.
235
+ * @param key - The member name.
236
+ * @returns The string, or undefined when the member is absent or not a string.
237
+ */
238
+ export function readString(record, key) {
239
+ const value = record?.[key];
240
+ return typeof value === 'string' ? value : undefined;
241
+ }
242
+ /**
243
+ * Resolves the npm command for this host.
244
+ *
245
+ * @remarks
246
+ * The `npm-cli.js` entry beside the running Node binary spawns without a shell on every host; the
247
+ * `npm` shim on Windows is a `.cmd` file that needs one, so that path is the fallback alone.
248
+ *
249
+ * @returns The file to spawn, the arguments that precede npm's own, and whether a shell is needed.
250
+ */
251
+ export function resolveNpm() {
252
+ const cli = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
253
+ if (existsSync(cli))
254
+ return { file: process.execPath, prefix: [cli], shell: false };
255
+ return { file: 'npm', prefix: [], shell: process.platform === 'win32' };
256
+ }
257
+ /**
258
+ * Runs npm with the given arguments and returns the finished child.
259
+ *
260
+ * @param args - The npm arguments, such as `['install']`.
261
+ * @param cwd - The working directory. Default: the current directory.
262
+ * @returns The spawn result with `utf8` stdout and stderr.
263
+ */
264
+ export function runNpm(args, cwd) {
265
+ const npm = resolveNpm();
266
+ return spawnSync(npm.file, [...npm.prefix, ...args], {
267
+ encoding: 'utf8',
268
+ windowsHide: true,
269
+ shell: npm.shell,
270
+ maxBuffer: 64 * 1024 * 1024,
271
+ ...(cwd === undefined ? {} : { cwd }),
272
+ });
273
+ }
274
+ /**
275
+ * Reads a target's package manifest.
276
+ *
277
+ * @param target - The target directory.
278
+ * @returns The manifest record, or undefined when it is absent, unreadable, or not a JSON object.
279
+ */
280
+ export function readManifest(target) {
33
281
  try {
34
282
  return parseJsonRecord(readFileSync(join(target, 'package.json'), 'utf8'));
35
283
  }
@@ -37,7 +285,14 @@ function readManifest(target) {
37
285
  return undefined;
38
286
  }
39
287
  }
40
- function readRanges(manifest, fields) {
288
+ /**
289
+ * Reads the ranges a manifest declares in the named fields.
290
+ *
291
+ * @param manifest - The manifest record.
292
+ * @param fields - The fields to read.
293
+ * @returns Each string range keyed by `field name`.
294
+ */
295
+ export function readRanges(manifest, fields) {
41
296
  return new Map(fields.flatMap((field) => {
42
297
  const block = manifest[field];
43
298
  if (typeof block !== 'object' || block === null)
@@ -45,7 +300,14 @@ function readRanges(manifest, fields) {
45
300
  return Object.entries(block).flatMap(([name, range]) => typeof range === 'string' ? [[`${field} ${name}`, range]] : []);
46
301
  }));
47
302
  }
48
- function compareRanges(published, final) {
303
+ /**
304
+ * Lists how the final ranges differ from the published ones.
305
+ *
306
+ * @param published - The ranges the published manifest declares.
307
+ * @param final - The ranges the target declares now.
308
+ * @returns One message per range added, removed, or changed, sorted by key.
309
+ */
310
+ export function compareRanges(published, final) {
49
311
  const names = [...new Set([...published.keys(), ...final.keys()])].sort();
50
312
  return names.flatMap((name) => {
51
313
  const before = published.get(name);
@@ -59,7 +321,14 @@ function compareRanges(published, final) {
59
321
  return [`${name} ${before} -> ${after}`];
60
322
  });
61
323
  }
62
- function resolveScaffold(target) {
324
+ /**
325
+ * Resolves the scaffold executable a target runs.
326
+ *
327
+ * @param target - The target directory.
328
+ * @returns Node and the installed scaffold entry, else Node and the target's own built entry, else
329
+ * none.
330
+ */
331
+ export function resolveScaffold(target) {
63
332
  const installed = join(target, 'node_modules', '@orkestrel', 'scaffold', 'dist', 'bin', 'main.js');
64
333
  if (existsSync(installed))
65
334
  return [process.execPath, installed];
@@ -68,8 +337,15 @@ function resolveScaffold(target) {
68
337
  return [process.execPath, own];
69
338
  return [];
70
339
  }
71
- function readReleases(text) {
72
- const releases = readJsonObject(text)?.releases;
340
+ /**
341
+ * Reads the release readings out of an audit's standard output.
342
+ *
343
+ * @param text - The audit's standard output, whose last line is the JSON envelope.
344
+ * @returns One release per reading that names a dependency and its range, or undefined when the
345
+ * envelope carries no `releases` list.
346
+ */
347
+ export function readReleases(text) {
348
+ const releases = parseJsonLine(text)?.releases;
73
349
  if (!Array.isArray(releases))
74
350
  return undefined;
75
351
  return releases.flatMap((entry) => {
@@ -83,15 +359,30 @@ function readReleases(text) {
83
359
  return [{ name, range, latest: readString(record, 'latest') }];
84
360
  });
85
361
  }
86
- function describeCommand(file, args) {
362
+ /**
363
+ * Renders a command as the line a step row carries.
364
+ *
365
+ * @param file - The executable.
366
+ * @param args - Its arguments.
367
+ * @returns The command line, naming the running Node binary as `node`.
368
+ */
369
+ export function renderCommand(file, args) {
87
370
  return [file === process.execPath ? 'node' : file, ...args].join(' ');
88
371
  }
89
- // A JSON verb writes its refusal envelope or its partial-run note on stdout, so the stderr tail
90
- // alone reads empty for exactly the failures the operator needs named.
91
- function describeFailure(result) {
372
+ /**
373
+ * Names why a finished command failed.
374
+ *
375
+ * @remarks
376
+ * A JSON verb writes its refusal envelope or its partial-run note on stdout, so the stderr tail
377
+ * alone reads empty for exactly the failures the operator needs named.
378
+ *
379
+ * @param result - The finished command.
380
+ * @returns Nothing for exit 0; else the `code: message` envelope, the `note`, or the last stderr lines.
381
+ */
382
+ export function describeFailure(result) {
92
383
  if (result.status === 0)
93
384
  return undefined;
94
- const value = readJsonObject(result.stdout);
385
+ const value = parseJsonLine(result.stdout);
95
386
  const error = value?.error;
96
387
  if (typeof error === 'object' && error !== null && !Array.isArray(error)) {
97
388
  const envelope = Object.fromEntries(Object.entries(error));
@@ -109,13 +400,33 @@ function describeFailure(result) {
109
400
  .slice(-3)
110
401
  .join(' | ');
111
402
  }
112
- function readVerdict(result) {
403
+ /**
404
+ * Reads the verdict of a finished command by its exit alone.
405
+ *
406
+ * @param result - The finished command.
407
+ * @returns The exit and the failure note.
408
+ */
409
+ export function readVerdict(result) {
113
410
  return { exit: result.status ?? undefined, note: describeFailure(result) };
114
411
  }
115
- // A judge reads the finished command and answers the exit and note its one log entry carries, so no
116
- // entry is amended after it is appended.
117
- function runStep(runner, step, file, args, cwd, judge = readVerdict, input) {
118
- const command = describeCommand(file, args);
412
+ /**
413
+ * Runs a command as one step of the visit and logs its row.
414
+ *
415
+ * @remarks
416
+ * A judge reads the finished command and answers the exit and note its one log entry carries, so no
417
+ * entry is amended after it is appended.
418
+ *
419
+ * @param runner - The visit's state.
420
+ * @param step - The step's name.
421
+ * @param file - The executable.
422
+ * @param args - Its arguments.
423
+ * @param cwd - The working directory.
424
+ * @param judge - Reads the exit and note from the result. Default: {@link readVerdict}.
425
+ * @param input - Standard input for the command. Default: none.
426
+ * @returns The finished command, or undefined under --dry-run.
427
+ */
428
+ export function runStep(runner, step, file, args, cwd, judge = readVerdict, input) {
429
+ const command = renderCommand(file, args);
119
430
  if (runner.dry) {
120
431
  runner.steps.push({ step, command, exit: undefined, durationMs: 0, note: 'dry run' });
121
432
  return undefined;
@@ -133,8 +444,17 @@ function runStep(runner, step, file, args, cwd, judge = readVerdict, input) {
133
444
  appendResult(runner, step, command, result, durationMs, verdict);
134
445
  return result;
135
446
  }
136
- // The audit exits 1 on the drift the pin step exists to close, so its exit is a reading here.
137
- function judgePin(result) {
447
+ /**
448
+ * Judges the pin step's audit.
449
+ *
450
+ * @remarks
451
+ * The audit exits 1 on the drift the pin step exists to close, so its exit is a reading here.
452
+ *
453
+ * @param result - The audit.
454
+ * @returns Exit 0 with the reading count when the audit printed releases and exited 0 or 1; else a
455
+ * failing exit with the reason.
456
+ */
457
+ export function judgePin(result) {
138
458
  const releases = readReleases(result.stdout);
139
459
  if (releases === undefined || (result.status !== 0 && result.status !== 1)) {
140
460
  return {
@@ -144,16 +464,34 @@ function judgePin(result) {
144
464
  }
145
465
  return { exit: 0, note: `${releases.length} release reading(s)` };
146
466
  }
147
- function judgeOverwrite(result, offline) {
467
+ /**
468
+ * Judges the overwrite command.
469
+ *
470
+ * @param result - The overwrite.
471
+ * @param offline - If `true`, an exit of 1 whose note names the catalog refusal passes; if `false`,
472
+ * the exit alone decides.
473
+ * @returns The verdict.
474
+ */
475
+ export function judgeOverwrite(result, offline) {
148
476
  if (!offline || result.status !== 1)
149
477
  return readVerdict(result);
150
- const note = readString(readJsonObject(result.stdout), 'note');
478
+ const note = readString(parseJsonLine(result.stdout), 'note');
151
479
  return note !== undefined && note.includes(OFFLINE_REFUSAL)
152
480
  ? { exit: 0, note: 'offline overwrite skipped the catalog step by design' }
153
481
  : { exit: 1, note: describeFailure(result) };
154
482
  }
155
- // The audit reads the canonical sources of scaffold's own checkout as foreign paths and exits 1.
156
- function judgeVerify(result, own) {
483
+ /**
484
+ * Judges the verify step's audit.
485
+ *
486
+ * @remarks
487
+ * The audit reads the canonical sources of scaffold's own checkout as foreign paths and exits 1.
488
+ *
489
+ * @param result - The audit.
490
+ * @param own - If `true`, the target is scaffold's own checkout and an exit of 1 passes; if `false`,
491
+ * the exit alone decides.
492
+ * @returns The verdict; an audit that printed no releases envelope fails.
493
+ */
494
+ export function judgeVerify(result, own) {
157
495
  if (readReleases(result.stdout) === undefined) {
158
496
  return {
159
497
  exit: result.status === null || result.status === 0 ? 2 : result.status,
@@ -164,7 +502,16 @@ function judgeVerify(result, own) {
164
502
  ? { exit: 0, note: 'foreign-path findings are expected on the scaffold checkout' }
165
503
  : readVerdict(result);
166
504
  }
167
- function runNpmStep(runner, step, args, judge = readVerdict) {
505
+ /**
506
+ * Runs npm as one step of the visit and logs its row.
507
+ *
508
+ * @param runner - The visit's state.
509
+ * @param step - The step's name.
510
+ * @param args - The npm arguments.
511
+ * @param judge - Reads the exit and note from the result. Default: {@link readVerdict}.
512
+ * @returns The finished command, or undefined under --dry-run.
513
+ */
514
+ export function runNpmStep(runner, step, args, judge = readVerdict) {
168
515
  const command = `npm ${args.join(' ')}`;
169
516
  if (runner.dry) {
170
517
  runner.steps.push({
@@ -183,7 +530,19 @@ function runNpmStep(runner, step, args, judge = readVerdict) {
183
530
  appendResult(runner, step, command, result, Date.now() - started, judge(result));
184
531
  return result;
185
532
  }
186
- function writeJournal(runner, step, command, exit, stdout, stderr) {
533
+ /**
534
+ * Writes one step's full output to the visit's journal directory.
535
+ *
536
+ * @param runner - The visit's state.
537
+ * @param step - The step's name.
538
+ * @param command - The command line.
539
+ * @param exit - The exit code.
540
+ * @param stdout - The standard output.
541
+ * @param stderr - The standard error.
542
+ * @returns The journal path, the failure that stopped the write, or neither when the visit keeps no
543
+ * journal.
544
+ */
545
+ export function writeJournal(runner, step, command, exit, stdout, stderr) {
187
546
  if (runner.directory === undefined)
188
547
  return {};
189
548
  const path = join(runner.directory, `${String(runner.steps.length + 1).padStart(2, '0')}-${step}.log`);
@@ -198,7 +557,18 @@ function writeJournal(runner, step, command, exit, stdout, stderr) {
198
557
  return { failure: `journal failed: ${path}: ${String(error)}` };
199
558
  }
200
559
  }
201
- function appendResult(runner, step, command, result, durationMs, verdict) {
560
+ /**
561
+ * Appends a finished command's row to the visit's log.
562
+ *
563
+ * @param runner - The visit's state.
564
+ * @param step - The step's name.
565
+ * @param command - The command line.
566
+ * @param result - The finished command.
567
+ * @param durationMs - How long it ran.
568
+ * @param verdict - The exit and note to record; a journal failure turns the exit into 1.
569
+ * @returns Nothing after the row is appended.
570
+ */
571
+ export function appendResult(runner, step, command, result, durationMs, verdict) {
202
572
  const journal = writeJournal(runner, step, command, result.status ?? undefined, result.stdout ?? '', result.stderr ?? '');
203
573
  runner.steps.push({
204
574
  step,
@@ -213,7 +583,17 @@ function appendResult(runner, step, command, result, durationMs, verdict) {
213
583
  }),
214
584
  });
215
585
  }
216
- function appendStep(runner, step, command, note, exit = 0) {
586
+ /**
587
+ * Appends a row for a step the script ran itself.
588
+ *
589
+ * @param runner - The visit's state.
590
+ * @param step - The step's name.
591
+ * @param command - The label the row carries as its command.
592
+ * @param note - What the step found or did.
593
+ * @param exit - The exit the row records. Default: 0.
594
+ * @returns Nothing after the row is appended.
595
+ */
596
+ export function appendStep(runner, step, command, note, exit = 0) {
217
597
  const journal = runner.dry ? {} : writeJournal(runner, step, command, exit, note, '');
218
598
  runner.steps.push({
219
599
  step,
@@ -224,14 +604,32 @@ function appendStep(runner, step, command, note, exit = 0) {
224
604
  ...(journal.path === undefined ? {} : { journal: journal.path }),
225
605
  });
226
606
  }
227
- function failed(runner) {
228
- return runner.steps.some((entry) => entry.superseded !== true &&
607
+ /**
608
+ * Checks whether a visit's log holds a failure.
609
+ *
610
+ * @remarks
611
+ * The one predicate the visit's steps and the script's exit code share. A superseded row, a row with
612
+ * no exit (a dry run), and a pins or compare row are readings, not failures.
613
+ *
614
+ * @param steps - The rows logged so far.
615
+ * @returns True if a row exited nonzero, was not superseded, and is not a pins or compare row; false
616
+ * otherwise.
617
+ */
618
+ export function failed(steps) {
619
+ return steps.some((entry) => entry.superseded !== true &&
229
620
  entry.exit !== undefined &&
230
621
  entry.exit !== 0 &&
231
622
  entry.step !== 'pins' &&
232
623
  entry.step !== 'compare');
233
624
  }
234
- function extractConflict(result) {
625
+ /**
626
+ * Reads the resolution conflict an install reported.
627
+ *
628
+ * @param result - The finished install.
629
+ * @returns The conflict line npm printed, a generic reason when it printed none, or undefined when
630
+ * the install did not fail with ERESOLVE.
631
+ */
632
+ export function extractConflict(result) {
235
633
  const output = `${result.stdout}\n${result.stderr}`;
236
634
  if (!/\bcode ERESOLVE\b/u.test(output))
237
635
  return undefined;
@@ -239,7 +637,14 @@ function extractConflict(result) {
239
637
  return ((lines.find((line) => line.includes('Conflicting peer dependency:')) ??
240
638
  lines.find((line) => line.includes('Could not resolve dependency:')))?.replace(/^npm (?:error|ERR!)\s*/u, '') ?? 'the install could not move a locked peer');
241
639
  }
242
- function runInstall(runner, step) {
640
+ /**
641
+ * Runs the install and regenerates the lock when asked and the install cannot resolve.
642
+ *
643
+ * @param runner - The visit's state.
644
+ * @param step - The step the rows belong to.
645
+ * @returns Nothing after the rows are appended.
646
+ */
647
+ export function runInstall(runner, step) {
243
648
  // npm can unlink a node_modules junction before reporting its resolution failure.
244
649
  if (!runner.dry &&
245
650
  runner.regenerate &&
@@ -264,14 +669,28 @@ function runInstall(runner, step) {
264
669
  runner.regenerate)
265
670
  regenerateLock(runner, step, installed);
266
671
  }
267
- function refuseLocalSpecs(runner, step) {
672
+ /**
673
+ * Refuses a manifest that holds local dependency specs.
674
+ *
675
+ * @param runner - The visit's state.
676
+ * @param step - The step that refuses.
677
+ * @returns True if a failing row was appended; false when the manifest holds none.
678
+ */
679
+ export function refuseLocalSpecs(runner, step) {
268
680
  const specs = scanLocalSpecs(readManifest(runner.target) ?? {});
269
681
  if (specs.length === 0)
270
682
  return false;
271
683
  appendStep(runner, step, 'scan manifest specs', `local dependency specs: ${specs.map(([path, spec]) => `${path}: ${spec}`).join(', ')}; restore the pins first`, 1);
272
684
  return true;
273
685
  }
274
- function readRegenerationRefusal(runner) {
686
+ /**
687
+ * Names why the lock must not be regenerated.
688
+ *
689
+ * @param runner - The visit's state.
690
+ * @returns The first refusal, or undefined when the lock is tracked and clean, the manifest holds no
691
+ * local spec or workspace, `tmp/` is ignored, and the target is its work tree's root.
692
+ */
693
+ export function readRegenerationRefusal(runner) {
275
694
  if (runStep(runner, 'regenerate', 'git', ['ls-files', '--error-unmatch', 'package-lock.json'], runner.target)?.status !== 0)
276
695
  return 'package-lock.json is untracked';
277
696
  const status = runStep(runner, 'regenerate', 'git', ['status', '--porcelain', '--', 'package-lock.json'], runner.target);
@@ -291,7 +710,16 @@ function readRegenerationRefusal(runner) {
291
710
  return 'the target is not the root of its work tree';
292
711
  return undefined;
293
712
  }
294
- function moveModules(source, target, attempt = 0) {
713
+ /**
714
+ * Renames a directory, retrying while the host holds it.
715
+ *
716
+ * @param source - The directory to move.
717
+ * @param target - Its new path.
718
+ * @param attempt - The retries spent so far. Default: 0.
719
+ * @returns Nothing after the rename.
720
+ * @throws Thrown when the rename fails for another reason or after the retries.
721
+ */
722
+ export function moveModules(source, target, attempt = 0) {
295
723
  try {
296
724
  renameSync(source, target);
297
725
  }
@@ -302,7 +730,15 @@ function moveModules(source, target, attempt = 0) {
302
730
  moveModules(source, target, attempt + 1);
303
731
  }
304
732
  }
305
- function regenerateLock(runner, step, conflict) {
733
+ /**
734
+ * Regenerates the lock and the installed tree from the manifest.
735
+ *
736
+ * @param runner - The visit's state.
737
+ * @param step - The step the rows belong to.
738
+ * @param conflict - The install that failed with ERESOLVE.
739
+ * @returns Nothing after the rows are appended; a failed regeneration restores the tree and lock.
740
+ */
741
+ export function regenerateLock(runner, step, conflict) {
306
742
  const refusal = readRegenerationRefusal(runner);
307
743
  if (refusal !== undefined || runner.directory === undefined) {
308
744
  appendStep(runner, step, 'regenerate lock and modules', `ERESOLVE; regeneration refused: ${refusal ?? 'tmp/ is not git-ignored'}`, 1);
@@ -348,8 +784,18 @@ function regenerateLock(runner, step, conflict) {
348
784
  if (result.listed !== undefined)
349
785
  appendResult(runner, step, 'npm ls --all --json', result.listed, 0, readVerdict(result.listed));
350
786
  }
351
- // Journal writes and backup disposal cannot enter the transaction's rollback boundary.
352
- function regenerateTree(target, manifest, before) {
787
+ /**
788
+ * Installs from a manifest with no lock and proves the tree.
789
+ *
790
+ * @remarks
791
+ * Journal writes and backup disposal cannot enter the transaction's rollback boundary.
792
+ *
793
+ * @param target - The target directory.
794
+ * @param manifest - The manifest bytes before the install.
795
+ * @param before - The SHA-256 of the lock before.
796
+ * @returns Exit 0 when the install, the tree listing, and the unchanged manifest prove it; else exit 1.
797
+ */
798
+ export function regenerateTree(target, manifest, before) {
353
799
  const lock = join(target, 'package-lock.json');
354
800
  try {
355
801
  unlinkSync(lock);
@@ -374,7 +820,15 @@ function regenerateTree(target, manifest, before) {
374
820
  return { exit: 1, note: `ERESOLVE; ${String(error)}` };
375
821
  }
376
822
  }
377
- function discardBackup(target, moved, backup) {
823
+ /**
824
+ * Discards the backups after a proved regeneration.
825
+ *
826
+ * @param target - The target directory.
827
+ * @param moved - The moved previous tree.
828
+ * @param backup - The saved lock.
829
+ * @returns Nothing on success, else the failure with the backup paths kept.
830
+ */
831
+ export function discardBackup(target, moved, backup) {
378
832
  try {
379
833
  for (const path of [moved, backup, join(target, 'node_modules')])
380
834
  validateWavePath(target, path);
@@ -385,7 +839,16 @@ function discardBackup(target, moved, backup) {
385
839
  return `backup cleanup failed: ${String(error)}; proved installation retained; backups: ${backup}; ${moved}`;
386
840
  }
387
841
  }
388
- function restoreLock(target, moved, backup, before) {
842
+ /**
843
+ * Restores the previous tree and lock after a failed regeneration.
844
+ *
845
+ * @param target - The target directory.
846
+ * @param moved - The moved previous tree.
847
+ * @param backup - The saved lock.
848
+ * @param before - The SHA-256 the saved lock must still have.
849
+ * @returns `tree and lock restored`, else the failure with the backup paths kept.
850
+ */
851
+ export function restoreLock(target, moved, backup, before) {
389
852
  try {
390
853
  for (const path of [
391
854
  moved,
@@ -408,7 +871,16 @@ function restoreLock(target, moved, backup, before) {
408
871
  return `rollback failed: ${String(error)}; backups: ${backup}; ${moved}`;
409
872
  }
410
873
  }
411
- function validateWavePath(target, destination) {
874
+ /**
875
+ * Checks that a destination stays inside the target and crosses no link.
876
+ *
877
+ * @param target - The target directory.
878
+ * @param destination - The path to write or remove.
879
+ * @returns Nothing when the path is safe.
880
+ * @throws Thrown when the path resolves outside the target, an ancestor is a link or not a
881
+ * directory, or an ancestor's real location leaves the target's.
882
+ */
883
+ export function validateWavePath(target, destination) {
412
884
  const root = resolve(target);
413
885
  const path = resolve(destination);
414
886
  const location = relative(root, path);
@@ -432,19 +904,26 @@ function validateWavePath(target, destination) {
432
904
  throw new Error(`unsafe wave path: ${entry} resolves outside ${physical}`);
433
905
  }
434
906
  }
435
- function readDirtyPaths(text) {
436
- const [entry, ...rest] = text.split('\0');
437
- if (entry === undefined || entry === '')
438
- return [];
439
- const renamed = /[RC]/u.test(entry.slice(0, 2));
440
- const original = renamed ? rest[0] : undefined;
441
- return [
442
- entry.slice(3),
443
- ...(original === undefined ? [] : [original]),
444
- ...readDirtyPaths((renamed ? rest.slice(1) : rest).join('\0')),
445
- ];
907
+ /**
908
+ * Reads the dirty paths out of `git status --porcelain=v1 -z` output.
909
+ *
910
+ * @remarks
911
+ * One pass over the entries with {@link DIRTY_ENTRY}, so the cost grows with the text and no entry
912
+ * recurses.
913
+ *
914
+ * @param text - The command's standard output.
915
+ * @returns Each entry's path; a rename or copy also lists its original path.
916
+ */
917
+ export function readDirtyPaths(text) {
918
+ return [...text.matchAll(DIRTY_ENTRY)].flatMap((match) => match[1] === undefined ? [match[3] ?? ''] : [match[1], match[2] ?? '']);
446
919
  }
447
- function readReceipt(target) {
920
+ /**
921
+ * Reads the receipt the overwrite step left.
922
+ *
923
+ * @param target - The target directory.
924
+ * @returns The receipt, or undefined when it is absent, unreadable, or lacks a field in its type.
925
+ */
926
+ export function readReceipt(target) {
448
927
  try {
449
928
  const value = parseJsonRecord(readFileSync(join(target, 'tmp', 'units', 'wave', 'overwrite.json'), 'utf8'));
450
929
  if (value === undefined ||
@@ -468,7 +947,15 @@ function readReceipt(target) {
468
947
  return undefined;
469
948
  }
470
949
  }
471
- function runRecord(runner, name) {
950
+ /**
951
+ * Runs the record step.
952
+ *
953
+ * @param runner - The visit's state.
954
+ * @param name - The package name the manifest declares.
955
+ * @returns Nothing after the rows are appended; the overwrite's paths are committed when the receipt
956
+ * attributes every dirty path to them.
957
+ */
958
+ export function runRecord(runner, name) {
472
959
  if (name === SCAFFOLD) {
473
960
  appendStep(runner, 'record', 'git commit', `skipped: ${SCAFFOLD}`);
474
961
  return;
@@ -509,17 +996,26 @@ function runRecord(runner, name) {
509
996
  return;
510
997
  }
511
998
  runStep(runner, 'record', 'git', ['--literal-pathspecs', 'add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'], runner.target, readVerdict, `${dirty.join('\0')}\0`);
512
- if (failed(runner))
999
+ if (failed(runner.steps))
513
1000
  return;
514
1001
  runStep(runner, 'record', 'git', ['commit', '-m', `Overwrite with scaffold ${receipt.version}`], runner.target);
515
- if (failed(runner))
1002
+ if (failed(runner.steps))
516
1003
  return;
517
1004
  runStep(runner, 'record', 'git', ['status', '--porcelain'], runner.target, (result) => result.status === 0 && result.stdout.trim() !== ''
518
1005
  ? { exit: 1, note: `tree remains dirty: ${result.stdout}` }
519
1006
  : readVerdict(result));
520
1007
  }
521
- // A later release of the same name reads the range the earlier reading wrote.
522
- function computeRanges(ranges, releases) {
1008
+ /**
1009
+ * Moves each named dependency to the registry caret of its latest release.
1010
+ *
1011
+ * @remarks
1012
+ * A later release of the same name reads the range the earlier reading wrote.
1013
+ *
1014
+ * @param ranges - The block's ranges by name.
1015
+ * @param releases - The audit's release readings.
1016
+ * @returns The ranges after the moves and one message per move.
1017
+ */
1018
+ export function computeRanges(ranges, releases) {
523
1019
  return releases.reduce((state, release) => {
524
1020
  if (release.latest === undefined || !(release.name in state.ranges))
525
1021
  return state;
@@ -532,7 +1028,14 @@ function computeRanges(ranges, releases) {
532
1028
  };
533
1029
  }, { ranges, moved: [] });
534
1030
  }
535
- function writeRanges(target, releases) {
1031
+ /**
1032
+ * Writes the re-pinned ranges into the target's manifest.
1033
+ *
1034
+ * @param target - The target directory.
1035
+ * @param releases - The audit's release readings.
1036
+ * @returns One message per range moved; the manifest is rewritten only when a range moved.
1037
+ */
1038
+ export function writeRanges(target, releases) {
536
1039
  const manifest = readManifest(target);
537
1040
  if (manifest === undefined)
538
1041
  return [];
@@ -552,7 +1055,16 @@ function writeRanges(target, releases) {
552
1055
  }
553
1056
  return moved;
554
1057
  }
555
- function runPin(runner, scaffoldFile, scaffoldPrefix, offline) {
1058
+ /**
1059
+ * Runs the pin step.
1060
+ *
1061
+ * @param runner - The visit's state.
1062
+ * @param scaffoldFile - The scaffold executable.
1063
+ * @param scaffoldPrefix - The arguments that precede scaffold's own.
1064
+ * @param offline - The `--offline` argument, or none.
1065
+ * @returns Nothing after the rows are appended.
1066
+ */
1067
+ export function runPin(runner, scaffoldFile, scaffoldPrefix, offline) {
556
1068
  const audit = runStep(runner, 'pin', scaffoldFile, [...scaffoldPrefix, 'audit', '--json', ...offline], runner.target, judgePin);
557
1069
  if (audit !== undefined) {
558
1070
  const releases = readReleases(audit.stdout);
@@ -566,7 +1078,13 @@ function runPin(runner, scaffoldFile, scaffoldPrefix, offline) {
566
1078
  }
567
1079
  runInstall(runner, 'pin');
568
1080
  }
569
- function runCommit(runner) {
1081
+ /**
1082
+ * Runs the commit step.
1083
+ *
1084
+ * @param runner - The visit's state.
1085
+ * @returns Nothing after the rows are appended.
1086
+ */
1087
+ export function runCommit(runner) {
570
1088
  if (refuseLocalSpecs(runner, 'commit'))
571
1089
  return;
572
1090
  const status = runStep(runner, 'commit', 'git', ['status', '--porcelain', '--', 'package.json', 'package-lock.json'], runner.target);
@@ -585,10 +1103,19 @@ function runCommit(runner) {
585
1103
  appendStep(runner, 'commit', 'git commit', 'manifest and lockfile already committed');
586
1104
  }
587
1105
  }
588
- function runOverwrite(runner, scaffoldFile, scaffoldPrefix, offline) {
1106
+ /**
1107
+ * Runs the overwrite step.
1108
+ *
1109
+ * @param runner - The visit's state.
1110
+ * @param scaffoldFile - The scaffold executable.
1111
+ * @param scaffoldPrefix - The arguments that precede scaffold's own.
1112
+ * @param offline - The `--offline` argument, or none.
1113
+ * @returns Nothing after the rows are appended and the receipt is written.
1114
+ */
1115
+ export function runOverwrite(runner, scaffoldFile, scaffoldPrefix, offline) {
589
1116
  const overwrite = runStep(runner, 'overwrite', scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline], runner.target, (result) => judgeOverwrite(result, runner.offline));
590
- if (overwrite !== undefined && !failed(runner) && runner.directory !== undefined) {
591
- const receipt = readJsonObject(overwrite.stdout);
1117
+ if (overwrite !== undefined && !failed(runner.steps) && runner.directory !== undefined) {
1118
+ const receipt = parseJsonLine(overwrite.stdout);
592
1119
  const head = runStep(runner, 'overwrite', 'git', ['rev-parse', 'HEAD'], runner.target);
593
1120
  const installed = readManifest(join(runner.target, 'node_modules', '@orkestrel', 'scaffold'));
594
1121
  const version = readString(installed, 'version');
@@ -610,11 +1137,21 @@ function runOverwrite(runner, scaffoldFile, scaffoldPrefix, offline) {
610
1137
  }
611
1138
  }
612
1139
  }
613
- if (!failed(runner)) {
1140
+ if (!failed(runner.steps)) {
614
1141
  runStep(runner, 'overwrite', scaffoldFile, [...scaffoldPrefix, 'audit', ...offline], runner.target);
615
1142
  }
616
1143
  }
617
- function runVerify(runner, scaffoldFile, scaffoldPrefix, offline, own) {
1144
+ /**
1145
+ * Runs the verify step.
1146
+ *
1147
+ * @param runner - The visit's state.
1148
+ * @param scaffoldFile - The scaffold executable.
1149
+ * @param scaffoldPrefix - The arguments that precede scaffold's own.
1150
+ * @param offline - The `--offline` argument, or none.
1151
+ * @param own - If `true`, the target is scaffold's own checkout; if `false`, it is another package.
1152
+ * @returns Nothing after the rows are appended.
1153
+ */
1154
+ export function runVerify(runner, scaffoldFile, scaffoldPrefix, offline, own) {
618
1155
  const audit = runStep(runner, 'verify', scaffoldFile, [...scaffoldPrefix, 'audit', '--json', ...offline], runner.target, (result) => judgeVerify(result, own));
619
1156
  if (audit === undefined)
620
1157
  return;
@@ -628,7 +1165,15 @@ function runVerify(runner, scaffoldFile, scaffoldPrefix, offline, own) {
628
1165
  .map((release) => `${release.name} ${release.range} vs ${String(release.latest)}`)
629
1166
  .join(', '), drift.length === 0 ? 0 : 1);
630
1167
  }
631
- function runPins(runner, before, version) {
1168
+ /**
1169
+ * Runs the pins step.
1170
+ *
1171
+ * @param runner - The visit's state.
1172
+ * @param before - The ranges the manifest declared before the visit.
1173
+ * @param version - The version to sweep for, or undefined when none is known.
1174
+ * @returns The number of hit lines, or undefined when the sweep did not run.
1175
+ */
1176
+ export function runPins(runner, before, version) {
632
1177
  const after = readRanges(readManifest(runner.target) ?? {}, RANGE_FIELDS);
633
1178
  const prior = [...before]
634
1179
  .filter(([name, range]) => after.get(name) !== range)
@@ -642,7 +1187,14 @@ function runPins(runner, before, version) {
642
1187
  ? undefined
643
1188
  : swept.stdout.split(/\r\n|\n/).filter((line) => /^\S+:\d+: /u.test(line)).length;
644
1189
  }
645
- function runCompare(runner, name) {
1190
+ /**
1191
+ * Runs the compare step.
1192
+ *
1193
+ * @param runner - The visit's state.
1194
+ * @param name - The package name the manifest declares.
1195
+ * @returns The bump ruling the two readings reach.
1196
+ */
1197
+ export function runCompare(runner, name) {
646
1198
  const compared = runStep(runner, 'compare', process.execPath, [COMPARE, '--json'], runner.target);
647
1199
  const distMoved = compared === undefined
648
1200
  ? undefined
@@ -663,10 +1215,25 @@ function runCompare(runner, name) {
663
1215
  const ranges = compareRanges(readRanges(published, RUNTIME_FIELDS), readRanges(readManifest(runner.target) ?? {}, RUNTIME_FIELDS));
664
1216
  return { distMoved, rangesMoved: ranges.length > 0, ranges, hits: undefined };
665
1217
  }
666
- function describeReading(moved, movedWord, sameWord) {
1218
+ /**
1219
+ * Words one reading of the ruling.
1220
+ *
1221
+ * @param moved - The reading, or undefined when unanswered.
1222
+ * @param movedWord - The word for a moved reading.
1223
+ * @param sameWord - The word for an unmoved reading.
1224
+ * @returns `unanswered`, the moved word, or the same word.
1225
+ */
1226
+ export function describeChange(moved, movedWord, sameWord) {
667
1227
  return moved === undefined ? 'unanswered' : moved ? movedWord : sameWord;
668
1228
  }
669
- function runVisit(argv) {
1229
+ /**
1230
+ * Runs one repository's visit.
1231
+ *
1232
+ * @param argv - The arguments after the script path.
1233
+ * @returns The finished visit, or undefined when the target carries no readable manifest or no
1234
+ * scaffold executable.
1235
+ */
1236
+ export function runVisit(argv) {
670
1237
  const target = readOption(argv, '--target') ?? process.cwd();
671
1238
  if (!existsSync(join(target, 'package.json'))) {
672
1239
  console.error(`wave: ${target} carries no package.json`);
@@ -711,37 +1278,37 @@ function runVisit(argv) {
711
1278
  }
712
1279
  const before = readRanges(manifest, RANGE_FIELDS);
713
1280
  const offline = runner.offline ? ['--offline'] : [];
714
- if (selected.has('pin') && !failed(runner))
1281
+ if (selected.has('pin') && !failed(runner.steps))
715
1282
  runPin(runner, scaffoldFile, scaffoldPrefix, offline);
716
- if (selected.has('commit') && !failed(runner))
1283
+ if (selected.has('commit') && !failed(runner.steps))
717
1284
  runCommit(runner);
718
- if (selected.has('overwrite') && !failed(runner)) {
1285
+ if (selected.has('overwrite') && !failed(runner.steps)) {
719
1286
  if (name === SCAFFOLD) {
720
- appendStep(runner, 'overwrite', describeCommand(scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline]), `skipped with its audit: ${SCAFFOLD} is the package whose checkout the vendored host is staged from`);
1287
+ appendStep(runner, 'overwrite', renderCommand(scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline]), `skipped with its audit: ${SCAFFOLD} is the package whose checkout the vendored host is staged from`);
721
1288
  }
722
1289
  else
723
1290
  runOverwrite(runner, scaffoldFile, scaffoldPrefix, offline);
724
1291
  }
725
- if (selected.has('verify') && !failed(runner)) {
1292
+ if (selected.has('verify') && !failed(runner.steps)) {
726
1293
  runVerify(runner, scaffoldFile, scaffoldPrefix, offline, name === SCAFFOLD);
727
1294
  }
728
- if (selected.has('install') && !failed(runner))
1295
+ if (selected.has('install') && !failed(runner.steps))
729
1296
  runInstall(runner, 'install');
730
- const hits = selected.has('pins') && !failed(runner)
1297
+ const hits = selected.has('pins') && !failed(runner.steps)
731
1298
  ? runPins(runner, before, readOption(argv, '--prior') ?? version)
732
1299
  : undefined;
733
- if (selected.has('format') && !failed(runner))
1300
+ if (selected.has('format') && !failed(runner.steps))
734
1301
  runNpmStep(runner, 'format', ['run', 'format']);
735
- if (selected.has('record') && !failed(runner))
1302
+ if (selected.has('record') && !failed(runner.steps))
736
1303
  runRecord(runner, name);
737
- if (selected.has('gates') && !failed(runner)) {
1304
+ if (selected.has('gates') && !failed(runner.steps)) {
738
1305
  for (const gate of GATES) {
739
- if (failed(runner))
1306
+ if (failed(runner.steps))
740
1307
  break;
741
1308
  runNpmStep(runner, 'gates', ['run', gate]);
742
1309
  }
743
1310
  }
744
- const compared = selected.has('compare') && !failed(runner)
1311
+ const compared = selected.has('compare') && !failed(runner.steps)
745
1312
  ? runCompare(runner, name)
746
1313
  : { distMoved: undefined, rangesMoved: undefined, ranges: undefined, hits: undefined };
747
1314
  const ruling = { ...compared, hits };
@@ -750,17 +1317,24 @@ function runVisit(argv) {
750
1317
  : ruling.distMoved === undefined || ruling.rangesMoved === undefined
751
1318
  ? 'unanswered'
752
1319
  : 'no bump';
753
- const reading = `dist ${describeReading(ruling.distMoved, 'moved', 'same')}, ranges ${describeReading(ruling.rangesMoved, 'moved', 'same')}${hits === undefined ? '' : `, ${hits} self-pin hit(s)`}`;
1320
+ const changes = `dist ${describeChange(ruling.distMoved, 'moved', 'same')}, ranges ${describeChange(ruling.rangesMoved, 'moved', 'same')}${hits === undefined ? '' : `, ${hits} self-pin hit(s)`}`;
754
1321
  return {
755
1322
  target,
756
1323
  name,
757
1324
  version,
758
1325
  steps: runner.steps,
759
1326
  ruling,
760
- row: `| ${name ?? target} | ${version ?? '?'} | ${bump} | ${reading} | round |`,
1327
+ row: `| ${name ?? target} | ${version ?? '?'} | ${bump} | ${changes} | round |`,
761
1328
  };
762
1329
  }
763
- function readJournalDirectory(target) {
1330
+ /**
1331
+ * Chooses the directory a visit journals its steps in.
1332
+ *
1333
+ * @param target - The target directory.
1334
+ * @returns A new stamped directory under the target's `tmp/units/wave/` when the target is inside a
1335
+ * work tree that ignores `tmp/`, else undefined.
1336
+ */
1337
+ export function readJournalDirectory(target) {
764
1338
  const inside = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
765
1339
  cwd: target,
766
1340
  encoding: 'utf8',
@@ -777,7 +1351,14 @@ function readJournalDirectory(target) {
777
1351
  ? join(target, 'tmp', 'units', 'wave', new Date().toISOString().replace(/[:.]/gu, '-'))
778
1352
  : undefined;
779
1353
  }
780
- function readLayers(catalogPath) {
1354
+ /**
1355
+ * Reads the packages by layer out of the catalog table.
1356
+ *
1357
+ * @param catalogPath - The agent file that carries the marker-bounded table.
1358
+ * @returns Each layer's `name range` members, layers in numeric order, or undefined when the file or
1359
+ * its markers are absent.
1360
+ */
1361
+ export function readLayers(catalogPath) {
781
1362
  if (!existsSync(catalogPath))
782
1363
  return undefined;
783
1364
  const text = readFileSync(catalogPath, 'utf8');
@@ -798,16 +1379,34 @@ function readLayers(catalogPath) {
798
1379
  .map(([layer, members]) => [layer, members.map(({ member }) => member)])
799
1380
  .sort(([left], [right]) => Number(left.slice(1)) - Number(right.slice(1))));
800
1381
  }
801
- function renderVisit(visit) {
1382
+ /**
1383
+ * Renders a visit as text lines.
1384
+ *
1385
+ * @param visit - The finished visit.
1386
+ * @returns One line per step, the ruling, each moved range, and the release row.
1387
+ */
1388
+ export function renderVisit(visit) {
802
1389
  const lines = [
803
1390
  ...visit.steps.map((entry) => `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}`}`),
804
- `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)`}`,
1391
+ `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)`}`,
805
1392
  ...(visit.ruling.ranges ?? []).map((range) => `wave: range ${range}`),
806
1393
  visit.row,
807
1394
  ];
808
1395
  return `${lines.join('\n')}\n`;
809
1396
  }
810
- function main(argv) {
1397
+ /**
1398
+ * Runs the mode the arguments name.
1399
+ *
1400
+ * @param argv - The arguments after the script path.
1401
+ * @returns The exit code: 0 when every step passed, 1 when one failed, 2 when a reading failed, 64
1402
+ * on usage.
1403
+ */
1404
+ export function main(argv) {
1405
+ const unknown = readUnknownFlags(argv, FLAGS);
1406
+ if (unknown.length > 0) {
1407
+ console.error(`wave: unknown flag ${unknown.join(', ')}; give each flag and its value as separate arguments\n${USAGE}`);
1408
+ return 64;
1409
+ }
811
1410
  const missing = readMissingFlags(argv, [
812
1411
  '--target',
813
1412
  '--from',
@@ -842,7 +1441,7 @@ function main(argv) {
842
1441
  return 0;
843
1442
  }
844
1443
  if (!argv.includes('--visit')) {
845
- console.error('usage: wave.ts --visit [--target DIR] [--offline] [--regenerate] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE] | --plan [--catalog PATH] [--json]');
1444
+ console.error(USAGE);
846
1445
  return 64;
847
1446
  }
848
1447
  const from = STEPS.indexOf(readOption(argv, '--from') ?? 'pin');
@@ -868,12 +1467,8 @@ function main(argv) {
868
1467
  writeFileSync(out, output);
869
1468
  console.log(`wave: wrote ${out}`);
870
1469
  }
871
- return visit.steps.some((entry) => entry.superseded !== true &&
872
- entry.exit !== undefined &&
873
- entry.exit !== 0 &&
874
- entry.step !== 'pins' &&
875
- entry.step !== 'compare')
876
- ? 1
877
- : 0;
1470
+ return failed(visit.steps) ? 1 : 0;
1471
+ }
1472
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
1473
+ process.exitCode = main(process.argv.slice(2));
878
1474
  }
879
- process.exitCode = main(process.argv.slice(2));