@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.
- package/dist/agents/skills/orkestrel-dispatch/scripts/bench.js +326 -91
- package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +110 -14
- package/dist/agents/skills/orkestrel-dispatch/scripts/chain.js +280 -0
- package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +188 -26
- package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +142 -43
- package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +240 -62
- package/dist/agents/skills/orkestrel-dispatch/scripts/patch.js +323 -0
- package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +169 -32
- package/dist/agents/skills/orkestrel-dispatch/scripts/sweep.js +290 -69
- package/dist/agents/skills/orkestrel-falsify/scripts/mutate.js +560 -0
- package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +1052 -205
- package/dist/agents/skills/orkestrel-harden/scripts/measure.js +361 -0
- package/dist/agents/skills/orkestrel-harden/scripts/titles.js +455 -80
- package/dist/agents/skills/orkestrel-parallel/scripts/cases.js +327 -0
- package/dist/agents/skills/orkestrel-publish/scripts/compare.js +298 -51
- package/dist/agents/skills/orkestrel-publish/scripts/pins.js +170 -13
- package/dist/agents/skills/orkestrel-publish/scripts/swap.js +956 -191
- package/dist/agents/skills/orkestrel-publish/scripts/wave.js +707 -112
- package/dist/agents/skills/orkestrel-publish/scripts/window.js +235 -41
- package/dist/agents/skills/orkestrel-scout/scripts/map.js +341 -54
- package/dist/agents/templates/brief.md +1 -1
- package/dist/bin/main.js +51 -46
- package/dist/bin/main.js.map +1 -1
- package/dist/host/AGENTS.md +23 -21
- package/dist/host/agents/orchestration.md +43 -18
- package/dist/host/agents/platforms/windows.md +3 -3
- package/dist/host/agents/skills/orkestrel-align/SKILL.md +10 -13
- package/dist/host/agents/skills/orkestrel-align/references/fleet.md +4 -10
- package/dist/host/agents/skills/orkestrel-align/references/integration.md +1 -1
- package/dist/host/agents/skills/orkestrel-build/SKILL.md +16 -48
- package/dist/host/agents/skills/orkestrel-build/agents/openai.yaml +1 -1
- package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +19 -16
- package/dist/host/agents/skills/orkestrel-debrief/agents/openai.yaml +1 -1
- package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +15 -28
- package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +5 -6
- package/dist/host/agents/skills/orkestrel-debrief/references/retention.md +4 -6
- package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +31 -20
- package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +3 -4
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/bench.ts +366 -75
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +126 -16
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/chain.ts +323 -0
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +220 -31
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +176 -33
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +294 -59
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/patch.ts +401 -0
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +199 -34
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/sweep.ts +330 -69
- package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +10 -8
- package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +1 -5
- package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +1 -1
- package/dist/host/agents/skills/orkestrel-falsify/scripts/mutate.ts +701 -0
- package/dist/host/agents/skills/orkestrel-harden/SKILL.md +16 -12
- package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +23 -81
- package/dist/host/agents/skills/orkestrel-harden/references/centralization.md +4 -5
- package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -0
- package/dist/host/agents/skills/orkestrel-harden/references/document.md +33 -0
- package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +6 -3
- package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +8 -4
- package/dist/host/agents/skills/orkestrel-harden/references/research.md +2 -0
- package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +1272 -279
- package/dist/host/agents/skills/orkestrel-harden/scripts/measure.ts +425 -0
- package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +603 -98
- package/dist/host/agents/skills/orkestrel-journey/SKILL.md +75 -98
- package/dist/host/agents/skills/orkestrel-journey/references/captures.md +54 -17
- package/dist/host/agents/skills/orkestrel-journey/references/decide.md +1 -20
- package/dist/host/agents/skills/orkestrel-journey/references/layer.md +21 -31
- package/dist/host/agents/skills/orkestrel-journey/references/styles.md +1 -1
- package/dist/host/agents/skills/orkestrel-parallel/SKILL.md +28 -24
- package/dist/host/agents/skills/orkestrel-parallel/references/claude.md +15 -13
- package/dist/host/agents/skills/orkestrel-parallel/references/codex.md +1 -0
- package/dist/host/agents/skills/orkestrel-parallel/references/cursor.md +13 -13
- package/dist/host/agents/skills/orkestrel-parallel/scripts/cases.ts +389 -0
- package/dist/host/agents/skills/orkestrel-polish/SKILL.md +16 -20
- package/dist/host/agents/skills/orkestrel-polish/references/capture-harness.md +16 -26
- package/dist/host/agents/skills/orkestrel-publish/SKILL.md +12 -26
- package/dist/host/agents/skills/orkestrel-publish/agents/openai.yaml +2 -2
- package/dist/host/agents/skills/orkestrel-publish/references/adopt.md +23 -0
- package/dist/host/agents/skills/orkestrel-publish/references/release.md +4 -4
- package/dist/host/agents/skills/orkestrel-publish/references/wave.md +10 -9
- package/dist/host/agents/skills/orkestrel-publish/references/window.md +20 -25
- package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +348 -59
- package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +202 -22
- package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1061 -191
- package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +812 -140
- package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +280 -38
- package/dist/host/agents/skills/orkestrel-scout/SKILL.md +3 -3
- package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +428 -70
- package/dist/host/agents/templates/brief.md +1 -1
- package/dist/host/agents/transports/claude-cli.md +3 -3
- package/dist/host/agents/transports/codex.md +2 -7
- package/dist/host/agents/transports/cursor.md +2 -3
- package/dist/host/claude/AGENTS.md +2 -2
- package/dist/host/claude/agents/analyst.md +3 -3
- package/dist/host/claude/agents/astra.md +3 -3
- package/dist/host/claude/agents/builder.md +1 -1
- package/dist/host/claude/agents/checker.md +1 -1
- package/dist/host/claude/agents/distiller.md +1 -1
- package/dist/host/claude/agents/grok.md +1 -1
- package/dist/host/claude/agents/opus.md +2 -2
- package/dist/host/claude/agents/orkestrel.md +57 -90
- package/dist/host/claude/agents/researcher.md +1 -1
- package/dist/host/claude/agents/reviewer.md +4 -0
- package/dist/host/claude/agents/scout.md +1 -1
- package/dist/host/claude/agents/verifier.md +1 -1
- package/dist/host/claude/rules/application.md +14 -21
- package/dist/host/claude/rules/architecture.md +34 -44
- package/dist/host/claude/rules/browser.md +3 -4
- package/dist/host/claude/rules/campaign.md +2 -2
- package/dist/host/claude/rules/documentation.md +25 -12
- package/dist/host/claude/rules/names.md +9 -4
- package/dist/host/claude/rules/patterns.md +6 -7
- package/dist/host/claude/rules/portability.md +1 -1
- package/dist/host/claude/rules/quality.md +4 -5
- package/dist/host/claude/rules/styles.md +2 -2
- package/dist/host/claude/rules/tests.md +57 -114
- package/dist/host/claude/rules/typescript.md +7 -9
- package/dist/host/claude/rules/workspace.md +21 -19
- package/dist/host/claude/rules/writing.md +2 -5
- package/dist/host/claude/skills/orkestrel-dispatch/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-harden/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-publish/SKILL.md +1 -1
- package/dist/host/claude/skills/orkestrel-scout/SKILL.md +1 -1
- package/dist/host/codex/agents/analyst.toml +9 -3
- package/dist/host/codex/agents/astra.toml +3 -4
- package/dist/host/codex/agents/builder.toml +1 -1
- package/dist/host/codex/agents/opus.toml +3 -4
- package/dist/host/codex/agents/orkestrel.toml +1 -1
- package/dist/host/codex/agents/researcher.toml +2 -2
- package/dist/host/codex/agents/verifier.toml +1 -1
- package/dist/host/configs/helpers.ts +21 -1
- package/dist/host/configs/policy.ts +299 -15
- package/dist/host/dotfiles/oxlintrc.json +137 -3
- package/dist/host/guides/agent.md +111 -134
- package/dist/host/guides/capture.md +801 -0
- package/dist/host/guides/probe.md +27 -8
- package/dist/host/guides/scaffold.md +96 -18
- package/dist/host/guides/study.md +534 -440
- package/dist/host/guides/test.md +111 -44
- package/dist/host/manifest.json +160 -240
- package/dist/host/tests/config.test.ts +971 -187
- package/dist/host/tests/policy.test.ts +127 -1
- package/dist/host/tests/setupPolicy.ts +893 -33
- package/dist/src/core/index.cjs +214 -86
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +122 -38
- package/dist/src/core/index.d.ts +122 -38
- package/dist/src/core/index.js +209 -85
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +20 -27
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.js +21 -28
- package/dist/src/server/index.js.map +1 -1
- package/package.json +11 -11
- package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +0 -283
- package/dist/host/agents/skills/orkestrel-dispatch/references/bench.md +0 -25
- package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +0 -333
- package/dist/host/guides/supervisor.md +0 -4890
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
// Prove a test catches a mutation: apply an edit file, run a command, restore the files, run the
|
|
2
|
+
// command again. Run from the checkout root:
|
|
3
|
+
// node .agents/skills/orkestrel-falsify/scripts/mutate.ts --edits EDITS.md -- COMMAND [ARGS...]
|
|
4
|
+
// The edit file has the shape `patch.ts` of the `orkestrel-dispatch` skill reads: blocks of a
|
|
5
|
+
// `### <path>` heading (relative to the working directory), a `reason:` line, and fenced `old:` and
|
|
6
|
+
// `new:` texts, where each `old` text must occur exactly once in its file after the earlier edits to
|
|
7
|
+
// that file. The script resolves every edit before it touches a file, saves each file's original
|
|
8
|
+
// bytes under `tmp/probes/mutate/`, writes the mutated files, runs the command with no shell and
|
|
9
|
+
// its output sent to this script's standard error, restores the original bytes and proves each
|
|
10
|
+
// file's SHA-256 digest equals the original, runs the command again, and prints one JSON line:
|
|
11
|
+
// `{"edits":N,"files":[{"path":"...","digest":"..."}],"mutatedExit":N,"restoredExit":N,"restored":true}`.
|
|
12
|
+
// The saved bytes are deleted once the restore is proven. Edits that spell one file two ways
|
|
13
|
+
// (`a.ts` and `./a.ts`, or a case-only difference on a host that folds case) apply to it in order,
|
|
14
|
+
// each against the text the earlier ones left. A mutation that leaves the suite uncollected also
|
|
15
|
+
// reads as a failing run, so read the command's own test count on standard error.
|
|
16
|
+
// A killed parent leaves the tree mutated, so a run refuses to start while a run directory remains
|
|
17
|
+
// and names the line that restores it, and
|
|
18
|
+
// node .agents/skills/orkestrel-falsify/scripts/mutate.ts --restore tmp/probes/mutate/run-NAME
|
|
19
|
+
// writes each saved original back, proves each file's SHA-256 digest against the run's manifest,
|
|
20
|
+
// deletes the run directory, and prints `{"files":[{"path":"...","digest":"..."}],"restored":true}`.
|
|
21
|
+
// The script refuses a flag it does not read before the bare `--`, the `--flag=value` form included,
|
|
22
|
+
// and names an unknown flag before a flag given with no value.
|
|
23
|
+
// Exit 0 when the mutated run failed and the restored run passed (or a `--restore` is proven), 3
|
|
24
|
+
// when either did not, 2 when a restore is not proven or the command cannot start, 64 on usage, an
|
|
25
|
+
// edit that does not resolve, or a run directory an earlier run left.
|
|
26
|
+
import { spawnSync } from 'node:child_process'
|
|
27
|
+
import { createHash } from 'node:crypto'
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
mkdtempSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
realpathSync,
|
|
35
|
+
rmSync,
|
|
36
|
+
statSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
} from 'node:fs'
|
|
39
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
40
|
+
|
|
41
|
+
/** Lists the flags that take a value. */
|
|
42
|
+
export const VALUE_FLAGS: readonly string[] = ['--edits', '--restore']
|
|
43
|
+
|
|
44
|
+
/** Lists every flag the script reads, value flags and switches alike. */
|
|
45
|
+
export const FLAGS: readonly string[] = VALUE_FLAGS
|
|
46
|
+
|
|
47
|
+
/** Holds the usage line printed when a call names no edit file or no command. */
|
|
48
|
+
export const USAGE =
|
|
49
|
+
'usage: mutate.ts --edits EDITS.md -- COMMAND [ARGS...] | mutate.ts --restore tmp/probes/mutate/run-NAME'
|
|
50
|
+
|
|
51
|
+
/** Holds the directory, under the working directory, that keeps the original bytes of a run. */
|
|
52
|
+
export const BACKUP_ROOT = 'tmp/probes/mutate'
|
|
53
|
+
|
|
54
|
+
/** Holds the name every run directory opens with. */
|
|
55
|
+
export const RUN_PREFIX = 'run-'
|
|
56
|
+
|
|
57
|
+
/** Holds the name of the manifest file a run directory keeps beside its saved bytes. */
|
|
58
|
+
export const MANIFEST = 'manifest.json'
|
|
59
|
+
|
|
60
|
+
/** Matches one edit block with its path, reason, and fenced old and new texts. */
|
|
61
|
+
export const EDIT_BLOCK =
|
|
62
|
+
/^### (.+?)[ \t]*\n+reason:[ \t]*(.*?)[ \t]*\n+old:\n+(`{3,})[^\n`]*\n([\s\S]*?)\n\3[ \t]*\n+new:\n+(`{3,})[^\n`]*\n([\s\S]*?)\n?\5[ \t]*$/gmu
|
|
63
|
+
|
|
64
|
+
/** Matches a line that belongs to an edit block and so must not remain outside one. */
|
|
65
|
+
export const STRAY_LINE = /^(?:### |reason:|old:|new:)/u
|
|
66
|
+
|
|
67
|
+
/** Describes one edit: the file it changes, why, and the text it swaps. */
|
|
68
|
+
export interface Edit {
|
|
69
|
+
readonly number: number
|
|
70
|
+
readonly path: string
|
|
71
|
+
readonly before: string
|
|
72
|
+
readonly after: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Describes one file a mutation touches: its original bytes and the text to write. */
|
|
76
|
+
export interface Mutation {
|
|
77
|
+
readonly path: string
|
|
78
|
+
readonly original: Buffer
|
|
79
|
+
readonly mutated: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Describes one file a run directory saved: its path, the saved bytes' file, and their digest. */
|
|
83
|
+
export interface ManifestEntry {
|
|
84
|
+
readonly path: string
|
|
85
|
+
readonly backup: string
|
|
86
|
+
readonly digest: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Describes what resolving the edits against the files found. */
|
|
90
|
+
export interface Resolution {
|
|
91
|
+
readonly mutations: readonly Mutation[]
|
|
92
|
+
readonly problems: readonly string[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Describes one finished command: its exit code, or why it never exited. */
|
|
96
|
+
export interface Finish {
|
|
97
|
+
readonly exit: number | undefined
|
|
98
|
+
readonly error: string | undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Names the value flags a caller gave with no value.
|
|
103
|
+
*
|
|
104
|
+
* @param argv - The arguments before the bare `--`.
|
|
105
|
+
* @param names - The flags that take a value.
|
|
106
|
+
* @returns Each named flag that appears last or is followed by another flag, in argument order.
|
|
107
|
+
*/
|
|
108
|
+
export function readMissingFlags(
|
|
109
|
+
argv: readonly string[],
|
|
110
|
+
names: readonly string[],
|
|
111
|
+
): readonly string[] {
|
|
112
|
+
return names.filter((name) => argv.includes(name) && readOption(argv, name) === undefined)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Reads the value that follows a flag.
|
|
117
|
+
*
|
|
118
|
+
* @remarks
|
|
119
|
+
* The first occurrence of a flag wins, a following value that opens with `--` is a missing value,
|
|
120
|
+
* and the `--flag=value` form is not read.
|
|
121
|
+
*
|
|
122
|
+
* @param argv - The arguments before the bare `--`.
|
|
123
|
+
* @param name - The flag, such as `--edits`.
|
|
124
|
+
* @returns The value after the flag, or undefined when the flag is absent, last, or followed by
|
|
125
|
+
* another flag.
|
|
126
|
+
*/
|
|
127
|
+
export function readOption(argv: readonly string[], name: string): string | undefined {
|
|
128
|
+
const index = argv.indexOf(name)
|
|
129
|
+
const value = index === -1 ? undefined : argv[index + 1]
|
|
130
|
+
return value === undefined || value.startsWith('--') ? undefined : value
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Names the flags a caller gave that the script does not read.
|
|
135
|
+
*
|
|
136
|
+
* @remarks
|
|
137
|
+
* A `--flag=value` argument is unknown, because the script reads no such form. A bare `--` ends the
|
|
138
|
+
* scan, because it opens the command the script runs.
|
|
139
|
+
*
|
|
140
|
+
* @param argv - The arguments after the script path.
|
|
141
|
+
* @param flags - Every flag the script reads, value flags and switches alike.
|
|
142
|
+
* @returns Each argument that opens with `--` and is not a read flag, in argument order.
|
|
143
|
+
*/
|
|
144
|
+
export function readUnknownFlags(
|
|
145
|
+
argv: readonly string[],
|
|
146
|
+
flags: readonly string[],
|
|
147
|
+
): readonly string[] {
|
|
148
|
+
const end = argv.indexOf('--')
|
|
149
|
+
return (end === -1 ? argv : argv.slice(0, end)).filter(
|
|
150
|
+
(argument) => argument.startsWith('--') && !flags.includes(argument),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Reads the arguments that are neither a flag nor the value of a value flag.
|
|
156
|
+
*
|
|
157
|
+
* @param argv - The arguments before the bare `--`.
|
|
158
|
+
* @param valueFlags - The flags that take a value.
|
|
159
|
+
* @returns The positional arguments in order.
|
|
160
|
+
*/
|
|
161
|
+
export function readPositionals(
|
|
162
|
+
argv: readonly string[],
|
|
163
|
+
valueFlags: readonly string[],
|
|
164
|
+
): readonly string[] {
|
|
165
|
+
return argv.filter(
|
|
166
|
+
(argument, index) => !argument.startsWith('--') && !valueFlags.includes(argv[index - 1] ?? ''),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Quotes an untrusted text for a diagnostic, JSON-encoded and cut to a bounded length.
|
|
172
|
+
*
|
|
173
|
+
* @param text - The text to quote.
|
|
174
|
+
* @returns The JSON string literal of the text, ending in an ellipsis inside the quotes when cut.
|
|
175
|
+
*/
|
|
176
|
+
export function quote(text: string): string {
|
|
177
|
+
return JSON.stringify(text.length > 80 ? `${text.slice(0, 80)}…` : text)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Reads the edit blocks of an edit file in order.
|
|
182
|
+
*
|
|
183
|
+
* @param text - The edit file's text; a carriage return before a line break is ignored.
|
|
184
|
+
* @returns Each complete block as an edit numbered from 1.
|
|
185
|
+
*/
|
|
186
|
+
export function parseEdits(text: string): readonly Edit[] {
|
|
187
|
+
return [...text.replace(/\r\n/gu, '\n').matchAll(EDIT_BLOCK)].map((match, index) => ({
|
|
188
|
+
number: index + 1,
|
|
189
|
+
path: match[1] ?? '',
|
|
190
|
+
before: match[4] ?? '',
|
|
191
|
+
after: match[6] ?? '',
|
|
192
|
+
}))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Finds the lines of an edit file that open or continue a block no complete block holds.
|
|
197
|
+
*
|
|
198
|
+
* @param text - The edit file's text.
|
|
199
|
+
* @returns Each stray `###`, `reason:`, `old:`, or `new:` line, in order.
|
|
200
|
+
*/
|
|
201
|
+
export function readStrays(text: string): readonly string[] {
|
|
202
|
+
return text
|
|
203
|
+
.replace(/\r\n/gu, '\n')
|
|
204
|
+
.replace(EDIT_BLOCK, '')
|
|
205
|
+
.split(/\r\n|\n/u)
|
|
206
|
+
.filter((line) => STRAY_LINE.test(line))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Counts how many places a text occurs in another, overlapping places included.
|
|
211
|
+
*
|
|
212
|
+
* @param text - The text to search.
|
|
213
|
+
* @param part - The text to find; an empty text occurs nowhere.
|
|
214
|
+
* @returns The number of start positions at which `part` occurs.
|
|
215
|
+
*/
|
|
216
|
+
export function countOccurrences(text: string, part: string): number {
|
|
217
|
+
if (part === '') return 0
|
|
218
|
+
const escaped = part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
219
|
+
return [...text.matchAll(new RegExp(`(?=${escaped})`, 'gu'))].length
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Checks whether a path sits strictly inside a directory.
|
|
224
|
+
*
|
|
225
|
+
* @param root - The directory.
|
|
226
|
+
* @param target - The path to check.
|
|
227
|
+
* @returns True when `target` is below `root`; false for `root` itself and for any path outside.
|
|
228
|
+
*/
|
|
229
|
+
export function isInside(root: string, target: string): boolean {
|
|
230
|
+
const path = relative(root, target)
|
|
231
|
+
return path !== '' && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Hashes bytes with SHA-256.
|
|
236
|
+
*
|
|
237
|
+
* @param bytes - The bytes to hash.
|
|
238
|
+
* @returns The digest as lowercase hexadecimal.
|
|
239
|
+
*/
|
|
240
|
+
export function digestBytes(bytes: Uint8Array): string {
|
|
241
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Applies one file's edits in order, each against the text the earlier edits left.
|
|
246
|
+
*
|
|
247
|
+
* @param text - The file's text before any edit.
|
|
248
|
+
* @param edits - The file's edits in file order.
|
|
249
|
+
* @returns The text after the edits, and one problem per edit whose `old` text does not occur
|
|
250
|
+
* exactly once; such an edit changes nothing.
|
|
251
|
+
*/
|
|
252
|
+
export function applyEdits(
|
|
253
|
+
text: string,
|
|
254
|
+
edits: readonly Edit[],
|
|
255
|
+
): { readonly text: string; readonly problems: readonly string[] } {
|
|
256
|
+
return edits.reduce<{ readonly text: string; readonly problems: readonly string[] }>(
|
|
257
|
+
(state, edit) => {
|
|
258
|
+
const count = countOccurrences(state.text, edit.before)
|
|
259
|
+
return count === 1
|
|
260
|
+
? { text: state.text.replace(edit.before, () => edit.after), problems: state.problems }
|
|
261
|
+
: {
|
|
262
|
+
text: state.text,
|
|
263
|
+
problems: [
|
|
264
|
+
...state.problems,
|
|
265
|
+
`#${String(edit.number)} ${edit.path}: old text occurs ${String(count)} times`,
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
{ text, problems: [] },
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Folds a path's case on a host whose file names ignore case.
|
|
275
|
+
*
|
|
276
|
+
* @param path - The path to fold.
|
|
277
|
+
* @param platform - The host platform, as `process.platform` names it.
|
|
278
|
+
* @returns The path in lowercase on `win32` and `darwin`; the path unchanged elsewhere.
|
|
279
|
+
*/
|
|
280
|
+
export function foldPath(path: string, platform: string): string {
|
|
281
|
+
return platform === 'win32' || platform === 'darwin' ? path.toLowerCase() : path
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Names the file an edit changes, so two spellings of one path share a name.
|
|
286
|
+
*
|
|
287
|
+
* @param root - The working directory the edit paths resolve against.
|
|
288
|
+
* @param edit - The edit.
|
|
289
|
+
* @returns The edit's resolved path, case-folded where the host folds case.
|
|
290
|
+
*/
|
|
291
|
+
export function keyEdit(root: string, edit: Edit): string {
|
|
292
|
+
return foldPath(resolve(root, edit.path), process.platform)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolves every edit against the files under a root, writing nothing.
|
|
297
|
+
*
|
|
298
|
+
* @remarks
|
|
299
|
+
* Edits that name one file under two spellings are one file's edits, applied in file order.
|
|
300
|
+
*
|
|
301
|
+
* @param root - The working directory the edit paths resolve against.
|
|
302
|
+
* @param edits - The edits in file order.
|
|
303
|
+
* @returns One mutation per file whose edits all resolved, and each problem found in any file.
|
|
304
|
+
*/
|
|
305
|
+
export function resolveMutations(root: string, edits: readonly Edit[]): Resolution {
|
|
306
|
+
const results = [...new Set(edits.map((edit) => keyEdit(root, edit)))].map((key) => {
|
|
307
|
+
const own = edits.filter((edit) => keyEdit(root, edit) === key)
|
|
308
|
+
const path = own[0]?.path ?? ''
|
|
309
|
+
const target = resolve(root, path)
|
|
310
|
+
if (!isInside(root, target)) {
|
|
311
|
+
return { mutation: undefined, problems: [`${path}: path leaves the working directory`] }
|
|
312
|
+
}
|
|
313
|
+
if (!existsSync(target) || !statSync(target).isFile()) {
|
|
314
|
+
return { mutation: undefined, problems: [`${path}: no such file`] }
|
|
315
|
+
}
|
|
316
|
+
const original = readFileSync(target)
|
|
317
|
+
const applied = applyEdits(original.toString('utf8'), own)
|
|
318
|
+
return {
|
|
319
|
+
mutation:
|
|
320
|
+
applied.problems.length === 0 ? { path, original, mutated: applied.text } : undefined,
|
|
321
|
+
problems: applied.problems,
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
return {
|
|
325
|
+
mutations: results.flatMap((result) =>
|
|
326
|
+
result.mutation === undefined ? [] : [result.mutation],
|
|
327
|
+
),
|
|
328
|
+
problems: results.flatMap((result) => result.problems),
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Runs the command with no shell, its output sent to this process's standard error.
|
|
334
|
+
*
|
|
335
|
+
* @param command - The executable to run.
|
|
336
|
+
* @param args - The arguments after the executable.
|
|
337
|
+
* @returns The exit code, or undefined with the reason when the command cannot start or a signal
|
|
338
|
+
* ended it.
|
|
339
|
+
*/
|
|
340
|
+
export function runCommand(command: string, args: readonly string[]): Finish {
|
|
341
|
+
const result = spawnSync(command, args, {
|
|
342
|
+
stdio: ['ignore', 2, 2],
|
|
343
|
+
windowsHide: true,
|
|
344
|
+
shell: false,
|
|
345
|
+
})
|
|
346
|
+
const error =
|
|
347
|
+
result.error?.message ?? (result.signal === null ? undefined : `ended by ${result.signal}`)
|
|
348
|
+
return { exit: error === undefined ? (result.status ?? undefined) : undefined, error }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Writes each mutation's original bytes back to its file.
|
|
353
|
+
*
|
|
354
|
+
* @param root - The working directory the paths resolve against.
|
|
355
|
+
* @param mutations - The mutations to undo.
|
|
356
|
+
* @returns Nothing after every file is written.
|
|
357
|
+
*/
|
|
358
|
+
export function restoreFiles(root: string, mutations: readonly Mutation[]): void {
|
|
359
|
+
for (const mutation of mutations) writeFileSync(resolve(root, mutation.path), mutation.original)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Names each file whose bytes no longer match the original digest.
|
|
364
|
+
*
|
|
365
|
+
* @param root - The working directory the paths resolve against.
|
|
366
|
+
* @param mutations - The mutations whose originals are the reference.
|
|
367
|
+
* @returns The path of each file that is missing or whose digest differs, in mutation order.
|
|
368
|
+
*/
|
|
369
|
+
export function readDrift(root: string, mutations: readonly Mutation[]): readonly string[] {
|
|
370
|
+
return mutations.flatMap((mutation) =>
|
|
371
|
+
digestFile(resolve(root, mutation.path)) === digestBytes(mutation.original)
|
|
372
|
+
? []
|
|
373
|
+
: [mutation.path],
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Hashes a file's bytes with SHA-256.
|
|
379
|
+
*
|
|
380
|
+
* @param path - The file to hash.
|
|
381
|
+
* @returns The digest as lowercase hexadecimal, or undefined when the path cannot be read as a file.
|
|
382
|
+
*/
|
|
383
|
+
export function digestFile(path: string): string | undefined {
|
|
384
|
+
try {
|
|
385
|
+
return digestBytes(readFileSync(path))
|
|
386
|
+
} catch {
|
|
387
|
+
return undefined
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Writes the mutated files and runs the command, reporting a write the host refuses as a run that
|
|
393
|
+
* never exited.
|
|
394
|
+
*
|
|
395
|
+
* @param root - The working directory the paths resolve against.
|
|
396
|
+
* @param mutations - The mutations to apply.
|
|
397
|
+
* @param command - The executable to run.
|
|
398
|
+
* @param args - The arguments after the executable.
|
|
399
|
+
* @returns How the mutated run ended.
|
|
400
|
+
*/
|
|
401
|
+
export function runMutated(
|
|
402
|
+
root: string,
|
|
403
|
+
mutations: readonly Mutation[],
|
|
404
|
+
command: string,
|
|
405
|
+
args: readonly string[],
|
|
406
|
+
): Finish {
|
|
407
|
+
try {
|
|
408
|
+
for (const mutation of mutations) writeFileSync(resolve(root, mutation.path), mutation.mutated)
|
|
409
|
+
return runCommand(command, args)
|
|
410
|
+
} catch (error) {
|
|
411
|
+
return { exit: undefined, error: error instanceof Error ? error.message : String(error) }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Restores every file and names the refusal when the host rejects a write.
|
|
417
|
+
*
|
|
418
|
+
* @param root - The working directory the paths resolve against.
|
|
419
|
+
* @param mutations - The mutations to undo.
|
|
420
|
+
* @returns The host's refusal message, or undefined when every file was written back.
|
|
421
|
+
*/
|
|
422
|
+
export function tryRestore(root: string, mutations: readonly Mutation[]): string | undefined {
|
|
423
|
+
try {
|
|
424
|
+
restoreFiles(root, mutations)
|
|
425
|
+
return undefined
|
|
426
|
+
} catch (error) {
|
|
427
|
+
return error instanceof Error ? error.message : String(error)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Saves each file's original bytes and a manifest of their digests in a new directory.
|
|
433
|
+
*
|
|
434
|
+
* @param root - The working directory the backup root sits under.
|
|
435
|
+
* @param mutations - The mutations whose originals to save.
|
|
436
|
+
* @returns The directory that holds the saved bytes.
|
|
437
|
+
*/
|
|
438
|
+
export function saveOriginals(root: string, mutations: readonly Mutation[]): string {
|
|
439
|
+
const base = resolve(root, BACKUP_ROOT)
|
|
440
|
+
mkdirSync(base, { recursive: true })
|
|
441
|
+
const directory = mkdtempSync(join(base, RUN_PREFIX))
|
|
442
|
+
for (const [index, mutation] of mutations.entries()) {
|
|
443
|
+
writeFileSync(join(directory, `${String(index)}.bak`), mutation.original)
|
|
444
|
+
}
|
|
445
|
+
writeFileSync(
|
|
446
|
+
join(directory, MANIFEST),
|
|
447
|
+
JSON.stringify(
|
|
448
|
+
mutations.map((mutation, index) => ({
|
|
449
|
+
path: mutation.path,
|
|
450
|
+
backup: `${String(index)}.bak`,
|
|
451
|
+
digest: digestBytes(mutation.original),
|
|
452
|
+
})),
|
|
453
|
+
),
|
|
454
|
+
)
|
|
455
|
+
return directory
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Lists the run directories an earlier run left under the backup root.
|
|
460
|
+
*
|
|
461
|
+
* @param root - The working directory the backup root sits under.
|
|
462
|
+
* @returns The name of each run directory, sorted; empty when none remains.
|
|
463
|
+
*/
|
|
464
|
+
export function readLeftovers(root: string): readonly string[] {
|
|
465
|
+
const base = resolve(root, BACKUP_ROOT)
|
|
466
|
+
return existsSync(base)
|
|
467
|
+
? readdirSync(base)
|
|
468
|
+
.filter((name) => name.startsWith(RUN_PREFIX))
|
|
469
|
+
.sort()
|
|
470
|
+
: []
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Checks whether a value is one entry of a run directory's manifest.
|
|
475
|
+
*
|
|
476
|
+
* @param value - The parsed JSON value.
|
|
477
|
+
* @returns True when the value names a path, a backup file with no directory part, and a digest.
|
|
478
|
+
*/
|
|
479
|
+
export function isManifestEntry(value: unknown): value is ManifestEntry {
|
|
480
|
+
return (
|
|
481
|
+
typeof value === 'object' &&
|
|
482
|
+
value !== null &&
|
|
483
|
+
'path' in value &&
|
|
484
|
+
typeof value.path === 'string' &&
|
|
485
|
+
'backup' in value &&
|
|
486
|
+
typeof value.backup === 'string' &&
|
|
487
|
+
value.backup !== '' &&
|
|
488
|
+
basename(value.backup) === value.backup &&
|
|
489
|
+
'digest' in value &&
|
|
490
|
+
typeof value.digest === 'string'
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Reads the manifest of a run directory.
|
|
496
|
+
*
|
|
497
|
+
* @param directory - The run directory.
|
|
498
|
+
* @returns Each saved file in order, or undefined when the manifest is missing, is not JSON, or
|
|
499
|
+
* holds anything but entries.
|
|
500
|
+
*/
|
|
501
|
+
export function readManifest(directory: string): readonly ManifestEntry[] | undefined {
|
|
502
|
+
try {
|
|
503
|
+
const parsed: unknown = JSON.parse(readFileSync(join(directory, MANIFEST), 'utf8'))
|
|
504
|
+
return Array.isArray(parsed) && parsed.every(isManifestEntry) ? parsed : undefined
|
|
505
|
+
} catch {
|
|
506
|
+
return undefined
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Writes each saved file's bytes back to its path and names the refusal when the host rejects a
|
|
512
|
+
* write.
|
|
513
|
+
*
|
|
514
|
+
* @param root - The working directory the paths resolve against.
|
|
515
|
+
* @param directory - The run directory that holds the saved bytes.
|
|
516
|
+
* @param entries - The manifest entries to write back.
|
|
517
|
+
* @returns The host's refusal message, or undefined when every file was written.
|
|
518
|
+
*/
|
|
519
|
+
export function writeSaved(
|
|
520
|
+
root: string,
|
|
521
|
+
directory: string,
|
|
522
|
+
entries: readonly ManifestEntry[],
|
|
523
|
+
): string | undefined {
|
|
524
|
+
try {
|
|
525
|
+
for (const entry of entries) {
|
|
526
|
+
writeFileSync(resolve(root, entry.path), readFileSync(join(directory, entry.backup)))
|
|
527
|
+
}
|
|
528
|
+
return undefined
|
|
529
|
+
} catch (error) {
|
|
530
|
+
return error instanceof Error ? error.message : String(error)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Restores the files a run directory saved and proves each digest from its manifest.
|
|
536
|
+
*
|
|
537
|
+
* @param root - The working directory the manifest paths resolve against.
|
|
538
|
+
* @param argument - The run directory as the caller gave it.
|
|
539
|
+
* @returns The exit code: 0 when every file is back and proven and the run directory is deleted, 2
|
|
540
|
+
* when a saved file no longer matches its digest or a restore is not proven, 64 when the argument
|
|
541
|
+
* is no run directory of this script or its manifest cannot be trusted.
|
|
542
|
+
*/
|
|
543
|
+
export function restoreRun(root: string, argument: string): number {
|
|
544
|
+
const base = resolve(root, BACKUP_ROOT)
|
|
545
|
+
const directory = resolve(root, argument)
|
|
546
|
+
if (
|
|
547
|
+
foldPath(dirname(directory), process.platform) !== foldPath(base, process.platform) ||
|
|
548
|
+
!basename(directory).startsWith(RUN_PREFIX)
|
|
549
|
+
) {
|
|
550
|
+
console.error(
|
|
551
|
+
`mutate: ${quote(argument)} is not a run directory under ${BACKUP_ROOT}; ${USAGE}`,
|
|
552
|
+
)
|
|
553
|
+
return 64
|
|
554
|
+
}
|
|
555
|
+
const entries = readManifest(directory)
|
|
556
|
+
if (entries === undefined) {
|
|
557
|
+
console.error(`mutate: ${quote(argument)} holds no readable ${MANIFEST}; ${USAGE}`)
|
|
558
|
+
return 64
|
|
559
|
+
}
|
|
560
|
+
const outside = entries.filter((entry) => !isInside(root, resolve(root, entry.path)))
|
|
561
|
+
if (outside.length > 0) {
|
|
562
|
+
console.error(
|
|
563
|
+
`mutate: ${MANIFEST} names a path outside the working directory: ${outside.map((entry) => quote(entry.path)).join(', ')}; nothing was written`,
|
|
564
|
+
)
|
|
565
|
+
return 64
|
|
566
|
+
}
|
|
567
|
+
const damaged = entries.filter(
|
|
568
|
+
(entry) => digestFile(join(directory, entry.backup)) !== entry.digest,
|
|
569
|
+
)
|
|
570
|
+
if (damaged.length > 0) {
|
|
571
|
+
console.error(
|
|
572
|
+
`mutate: the saved bytes of ${damaged.map((entry) => entry.path).join(', ')} no longer match ${MANIFEST}; nothing was written`,
|
|
573
|
+
)
|
|
574
|
+
return 2
|
|
575
|
+
}
|
|
576
|
+
const refusal = writeSaved(root, directory, entries)
|
|
577
|
+
const drift = entries.filter((entry) => digestFile(resolve(root, entry.path)) !== entry.digest)
|
|
578
|
+
if (refusal !== undefined || drift.length > 0) {
|
|
579
|
+
console.error(
|
|
580
|
+
`mutate: restore not proven for ${drift.map((entry) => entry.path).join(', ')}${refusal === undefined ? '' : ` (${refusal})`}; the originals stay under ${directory}`,
|
|
581
|
+
)
|
|
582
|
+
return 2
|
|
583
|
+
}
|
|
584
|
+
rmSync(directory, { recursive: true, force: true })
|
|
585
|
+
console.log(
|
|
586
|
+
JSON.stringify({
|
|
587
|
+
files: entries.map((entry) => ({ path: entry.path, digest: entry.digest })),
|
|
588
|
+
restored: true,
|
|
589
|
+
}),
|
|
590
|
+
)
|
|
591
|
+
return 0
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Runs the script on its arguments.
|
|
596
|
+
*
|
|
597
|
+
* @param argv - The arguments after the script path.
|
|
598
|
+
* @returns The exit code: 0 when the mutated run failed and the restored run passed or a `--restore`
|
|
599
|
+
* is proven, 3 when either did not, 2 when a restore is not proven or the command cannot start, 64
|
|
600
|
+
* on usage, an edit that does not resolve, or a run directory an earlier run left.
|
|
601
|
+
*/
|
|
602
|
+
export function main(argv: readonly string[]): number {
|
|
603
|
+
const end = argv.indexOf('--')
|
|
604
|
+
const own = end === -1 ? argv : argv.slice(0, end)
|
|
605
|
+
const command = end === -1 ? [] : argv.slice(end + 1)
|
|
606
|
+
const unknown = readUnknownFlags(argv, FLAGS)
|
|
607
|
+
if (unknown.length > 0) {
|
|
608
|
+
console.error(`mutate: ${unknown.join(', ')} is not a flag this script reads; ${USAGE}`)
|
|
609
|
+
return 64
|
|
610
|
+
}
|
|
611
|
+
const missing = readMissingFlags(own, VALUE_FLAGS)
|
|
612
|
+
if (missing.length > 0) {
|
|
613
|
+
console.error(`mutate: ${missing.join(', ')} given with no value`)
|
|
614
|
+
return 64
|
|
615
|
+
}
|
|
616
|
+
const restore = readOption(own, '--restore')
|
|
617
|
+
if (restore !== undefined) {
|
|
618
|
+
if (end !== -1 || own.includes('--edits') || readPositionals(own, VALUE_FLAGS).length > 0) {
|
|
619
|
+
console.error(USAGE)
|
|
620
|
+
return 64
|
|
621
|
+
}
|
|
622
|
+
return restoreRun(process.cwd(), restore)
|
|
623
|
+
}
|
|
624
|
+
const source = readOption(own, '--edits')
|
|
625
|
+
const executable = command[0]
|
|
626
|
+
if (
|
|
627
|
+
source === undefined ||
|
|
628
|
+
executable === undefined ||
|
|
629
|
+
readPositionals(own, VALUE_FLAGS).length > 0
|
|
630
|
+
) {
|
|
631
|
+
console.error(USAGE)
|
|
632
|
+
return 64
|
|
633
|
+
}
|
|
634
|
+
if (!existsSync(source) || !statSync(source).isFile()) {
|
|
635
|
+
console.error(`mutate: ${quote(source)} is not a readable edit file; ${USAGE}`)
|
|
636
|
+
return 64
|
|
637
|
+
}
|
|
638
|
+
const text = readFileSync(source, 'utf8')
|
|
639
|
+
const edits = parseEdits(text)
|
|
640
|
+
const strays = readStrays(text)
|
|
641
|
+
if (edits.length === 0 || strays.length > 0) {
|
|
642
|
+
console.error(
|
|
643
|
+
`mutate: ${quote(source)} ${edits.length === 0 ? 'holds no complete edit block' : `has a line that opens no complete block: ${strays.map(quote).join(', ')}`}; a block is a "### path" line, a "reason:" line, and fenced "old:" and "new:" texts`,
|
|
644
|
+
)
|
|
645
|
+
return 64
|
|
646
|
+
}
|
|
647
|
+
const root = process.cwd()
|
|
648
|
+
const leftovers = readLeftovers(root)
|
|
649
|
+
if (leftovers.length > 0) {
|
|
650
|
+
console.error(
|
|
651
|
+
`mutate: an earlier run left its saved originals, so the tree may still be mutated; no file was touched and no command ran. Restore it first: ${leftovers.map((name) => `mutate.ts --restore ${BACKUP_ROOT}/${name}`).join('; ')}`,
|
|
652
|
+
)
|
|
653
|
+
return 64
|
|
654
|
+
}
|
|
655
|
+
const resolution = resolveMutations(root, edits)
|
|
656
|
+
if (resolution.problems.length > 0) {
|
|
657
|
+
console.error(
|
|
658
|
+
`mutate: no file was touched and no command ran: ${resolution.problems.join('; ')}`,
|
|
659
|
+
)
|
|
660
|
+
return 64
|
|
661
|
+
}
|
|
662
|
+
const saved = saveOriginals(root, resolution.mutations)
|
|
663
|
+
console.error(`mutate: originals saved under ${saved}`)
|
|
664
|
+
const mutated = runMutated(root, resolution.mutations, executable, command.slice(1))
|
|
665
|
+
const refusal = tryRestore(root, resolution.mutations)
|
|
666
|
+
const drift = readDrift(root, resolution.mutations)
|
|
667
|
+
if (refusal !== undefined || drift.length > 0) {
|
|
668
|
+
console.error(
|
|
669
|
+
`mutate: restore not proven for ${drift.join(', ')}${refusal === undefined ? '' : ` (${refusal})`}; the originals are under ${saved}`,
|
|
670
|
+
)
|
|
671
|
+
return 2
|
|
672
|
+
}
|
|
673
|
+
if (mutated.exit === undefined) {
|
|
674
|
+
rmSync(saved, { recursive: true, force: true })
|
|
675
|
+
console.error(`mutate: the command did not exit: ${mutated.error ?? 'unknown'}`)
|
|
676
|
+
return 2
|
|
677
|
+
}
|
|
678
|
+
const restored = runCommand(executable, command.slice(1))
|
|
679
|
+
rmSync(saved, { recursive: true, force: true })
|
|
680
|
+
if (restored.exit === undefined) {
|
|
681
|
+
console.error(`mutate: the restored run did not exit: ${restored.error ?? 'unknown'}`)
|
|
682
|
+
return 2
|
|
683
|
+
}
|
|
684
|
+
console.log(
|
|
685
|
+
JSON.stringify({
|
|
686
|
+
edits: edits.length,
|
|
687
|
+
files: resolution.mutations.map((mutation) => ({
|
|
688
|
+
path: mutation.path,
|
|
689
|
+
digest: digestBytes(mutation.original),
|
|
690
|
+
})),
|
|
691
|
+
mutatedExit: mutated.exit,
|
|
692
|
+
restoredExit: restored.exit,
|
|
693
|
+
restored: true,
|
|
694
|
+
}),
|
|
695
|
+
)
|
|
696
|
+
return mutated.exit !== 0 && restored.exit === 0 ? 0 : 3
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === import.meta.filename) {
|
|
700
|
+
process.exitCode = main(process.argv.slice(2))
|
|
701
|
+
}
|