@descent-vtt/spec-harness 0.10.1 → 0.11.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +94 -0
- package/README.md +13 -4
- package/bin/spec-harness.js +16 -0
- package/dist/cli.d.ts +24 -3
- package/dist/cli.js +55 -10
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +23 -4
- package/dist/commands.js.map +1 -1
- package/dist/probe.d.ts +59 -1
- package/dist/probe.js +88 -1
- package/dist/probe.js.map +1 -1
- package/dist/sandbox.d.ts +24 -2
- package/dist/sandbox.js +93 -11
- package/dist/sandbox.js.map +1 -1
- package/package.json +1 -1
- package/skills/draft-brief/SKILL.md +5 -3
package/dist/sandbox.js
CHANGED
|
@@ -20,6 +20,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
20
20
|
import { constants, tmpdir } from 'node:os';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { addWorktree, removeWorktree } from './git.js';
|
|
23
|
+
import { probeEnvironment } from './probe.js';
|
|
23
24
|
const live = new Map();
|
|
24
25
|
const running = new Set();
|
|
25
26
|
let installed = false;
|
|
@@ -33,8 +34,37 @@ let ending = false;
|
|
|
33
34
|
*/
|
|
34
35
|
const SETTLE_MS = 3_000;
|
|
35
36
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
37
|
+
* Whether a process has this id now. Signal 0 asks and sends nothing, and any
|
|
38
|
+
* answer but "no such process" says one has it: another user's process
|
|
39
|
+
* answers that it may not be signalled.
|
|
40
|
+
*/
|
|
41
|
+
function taken(pid) {
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
return error.code !== 'ESRCH';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Stops a command with what can be reached of what it started, forced:
|
|
52
|
+
* SIGKILL to its process group on Linux and macOS, taskkill over its tree on
|
|
53
|
+
* Windows. A process that left the group is not in it, and taskkill follows
|
|
54
|
+
* a tree from parent to child while each parent runs: one whose parent has
|
|
55
|
+
* ended is found under no root. Such a process runs on (ADR-0003).
|
|
56
|
+
*
|
|
57
|
+
* Both name the command by its id, and the id is the command's only while
|
|
58
|
+
* something holds it (ADR-0003). Node holds the command's shell until it has
|
|
59
|
+
* seen the shell end, and until then the system gives the id to nothing else.
|
|
60
|
+
* After that the id is free on Windows, where a process started seconds later
|
|
61
|
+
* can be given it: taskkill would stop that process with all it started, and
|
|
62
|
+
* could not have found what the command left anyway, since it finds no tree
|
|
63
|
+
* under a root that has ended. So a command whose shell has ended is not
|
|
64
|
+
* stopped there. On Linux and macOS the id names the command's process group
|
|
65
|
+
* as well, and stays out of use while the group has a member: what the shell
|
|
66
|
+
* left in its group is still stopped, unless a process has the id now, which
|
|
67
|
+
* says the group emptied and the id was given out again.
|
|
38
68
|
*
|
|
39
69
|
* `windowsHide` here and below only keeps a console window from opening on
|
|
40
70
|
* Windows, which nothing reads, so its mutants are equivalent.
|
|
@@ -46,6 +76,15 @@ function stop(child) {
|
|
|
46
76
|
// tick nothing here stops a command.
|
|
47
77
|
if (child.pid === undefined)
|
|
48
78
|
return;
|
|
79
|
+
// Node sets one of the two when it sees the shell end, and lets go of the
|
|
80
|
+
// shell then. Windows reports no signal: there the second is set only once
|
|
81
|
+
// the fallback below has stopped the shell, on the path no test takes.
|
|
82
|
+
const held = child.exitCode === null && child.signalCode === null;
|
|
83
|
+
// Windows is not asked whether a process has the id: one could be given it
|
|
84
|
+
// between the answer and taskkill. Elsewhere `win32` is false already, so
|
|
85
|
+
// the mutants that make it false are equivalent there.
|
|
86
|
+
if (!held && (process.platform === 'win32' || taken(child.pid)))
|
|
87
|
+
return;
|
|
49
88
|
try {
|
|
50
89
|
// False already on Linux and macOS, so the mutants that make it false
|
|
51
90
|
// are equivalent there. On Windows the tests fail without taskkill:
|
|
@@ -58,10 +97,13 @@ function stop(child) {
|
|
|
58
97
|
process.kill(-child.pid, 'SIGKILL');
|
|
59
98
|
}
|
|
60
99
|
catch {
|
|
61
|
-
// Reached when the group
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
100
|
+
// Reached when the group is already gone, and the command's shell with
|
|
101
|
+
// it, as when the command left something outside the group holding its
|
|
102
|
+
// output; on Windows when the shell has only just ended, which taskkill
|
|
103
|
+
// sees before Node does; and there when taskkill cannot be run, where
|
|
104
|
+
// stopping the shell is what is left. No test takes that last path.
|
|
105
|
+
// `kill` goes through Node's own hold on the shell, and does nothing
|
|
106
|
+
// once that is gone.
|
|
65
107
|
child.kill('SIGKILL');
|
|
66
108
|
}
|
|
67
109
|
}
|
|
@@ -206,15 +248,30 @@ export async function withWorktree(repository, revision, work) {
|
|
|
206
248
|
}
|
|
207
249
|
}
|
|
208
250
|
/**
|
|
209
|
-
* Runs a command line in a directory, output and errors interleaved,
|
|
210
|
-
*
|
|
251
|
+
* Runs a command line in a directory, output and errors interleaved, until
|
|
252
|
+
* its output closes. A command that outlives `timeoutSeconds` is stopped
|
|
253
|
+
* with what `stop` reaches of it, as is one still running when the process
|
|
211
254
|
* is interrupted or exits. Once the process is interrupted it never answers:
|
|
212
255
|
* the job waiting on it would otherwise carry on in a worktree being removed.
|
|
213
256
|
*
|
|
257
|
+
* The timeout bounds the wait as well as the command. What the stop reached
|
|
258
|
+
* lets go of the output in the time the system takes to end it, and the
|
|
259
|
+
* command answers then. What still holds the output `SETTLE_MS` later is
|
|
260
|
+
* what `stop` does not reach, and no wait would end it: the sandbox lets go
|
|
261
|
+
* of the output instead, and answers with what was printed and with
|
|
262
|
+
* `outputHeld`. Letting go is what the end of this process would do to
|
|
263
|
+
* whatever holds the output, only sooner, and without it the open output
|
|
264
|
+
* keeps this process from ending once its work is done.
|
|
265
|
+
*
|
|
214
266
|
* Through the shell, on purpose: a probe's command is a line from the
|
|
215
267
|
* repository's own brief - `npm test -- x` - which the person approved with
|
|
216
268
|
* the brief, exactly as CI runs the repository's own scripts. Nothing from
|
|
217
269
|
* outside the repository reaches it.
|
|
270
|
+
*
|
|
271
|
+
* The line is run as it is written. Its environment is the person's own as
|
|
272
|
+
* `probeEnvironment` hands it on: `CI` set, and npm told to fetch nothing for
|
|
273
|
+
* a command where neither that environment nor the line says what it may
|
|
274
|
+
* fetch (ADR-0007).
|
|
218
275
|
*/
|
|
219
276
|
export function runCommand(line, cwd, timeoutSeconds) {
|
|
220
277
|
install();
|
|
@@ -225,7 +282,18 @@ export function runCommand(line, cwd, timeoutSeconds) {
|
|
|
225
282
|
// tests catch on Windows. Everywhere else `detached` is true already, so
|
|
226
283
|
// the mutants that make it true are equivalent there, as are
|
|
227
284
|
// `windowsHide`'s.
|
|
228
|
-
|
|
285
|
+
//
|
|
286
|
+
// It is given no input: nobody is there to type any, and a pipe that is
|
|
287
|
+
// never written to and never closed kept a command that reads its input
|
|
288
|
+
// waiting until the timeout stopped it.
|
|
289
|
+
const child = spawn(line, {
|
|
290
|
+
cwd,
|
|
291
|
+
shell: true,
|
|
292
|
+
windowsHide: true,
|
|
293
|
+
detached: process.platform !== 'win32',
|
|
294
|
+
env: probeEnvironment(process.env),
|
|
295
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
296
|
+
});
|
|
229
297
|
running.add(child);
|
|
230
298
|
let output = '';
|
|
231
299
|
const keep = (chunk) => {
|
|
@@ -237,10 +305,23 @@ export function runCommand(line, cwd, timeoutSeconds) {
|
|
|
237
305
|
// and its mutants are equivalent.
|
|
238
306
|
child.stdout?.on('data', keep);
|
|
239
307
|
child.stderr?.on('data', keep);
|
|
308
|
+
const answer = (run) => {
|
|
309
|
+
if (!ending)
|
|
310
|
+
resolve(run);
|
|
311
|
+
};
|
|
240
312
|
let stopped = false;
|
|
313
|
+
let settling;
|
|
241
314
|
const timer = setTimeout(() => {
|
|
242
315
|
stopped = true;
|
|
243
316
|
stop(child);
|
|
317
|
+
settling = setTimeout(() => {
|
|
318
|
+
// With both let go, 'close' follows as soon as Node has seen the
|
|
319
|
+
// shell end, and takes the command off the list of those running.
|
|
320
|
+
// The streams exist, as above, so the mutants of `?.` are equivalent.
|
|
321
|
+
child.stdout?.destroy();
|
|
322
|
+
child.stderr?.destroy();
|
|
323
|
+
answer({ exitCode: null, output, outputHeld: true });
|
|
324
|
+
}, SETTLE_MS);
|
|
244
325
|
}, timeoutSeconds * 1000);
|
|
245
326
|
child.on('error', (error) => {
|
|
246
327
|
// 'close' follows the error of a spawn that failed, and clears the
|
|
@@ -250,9 +331,10 @@ export function runCommand(line, cwd, timeoutSeconds) {
|
|
|
250
331
|
});
|
|
251
332
|
child.on('close', (code) => {
|
|
252
333
|
clearTimeout(timer);
|
|
334
|
+
clearTimeout(settling);
|
|
253
335
|
running.delete(child);
|
|
254
|
-
|
|
255
|
-
|
|
336
|
+
// The second answer of a command already answered as held does nothing.
|
|
337
|
+
answer({ exitCode: stopped ? null : (code ?? 1), output });
|
|
256
338
|
});
|
|
257
339
|
});
|
|
258
340
|
}
|
package/dist/sandbox.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Temporary worktrees: somewhere to run a command at a commit without\n * touching the person's own checkout.\n *\n * The family's tools read git and never write it (spec-core ADR-0005). The one\n * exception is this: a detached worktree under the system's temporary\n * directory, added for one job and removed after it, whether the job\n * succeeded, threw, or the process was interrupted (ADR-0003). The person's\n * branch, index, stash and working files are never touched; a failed round is\n * discarded by discarding its worktree, never by resetting theirs.\n *\n * Git forgets a worktree whatever becomes of its directory. A directory that\n * cannot be deleted, as on Windows while something a command started still\n * runs in it, costs the job nothing: its answer stands, the directory is\n * tried again as the process exits, and one still there then is named on the\n * standard error.\n */\n\nimport { execFileSync, spawn, type ChildProcess } from 'node:child_process';\nimport { mkdtempSync, rmSync } from 'node:fs';\nimport { constants, tmpdir } from 'node:os';\nimport { join } from 'node:path';\n\nimport { addWorktree, removeWorktree } from './git.js';\n\nconst live = new Map<string, string>();\nconst running = new Set<ChildProcess>();\nlet installed = false;\n/** Set once the process is interrupted: from then on a command's end is never answered. */\nlet ending = false;\n\n/**\n * How long the sandbox waits for the commands it stopped to let go of their\n * output and their directory. They were stopped forced, so this is only the\n * time the system takes to end them; whatever still holds either after it has\n * left the tree, and no wait would end it.\n */\nconst SETTLE_MS = 3_000;\n\n/**\n * Stops a command with everything it started, forced: SIGKILL to its process\n * group on Linux and macOS, taskkill over its tree on Windows.\n *\n * `windowsHide` here and below only keeps a console window from opening on\n * Windows, which nothing reads, so its mutants are equivalent.\n */\nfunction stop(child: ChildProcess): void {\n // No pid means the spawn failed and nothing runs. Past the tick it failed\n // in, the kill below throws on the missing id and the fallback finds no\n // process, so the mutant that lets it through is equivalent; within that\n // tick nothing here stops a command.\n if (child.pid === undefined) return;\n try {\n // False already on Linux and macOS, so the mutants that make it false\n // are equivalent there. On Windows the tests fail without taskkill:\n // stopping the shell alone leaves node holding the output. True already\n // on Windows, so the mutant that makes it true is equivalent there;\n // elsewhere there is no taskkill, and the shell alone is stopped.\n if (process.platform === 'win32') execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });\n else process.kill(-child.pid, 'SIGKILL');\n } catch {\n // Reached when the group or the tree is already gone, and the command's\n // shell with it, as when the command left something holding its output;\n // and on Windows when taskkill cannot be run, where stopping the shell is\n // what is left. No test takes that second path.\n child.kill('SIGKILL');\n }\n}\n\n/**\n * Deletes a worktree's directory, trying again for up to `SETTLE_MS` while\n * something holds it, and answers `null` once it is gone, or else why it is\n * not. On Windows a command just stopped lets go of its directory a moment\n * after taskkill returns, and an exit handler, which runs synchronously, has\n * no other way to wait; elsewhere a directory in use is deleted all the same.\n */\nfunction discard(directory: string): Error | null {\n const deadline = Date.now() + SETTLE_MS;\n for (;;) {\n try {\n rmSync(directory, { recursive: true, force: true });\n return null;\n } catch (error) {\n // `>=` would differ only in the millisecond the deadline falls on, so\n // that mutant is equivalent.\n if (Date.now() > deadline) return error as Error;\n // The pause only spares the processor between attempts: without it\n // the directory is deleted, or given up on, at the same moment, so\n // that mutant is equivalent.\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);\n }\n }\n}\n\n/**\n * Has git remove the worktree at `directory` with its record of it, as\n * `removeWorktree` does and synchronously, and answers whether git did.\n */\nfunction forget(directory: string, repository: string): boolean {\n try {\n execFileSync('git', ['worktree', 'remove', '--force', directory], { cwd: repository, stdio: 'ignore', windowsHide: true });\n // Asked again about a worktree it has forgotten, git changes nothing, so\n // the mutant that answers false here is equivalent.\n return true;\n } catch {\n // Git refused a worktree its job broke, could not delete all of the\n // directory, holds nothing of it any more, or cannot be run. The answer\n // is read with `!` and nowhere else, so the mutant that answers nothing\n // here is equivalent.\n return false;\n }\n}\n\n/**\n * Stops every command still running, then removes every live worktree:\n * synchronously, the only kind of work an exit handler may do, so at exit\n * nothing waits for the commands to end but `discard`'s retries. A directory\n * that outlasts those is named on the standard error and left: nothing\n * thrown here could be caught, and the next worktree is still to remove.\n */\nfunction sweep(): void {\n for (const child of running) stop(child);\n for (const [directory, repository] of live) {\n // In the order a job's end takes, for the reasons `withWorktree` gives.\n const forgotten = forget(directory, repository);\n const kept = discard(directory);\n if (kept !== null) process.stderr.write(`spec-harness: the temporary worktree at ${directory} could not be deleted and is left there: ${kept.message}\\n`);\n // The mutant that always asks again is equivalent, as in `forget`.\n else if (!forgotten) forget(directory, repository);\n }\n // A signal handler ends in an exit, which sweeps again: over worktrees\n // already gone and forgotten, where keeping them would change nothing, and\n // over one that could not be deleted, which would be named a second time.\n live.clear();\n}\n\n/**\n * Stops every running command with its tree, waits a bounded time for them to\n * let go of their output, removes the worktrees, and exits as a shell reports\n * a process the signal ended: 128 and the signal's number, the same on every\n * platform Node runs on. The commands lead process groups of their own on\n * Linux and macOS, so the terminal's signal never reached them.\n */\nasync function interrupted(signal: NodeJS.Signals): Promise<void> {\n ending = true;\n const stopping = [...running];\n for (const child of stopping) stop(child);\n await Promise.race([\n Promise.all(stopping.map((child) => new Promise((resolve) => child.once('close', resolve)))),\n new Promise((resolve) => setTimeout(resolve, SETTLE_MS)),\n ]);\n sweep();\n process.exit(128 + constants.signals[signal]);\n}\n\nfunction install(): void {\n if (installed) return;\n installed = true;\n process.once('exit', sweep);\n // `on`, not `once`: a second signal while the first waits would otherwise\n // meet Node's default, which ends the process before any 'exit' handler\n // runs, with the worktrees still in place. It runs the same steps again,\n // and the first to finish exits.\n //\n // SIGBREAK is Windows' own: Ctrl+Break, which GitHub's runner sends there,\n // after Ctrl+C, where it sends SIGTERM elsewhere. Unheard, it ends the\n // process before any 'exit' handler runs, and the commands run on.\n // Elsewhere there is no such signal and nothing calls its listener, so\n // its mutants are equivalent there.\n for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] as const) process.on(signal, interrupted);\n}\n\n/**\n * Runs `work` in a detached worktree of `repository` at `revision`, and\n * removes the worktree after.\n *\n * Git removes it first, while it is as the job left it: git then forgets the\n * worktree whether or not all of its directory can be deleted. What git left\n * of the directory is deleted next, which is all of it when the job broke the\n * worktree, since git refuses one whose `.git` file is gone. Only then is\n * git asked again, once the directory is gone, when it forgets what it finds\n * missing (`removeWorktree`).\n *\n * A directory that cannot be deleted stays on the list for the exit to try\n * again, and does not take the job's answer with it.\n */\nexport async function withWorktree<T>(repository: string, revision: string, work: (directory: string) => Promise<T>): Promise<T> {\n install();\n const directory = mkdtempSync(join(tmpdir(), 'spec-harness-'));\n live.set(directory, repository);\n try {\n await addWorktree(directory, revision, repository);\n return await work(directory);\n } finally {\n const forgotten = await removeWorktree(directory, repository);\n if (discard(directory) === null) {\n // Asked again about a worktree it has forgotten, git changes nothing,\n // so the mutant that always asks again is equivalent.\n if (!forgotten) await removeWorktree(directory, repository);\n live.delete(directory);\n }\n }\n}\n\nexport interface CommandRun {\n /** `null` when the command was stopped at its timeout. */\n readonly exitCode: number | null;\n readonly output: string;\n}\n\n/**\n * Runs a command line in a directory, output and errors interleaved, stopped\n * with everything it started when it outlives `timeoutSeconds` or the process\n * is interrupted or exits. Once the process is interrupted it never answers:\n * the job waiting on it would otherwise carry on in a worktree being removed.\n *\n * Through the shell, on purpose: a probe's command is a line from the\n * repository's own brief - `npm test -- x` - which the person approved with\n * the brief, exactly as CI runs the repository's own scripts. Nothing from\n * outside the repository reaches it.\n */\nexport function runCommand(line: string, cwd: string, timeoutSeconds: number): Promise<CommandRun> {\n install();\n return new Promise((resolve) => {\n // Elsewhere the command leads a process group of its own, so the timeout\n // can stop the group. Windows has none to signal, and taskkill follows\n // the tree instead; a command detached there loses its output, which the\n // tests catch on Windows. Everywhere else `detached` is true already, so\n // the mutants that make it true are equivalent there, as are\n // `windowsHide`'s.\n const child = spawn(line, { cwd, shell: true, windowsHide: true, detached: process.platform !== 'win32', env: { ...process.env, CI: '1' } });\n running.add(child);\n let output = '';\n const keep = (chunk: Buffer): void => {\n // Enough to find a signature in; a runaway log is not kept whole.\n if (output.length < 4 * 1024 * 1024) output += chunk.toString('utf8');\n };\n // Both streams are piped, so both exist: `?.` only satisfies the type,\n // and its mutants are equivalent.\n child.stdout?.on('data', keep);\n child.stderr?.on('data', keep);\n let stopped = false;\n const timer = setTimeout(() => {\n stopped = true;\n stop(child);\n }, timeoutSeconds * 1000);\n child.on('error', (error) => {\n // 'close' follows the error of a spawn that failed, and clears the\n // timer too, so the mutant that drops this call is equivalent.\n clearTimeout(timer);\n resolve({ exitCode: 127, output: `${output}${error.message}\\n` });\n });\n child.on('close', (code) => {\n clearTimeout(timer);\n running.delete(child);\n if (!ending) resolve({ exitCode: stopped ? null : (code ?? 1), output });\n });\n });\n}\n"]}
|
|
1
|
+
{"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Temporary worktrees: somewhere to run a command at a commit without\n * touching the person's own checkout.\n *\n * The family's tools read git and never write it (spec-core ADR-0005). The one\n * exception is this: a detached worktree under the system's temporary\n * directory, added for one job and removed after it, whether the job\n * succeeded, threw, or the process was interrupted (ADR-0003). The person's\n * branch, index, stash and working files are never touched; a failed round is\n * discarded by discarding its worktree, never by resetting theirs.\n *\n * Git forgets a worktree whatever becomes of its directory. A directory that\n * cannot be deleted, as on Windows while something a command started still\n * runs in it, costs the job nothing: its answer stands, the directory is\n * tried again as the process exits, and one still there then is named on the\n * standard error.\n */\n\nimport { execFileSync, spawn, type ChildProcess } from 'node:child_process';\nimport { mkdtempSync, rmSync } from 'node:fs';\nimport { constants, tmpdir } from 'node:os';\nimport { join } from 'node:path';\n\nimport { addWorktree, removeWorktree } from './git.js';\nimport { probeEnvironment } from './probe.js';\n\nconst live = new Map<string, string>();\nconst running = new Set<ChildProcess>();\nlet installed = false;\n/** Set once the process is interrupted: from then on a command's end is never answered. */\nlet ending = false;\n\n/**\n * How long the sandbox waits for the commands it stopped to let go of their\n * output and their directory. They were stopped forced, so this is only the\n * time the system takes to end them; whatever still holds either after it has\n * left the tree, and no wait would end it.\n */\nconst SETTLE_MS = 3_000;\n\n/**\n * Whether a process has this id now. Signal 0 asks and sends nothing, and any\n * answer but \"no such process\" says one has it: another user's process\n * answers that it may not be signalled.\n */\nfunction taken(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code !== 'ESRCH';\n }\n}\n\n/**\n * Stops a command with what can be reached of what it started, forced:\n * SIGKILL to its process group on Linux and macOS, taskkill over its tree on\n * Windows. A process that left the group is not in it, and taskkill follows\n * a tree from parent to child while each parent runs: one whose parent has\n * ended is found under no root. Such a process runs on (ADR-0003).\n *\n * Both name the command by its id, and the id is the command's only while\n * something holds it (ADR-0003). Node holds the command's shell until it has\n * seen the shell end, and until then the system gives the id to nothing else.\n * After that the id is free on Windows, where a process started seconds later\n * can be given it: taskkill would stop that process with all it started, and\n * could not have found what the command left anyway, since it finds no tree\n * under a root that has ended. So a command whose shell has ended is not\n * stopped there. On Linux and macOS the id names the command's process group\n * as well, and stays out of use while the group has a member: what the shell\n * left in its group is still stopped, unless a process has the id now, which\n * says the group emptied and the id was given out again.\n *\n * `windowsHide` here and below only keeps a console window from opening on\n * Windows, which nothing reads, so its mutants are equivalent.\n */\nfunction stop(child: ChildProcess): void {\n // No pid means the spawn failed and nothing runs. Past the tick it failed\n // in, the kill below throws on the missing id and the fallback finds no\n // process, so the mutant that lets it through is equivalent; within that\n // tick nothing here stops a command.\n if (child.pid === undefined) return;\n // Node sets one of the two when it sees the shell end, and lets go of the\n // shell then. Windows reports no signal: there the second is set only once\n // the fallback below has stopped the shell, on the path no test takes.\n const held = child.exitCode === null && child.signalCode === null;\n // Windows is not asked whether a process has the id: one could be given it\n // between the answer and taskkill. Elsewhere `win32` is false already, so\n // the mutants that make it false are equivalent there.\n if (!held && (process.platform === 'win32' || taken(child.pid))) return;\n try {\n // False already on Linux and macOS, so the mutants that make it false\n // are equivalent there. On Windows the tests fail without taskkill:\n // stopping the shell alone leaves node holding the output. True already\n // on Windows, so the mutant that makes it true is equivalent there;\n // elsewhere there is no taskkill, and the shell alone is stopped.\n if (process.platform === 'win32') execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });\n else process.kill(-child.pid, 'SIGKILL');\n } catch {\n // Reached when the group is already gone, and the command's shell with\n // it, as when the command left something outside the group holding its\n // output; on Windows when the shell has only just ended, which taskkill\n // sees before Node does; and there when taskkill cannot be run, where\n // stopping the shell is what is left. No test takes that last path.\n // `kill` goes through Node's own hold on the shell, and does nothing\n // once that is gone.\n child.kill('SIGKILL');\n }\n}\n\n/**\n * Deletes a worktree's directory, trying again for up to `SETTLE_MS` while\n * something holds it, and answers `null` once it is gone, or else why it is\n * not. On Windows a command just stopped lets go of its directory a moment\n * after taskkill returns, and an exit handler, which runs synchronously, has\n * no other way to wait; elsewhere a directory in use is deleted all the same.\n */\nfunction discard(directory: string): Error | null {\n const deadline = Date.now() + SETTLE_MS;\n for (;;) {\n try {\n rmSync(directory, { recursive: true, force: true });\n return null;\n } catch (error) {\n // `>=` would differ only in the millisecond the deadline falls on, so\n // that mutant is equivalent.\n if (Date.now() > deadline) return error as Error;\n // The pause only spares the processor between attempts: without it\n // the directory is deleted, or given up on, at the same moment, so\n // that mutant is equivalent.\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);\n }\n }\n}\n\n/**\n * Has git remove the worktree at `directory` with its record of it, as\n * `removeWorktree` does and synchronously, and answers whether git did.\n */\nfunction forget(directory: string, repository: string): boolean {\n try {\n execFileSync('git', ['worktree', 'remove', '--force', directory], { cwd: repository, stdio: 'ignore', windowsHide: true });\n // Asked again about a worktree it has forgotten, git changes nothing, so\n // the mutant that answers false here is equivalent.\n return true;\n } catch {\n // Git refused a worktree its job broke, could not delete all of the\n // directory, holds nothing of it any more, or cannot be run. The answer\n // is read with `!` and nowhere else, so the mutant that answers nothing\n // here is equivalent.\n return false;\n }\n}\n\n/**\n * Stops every command still running, then removes every live worktree:\n * synchronously, the only kind of work an exit handler may do, so at exit\n * nothing waits for the commands to end but `discard`'s retries. A directory\n * that outlasts those is named on the standard error and left: nothing\n * thrown here could be caught, and the next worktree is still to remove.\n */\nfunction sweep(): void {\n for (const child of running) stop(child);\n for (const [directory, repository] of live) {\n // In the order a job's end takes, for the reasons `withWorktree` gives.\n const forgotten = forget(directory, repository);\n const kept = discard(directory);\n if (kept !== null) process.stderr.write(`spec-harness: the temporary worktree at ${directory} could not be deleted and is left there: ${kept.message}\\n`);\n // The mutant that always asks again is equivalent, as in `forget`.\n else if (!forgotten) forget(directory, repository);\n }\n // A signal handler ends in an exit, which sweeps again: over worktrees\n // already gone and forgotten, where keeping them would change nothing, and\n // over one that could not be deleted, which would be named a second time.\n live.clear();\n}\n\n/**\n * Stops every running command with its tree, waits a bounded time for them to\n * let go of their output, removes the worktrees, and exits as a shell reports\n * a process the signal ended: 128 and the signal's number, the same on every\n * platform Node runs on. The commands lead process groups of their own on\n * Linux and macOS, so the terminal's signal never reached them.\n */\nasync function interrupted(signal: NodeJS.Signals): Promise<void> {\n ending = true;\n const stopping = [...running];\n for (const child of stopping) stop(child);\n await Promise.race([\n Promise.all(stopping.map((child) => new Promise((resolve) => child.once('close', resolve)))),\n new Promise((resolve) => setTimeout(resolve, SETTLE_MS)),\n ]);\n sweep();\n process.exit(128 + constants.signals[signal]);\n}\n\nfunction install(): void {\n if (installed) return;\n installed = true;\n process.once('exit', sweep);\n // `on`, not `once`: a second signal while the first waits would otherwise\n // meet Node's default, which ends the process before any 'exit' handler\n // runs, with the worktrees still in place. It runs the same steps again,\n // and the first to finish exits.\n //\n // SIGBREAK is Windows' own: Ctrl+Break, which GitHub's runner sends there,\n // after Ctrl+C, where it sends SIGTERM elsewhere. Unheard, it ends the\n // process before any 'exit' handler runs, and the commands run on.\n // Elsewhere there is no such signal and nothing calls its listener, so\n // its mutants are equivalent there.\n for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] as const) process.on(signal, interrupted);\n}\n\n/**\n * Runs `work` in a detached worktree of `repository` at `revision`, and\n * removes the worktree after.\n *\n * Git removes it first, while it is as the job left it: git then forgets the\n * worktree whether or not all of its directory can be deleted. What git left\n * of the directory is deleted next, which is all of it when the job broke the\n * worktree, since git refuses one whose `.git` file is gone. Only then is\n * git asked again, once the directory is gone, when it forgets what it finds\n * missing (`removeWorktree`).\n *\n * A directory that cannot be deleted stays on the list for the exit to try\n * again, and does not take the job's answer with it.\n */\nexport async function withWorktree<T>(repository: string, revision: string, work: (directory: string) => Promise<T>): Promise<T> {\n install();\n const directory = mkdtempSync(join(tmpdir(), 'spec-harness-'));\n live.set(directory, repository);\n try {\n await addWorktree(directory, revision, repository);\n return await work(directory);\n } finally {\n const forgotten = await removeWorktree(directory, repository);\n if (discard(directory) === null) {\n // Asked again about a worktree it has forgotten, git changes nothing,\n // so the mutant that always asks again is equivalent.\n if (!forgotten) await removeWorktree(directory, repository);\n live.delete(directory);\n }\n }\n}\n\nexport interface CommandRun {\n /** `null` when the command was stopped at its timeout. */\n readonly exitCode: number | null;\n /** What the command had printed by the time it was answered. */\n readonly output: string;\n /**\n * Set on a command that was stopped at its timeout and answered with its\n * output still open: something it started holds the output where the stop\n * did not reach it, and runs on (ADR-0003).\n */\n readonly outputHeld?: true;\n}\n\n/**\n * Runs a command line in a directory, output and errors interleaved, until\n * its output closes. A command that outlives `timeoutSeconds` is stopped\n * with what `stop` reaches of it, as is one still running when the process\n * is interrupted or exits. Once the process is interrupted it never answers:\n * the job waiting on it would otherwise carry on in a worktree being removed.\n *\n * The timeout bounds the wait as well as the command. What the stop reached\n * lets go of the output in the time the system takes to end it, and the\n * command answers then. What still holds the output `SETTLE_MS` later is\n * what `stop` does not reach, and no wait would end it: the sandbox lets go\n * of the output instead, and answers with what was printed and with\n * `outputHeld`. Letting go is what the end of this process would do to\n * whatever holds the output, only sooner, and without it the open output\n * keeps this process from ending once its work is done.\n *\n * Through the shell, on purpose: a probe's command is a line from the\n * repository's own brief - `npm test -- x` - which the person approved with\n * the brief, exactly as CI runs the repository's own scripts. Nothing from\n * outside the repository reaches it.\n *\n * The line is run as it is written. Its environment is the person's own as\n * `probeEnvironment` hands it on: `CI` set, and npm told to fetch nothing for\n * a command where neither that environment nor the line says what it may\n * fetch (ADR-0007).\n */\nexport function runCommand(line: string, cwd: string, timeoutSeconds: number): Promise<CommandRun> {\n install();\n return new Promise((resolve) => {\n // Elsewhere the command leads a process group of its own, so the timeout\n // can stop the group. Windows has none to signal, and taskkill follows\n // the tree instead; a command detached there loses its output, which the\n // tests catch on Windows. Everywhere else `detached` is true already, so\n // the mutants that make it true are equivalent there, as are\n // `windowsHide`'s.\n //\n // It is given no input: nobody is there to type any, and a pipe that is\n // never written to and never closed kept a command that reads its input\n // waiting until the timeout stopped it.\n const child = spawn(line, {\n cwd,\n shell: true,\n windowsHide: true,\n detached: process.platform !== 'win32',\n env: probeEnvironment(process.env),\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n running.add(child);\n let output = '';\n const keep = (chunk: Buffer): void => {\n // Enough to find a signature in; a runaway log is not kept whole.\n if (output.length < 4 * 1024 * 1024) output += chunk.toString('utf8');\n };\n // Both streams are piped, so both exist: `?.` only satisfies the type,\n // and its mutants are equivalent.\n child.stdout?.on('data', keep);\n child.stderr?.on('data', keep);\n const answer = (run: CommandRun): void => {\n if (!ending) resolve(run);\n };\n let stopped = false;\n let settling: NodeJS.Timeout | undefined;\n const timer = setTimeout(() => {\n stopped = true;\n stop(child);\n settling = setTimeout(() => {\n // With both let go, 'close' follows as soon as Node has seen the\n // shell end, and takes the command off the list of those running.\n // The streams exist, as above, so the mutants of `?.` are equivalent.\n child.stdout?.destroy();\n child.stderr?.destroy();\n answer({ exitCode: null, output, outputHeld: true });\n }, SETTLE_MS);\n }, timeoutSeconds * 1000);\n child.on('error', (error) => {\n // 'close' follows the error of a spawn that failed, and clears the\n // timer too, so the mutant that drops this call is equivalent.\n clearTimeout(timer);\n resolve({ exitCode: 127, output: `${output}${error.message}\\n` });\n });\n child.on('close', (code) => {\n clearTimeout(timer);\n clearTimeout(settling);\n running.delete(child);\n // The second answer of a command already answered as held does nothing.\n answer({ exitCode: stopped ? null : (code ?? 1), output });\n });\n });\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@descent-vtt/spec-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "The agent-facing member of the spec-* tools: context for a round, a guard on every write, an audit when it ends, probes that prove a defect before it is filed, and rulings a person signs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -66,8 +66,9 @@ the defect.
|
|
|
66
66
|
The probe runs in a fresh worktree, where nothing is installed until its
|
|
67
67
|
`setup` line installs it, as `setup: npm ci` does. Make `run` one of the
|
|
68
68
|
project's own scripts, such as `npm test -- <file>`, and never `npx` with a
|
|
69
|
-
tool's name alone: where the tool is not installed, npm
|
|
70
|
-
registry has under that name
|
|
69
|
+
tool's name alone: where the tool is not installed, npm would fetch whatever
|
|
70
|
+
the registry has under that name, so `probe` has it stop there and the probe
|
|
71
|
+
is `invalid`. Never add `--yes` to get past that. Then:
|
|
71
72
|
|
|
72
73
|
```bash
|
|
73
74
|
npx --no-install @descent-vtt/spec-harness probe <id> --at base
|
|
@@ -75,7 +76,8 @@ npx --no-install @descent-vtt/spec-harness probe <id> --at base
|
|
|
75
76
|
|
|
76
77
|
`measured` means the defect is real; paste the evidence table into the
|
|
77
78
|
brief. `vacuous` means it is not there: say so to the person and stop.
|
|
78
|
-
`invalid` or `flaky` means the probe proves nothing yet:
|
|
79
|
+
`invalid` or `flaky` means the probe proves nothing yet: read the end of its
|
|
80
|
+
command's output, printed after the table, and fix the probe.
|
|
79
81
|
|
|
80
82
|
## 4. Check, then hand over
|
|
81
83
|
|