@cruxy/cli 0.9.0 → 0.11.0
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/approval/classify.js +21 -0
- package/dist/approval/policy.js +6 -0
- package/dist/approval/prompt.js +8 -29
- package/dist/approval/types.d.ts +5 -0
- package/dist/cli/commands/rollback.js +45 -0
- package/dist/cli/commands/test.d.ts +9 -0
- package/dist/cli/commands/test.js +47 -0
- package/dist/cli/program.js +2 -0
- package/dist/cli/repl.d.ts +5 -0
- package/dist/cli/repl.js +17 -0
- package/dist/cli/session-factory.js +6 -2
- package/dist/components/autocomplete.d.ts +32 -0
- package/dist/components/autocomplete.js +50 -0
- package/dist/components/frame.d.ts +25 -0
- package/dist/components/frame.js +49 -0
- package/dist/components/fuzzy.d.ts +61 -0
- package/dist/components/fuzzy.js +174 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +6 -0
- package/dist/components/input.d.ts +78 -0
- package/dist/components/input.js +111 -0
- package/dist/components/keys.d.ts +48 -0
- package/dist/components/keys.js +105 -0
- package/dist/components/select.d.ts +28 -0
- package/dist/components/select.js +69 -0
- package/dist/config/schema.d.ts +47 -0
- package/dist/config/schema.js +20 -0
- package/dist/errors/constructors.d.ts +12 -0
- package/dist/errors/constructors.js +31 -0
- package/dist/errors/types.d.ts +4 -0
- package/dist/errors/types.js +10 -0
- package/dist/onboarding/io.d.ts +3 -2
- package/dist/onboarding/io.js +35 -81
- package/dist/testing/detect.d.ts +3 -0
- package/dist/testing/detect.js +44 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/parse.d.ts +33 -0
- package/dist/testing/parse.js +137 -0
- package/dist/testing/run-tests-tool.d.ts +42 -0
- package/dist/testing/run-tests-tool.js +128 -0
- package/dist/testing/runner.d.ts +26 -0
- package/dist/testing/runner.js +124 -0
- package/dist/testing/types.d.ts +61 -0
- package/dist/testing/types.js +7 -0
- package/dist/tools/registry.js +3 -0
- package/dist/tools/types.d.ts +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ErrorCode } from "../errors/index.js";
|
|
3
|
+
import { detectTestCommand } from "./detect.js";
|
|
4
|
+
import { CommandTestRunner } from "./runner.js";
|
|
5
|
+
/**
|
|
6
|
+
* The `run_tests` tool (C.13): execute the project's test suite and return a
|
|
7
|
+
* structured result the model can iterate on (edit → re-run → repeat). The
|
|
8
|
+
* iteration loop itself is the ordinary agent loop; this module contributes
|
|
9
|
+
* the two guarantees that make it trustworthy — honest green (exit-code-only)
|
|
10
|
+
* and a hard cap on consecutive failing runs.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Counts consecutive FAILING test executions; a green run resets it. When the
|
|
14
|
+
* count reaches the cap, the next attempt is refused with the coded
|
|
15
|
+
* CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes), and the counter
|
|
16
|
+
* resets — so the episode ends loudly, but a deliberate later attempt (a new
|
|
17
|
+
* user instruction) starts fresh rather than finding a permanently dead tool.
|
|
18
|
+
* Per-turn work stays bounded regardless via `agent.maxIterations`.
|
|
19
|
+
*/
|
|
20
|
+
export class TestIterationBudget {
|
|
21
|
+
failedRuns = 0;
|
|
22
|
+
/** Runs already spent in the current failing streak. */
|
|
23
|
+
get spent() {
|
|
24
|
+
return this.failedRuns;
|
|
25
|
+
}
|
|
26
|
+
/** True when the next run must be refused; resets the streak as it trips. */
|
|
27
|
+
trip(maxIterations) {
|
|
28
|
+
if (this.failedRuns < maxIterations)
|
|
29
|
+
return false;
|
|
30
|
+
this.failedRuns = 0;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
record(passed) {
|
|
34
|
+
this.failedRuns = passed ? 0 : this.failedRuns + 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const parameters = z.object({
|
|
38
|
+
command: z
|
|
39
|
+
.string()
|
|
40
|
+
.min(1)
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Override the detected test command (still requires approval). " +
|
|
43
|
+
"Omit to use the project's configured/detected command."),
|
|
44
|
+
});
|
|
45
|
+
/** The wire shape fed back to the model — structured, bounded, honest. */
|
|
46
|
+
function renderResult(result, command, iteration) {
|
|
47
|
+
return JSON.stringify({
|
|
48
|
+
passed: result.passed,
|
|
49
|
+
exitCode: result.exitCode,
|
|
50
|
+
durationMs: result.durationMs,
|
|
51
|
+
command: command.command,
|
|
52
|
+
...(result.total !== undefined ? { total: result.total } : {}),
|
|
53
|
+
failures: result.failures,
|
|
54
|
+
iteration,
|
|
55
|
+
output: result.output,
|
|
56
|
+
outputTruncated: result.outputTruncated,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Build the `run_tests` tool. One instance = one session's iteration budget. */
|
|
60
|
+
export function makeRunTestsTool(deps = {}) {
|
|
61
|
+
const runner = deps.runner ?? new CommandTestRunner();
|
|
62
|
+
const detect = deps.detect ??
|
|
63
|
+
((ctx) => detectTestCommand(ctx.cwd, ctx.config));
|
|
64
|
+
const budget = new TestIterationBudget();
|
|
65
|
+
return {
|
|
66
|
+
name: "run_tests",
|
|
67
|
+
description: "Run the project's test suite and get a structured result: passed (from the exit code), " +
|
|
68
|
+
"extracted failures with file/line where recognizable, and the output tail. " +
|
|
69
|
+
"Use it to verify changes: run, read the failures, fix, re-run. The edit→re-run loop is " +
|
|
70
|
+
"capped — when the iteration limit trips, stop, summarize the remaining failures, and ask the user.",
|
|
71
|
+
parameters,
|
|
72
|
+
async execute(input, ctx) {
|
|
73
|
+
// Resolve the command first: detection failure needs no approval and
|
|
74
|
+
// must be a coded, actionable error — never an invented command.
|
|
75
|
+
let resolved;
|
|
76
|
+
if (input.command !== undefined) {
|
|
77
|
+
resolved = { command: input.command, source: "override" };
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const detected = detect(ctx);
|
|
81
|
+
if (detected === null)
|
|
82
|
+
return { ok: false, error: NOT_FOUND };
|
|
83
|
+
resolved = detected;
|
|
84
|
+
}
|
|
85
|
+
// Iteration cap BEFORE the gate and the run: a refused attempt executes
|
|
86
|
+
// nothing and costs nothing.
|
|
87
|
+
const max = ctx.config.test.maxIterations;
|
|
88
|
+
if (budget.trip(max)) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: `${ErrorCode.TestIterationLimit}: tests are still failing after ${max} run${max === 1 ? "" : "s"}. ` +
|
|
92
|
+
"Stop iterating. Summarize the remaining failures and what you tried, then ask the user how to proceed.",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// U.3 gate (destructive tier, exact-command grant scope). A thrown
|
|
96
|
+
// CRUXY_E_APPROVAL_REQUIRED (non-interactive) propagates — do not catch.
|
|
97
|
+
const decision = await ctx.requestApproval({
|
|
98
|
+
kind: "test",
|
|
99
|
+
command: resolved.command,
|
|
100
|
+
});
|
|
101
|
+
if (!decision.allow) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: decision.feedback ?? "test run denied by the user",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const result = await runner.run(resolved.command, {
|
|
108
|
+
cwd: ctx.cwd,
|
|
109
|
+
timeoutMs: ctx.config.shell.timeoutMs,
|
|
110
|
+
captureBytes: ctx.config.test.captureBytes,
|
|
111
|
+
});
|
|
112
|
+
budget.record(result.passed);
|
|
113
|
+
const payload = renderResult(result, resolved, {
|
|
114
|
+
run: result.passed ? 0 : budget.spent,
|
|
115
|
+
max,
|
|
116
|
+
});
|
|
117
|
+
// Failing tests are an is_error result so the U.4 trail note reads ✗ —
|
|
118
|
+
// the payload is identical either way; the model reasons over both.
|
|
119
|
+
return result.passed
|
|
120
|
+
? { ok: true, output: payload }
|
|
121
|
+
: { ok: false, error: payload };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** The coded not-configured message, actionable for model and user alike. */
|
|
126
|
+
const NOT_FOUND = `${ErrorCode.TestCommandNotFound}: no test command found — package.json has no usable ` +
|
|
127
|
+
"`scripts.test` and `test.command` is not configured. Ask the user to set `test.command` " +
|
|
128
|
+
'(e.g. `cruxy config set test.command "pnpm test"`); do not guess a command.';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TestRunner, TestRunOptions, TestRunResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The shipped {@link TestRunner}: spawn the command via the system shell (the
|
|
4
|
+
* same detached-group + kill-tree discipline as `run_command`), capture a
|
|
5
|
+
* TAIL-biased, byte-capped transcript (failures live at the end of test
|
|
6
|
+
* output), and derive `passed` from the exit code — the only source of truth.
|
|
7
|
+
* A timeout, a signal kill, or a spawn error is a *failed result*, never a
|
|
8
|
+
* thrown exception and never a fabricated success.
|
|
9
|
+
*/
|
|
10
|
+
export declare class CommandTestRunner implements TestRunner {
|
|
11
|
+
run(command: string, opts: TestRunOptions): Promise<TestRunResult>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A rolling, tail-biased capture: whole chunks are dropped from the FRONT
|
|
15
|
+
* once the byte cap is exceeded, so the end of the output — where test
|
|
16
|
+
* runners print their failure summaries — always survives.
|
|
17
|
+
*/
|
|
18
|
+
export declare class TailCapture {
|
|
19
|
+
private readonly cap;
|
|
20
|
+
private chunks;
|
|
21
|
+
private bytes;
|
|
22
|
+
truncated: boolean;
|
|
23
|
+
constructor(cap: number);
|
|
24
|
+
push(buf: Buffer): void;
|
|
25
|
+
text(): string;
|
|
26
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { parseFailures } from "./parse.js";
|
|
3
|
+
/**
|
|
4
|
+
* The shipped {@link TestRunner}: spawn the command via the system shell (the
|
|
5
|
+
* same detached-group + kill-tree discipline as `run_command`), capture a
|
|
6
|
+
* TAIL-biased, byte-capped transcript (failures live at the end of test
|
|
7
|
+
* output), and derive `passed` from the exit code — the only source of truth.
|
|
8
|
+
* A timeout, a signal kill, or a spawn error is a *failed result*, never a
|
|
9
|
+
* thrown exception and never a fabricated success.
|
|
10
|
+
*/
|
|
11
|
+
export class CommandTestRunner {
|
|
12
|
+
run(command, opts) {
|
|
13
|
+
const startedAt = Date.now();
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const capture = new TailCapture(opts.captureBytes);
|
|
16
|
+
let child;
|
|
17
|
+
try {
|
|
18
|
+
child = spawn(command, {
|
|
19
|
+
shell: true,
|
|
20
|
+
cwd: opts.cwd,
|
|
21
|
+
detached: true,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
resolve(failed(null, err.message, startedAt));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
child.stdout?.on("data", (buf) => capture.push(buf));
|
|
29
|
+
child.stderr?.on("data", (buf) => capture.push(buf));
|
|
30
|
+
let settled = false;
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
killTree(child.pid);
|
|
36
|
+
resolve(failed(null, capture.text() +
|
|
37
|
+
`\n… [test run timed out after ${opts.timeoutMs}ms and was killed]`, startedAt, capture.truncated));
|
|
38
|
+
}, opts.timeoutMs);
|
|
39
|
+
child.on("error", (err) => {
|
|
40
|
+
if (settled)
|
|
41
|
+
return;
|
|
42
|
+
settled = true;
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
resolve(failed(null, err.message, startedAt));
|
|
45
|
+
});
|
|
46
|
+
child.on("close", (code) => {
|
|
47
|
+
if (settled)
|
|
48
|
+
return;
|
|
49
|
+
settled = true;
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
const output = capture.text();
|
|
52
|
+
// THE honest-green rule: exit code 0 and nothing else means passed.
|
|
53
|
+
// `code` is null on a signal kill — a failure, whatever the output says.
|
|
54
|
+
const passed = code === 0;
|
|
55
|
+
const parsed = passed ? { failures: [] } : parseFailures(output);
|
|
56
|
+
resolve({
|
|
57
|
+
passed,
|
|
58
|
+
exitCode: code,
|
|
59
|
+
durationMs: Date.now() - startedAt,
|
|
60
|
+
...(parsed.total !== undefined ? { total: parsed.total } : {}),
|
|
61
|
+
failures: parsed.failures,
|
|
62
|
+
output,
|
|
63
|
+
outputTruncated: capture.truncated,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Shape a non-execution failure (spawn error, timeout) as a failed result. */
|
|
70
|
+
function failed(exitCode, output, startedAt, truncated = false) {
|
|
71
|
+
return {
|
|
72
|
+
passed: false,
|
|
73
|
+
exitCode,
|
|
74
|
+
durationMs: Date.now() - startedAt,
|
|
75
|
+
failures: [],
|
|
76
|
+
output,
|
|
77
|
+
outputTruncated: truncated,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A rolling, tail-biased capture: whole chunks are dropped from the FRONT
|
|
82
|
+
* once the byte cap is exceeded, so the end of the output — where test
|
|
83
|
+
* runners print their failure summaries — always survives.
|
|
84
|
+
*/
|
|
85
|
+
export class TailCapture {
|
|
86
|
+
cap;
|
|
87
|
+
chunks = [];
|
|
88
|
+
bytes = 0;
|
|
89
|
+
truncated = false;
|
|
90
|
+
constructor(cap) {
|
|
91
|
+
this.cap = cap;
|
|
92
|
+
}
|
|
93
|
+
push(buf) {
|
|
94
|
+
this.chunks.push(buf);
|
|
95
|
+
this.bytes += buf.length;
|
|
96
|
+
// Drop head chunks while the REMAINDER still meets the cap.
|
|
97
|
+
while (this.chunks.length > 1 &&
|
|
98
|
+
this.bytes - this.chunks[0].length >= this.cap) {
|
|
99
|
+
this.bytes -= this.chunks[0].length;
|
|
100
|
+
this.chunks.shift();
|
|
101
|
+
this.truncated = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
text() {
|
|
105
|
+
let all = Buffer.concat(this.chunks);
|
|
106
|
+
if (all.length > this.cap) {
|
|
107
|
+
all = all.subarray(all.length - this.cap);
|
|
108
|
+
this.truncated = true;
|
|
109
|
+
}
|
|
110
|
+
const body = all.toString("utf8");
|
|
111
|
+
return this.truncated ? `… [earlier output truncated]\n${body}` : body;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Kill the whole process group (POSIX; matches run_command's behavior). */
|
|
115
|
+
function killTree(pid) {
|
|
116
|
+
if (pid === undefined)
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
process.kill(-pid, "SIGKILL");
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Already exited, or no group — nothing to kill.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the test-execution loop (C.13): run the project's test suite,
|
|
3
|
+
* parse what's honestly parseable, and let the agent iterate on failures
|
|
4
|
+
* under a hard cap. The cardinal rule lives in `runner.ts`: **passed is
|
|
5
|
+
* derived from the exit code and nothing else.**
|
|
6
|
+
*/
|
|
7
|
+
/** A resolved test command and where it came from — shown, never guessed. */
|
|
8
|
+
export interface TestCommand {
|
|
9
|
+
command: string;
|
|
10
|
+
source: "config" | "package-json";
|
|
11
|
+
}
|
|
12
|
+
/** One extracted failure. Every field beyond `name`/`message` is best-effort. */
|
|
13
|
+
export interface TestFailure {
|
|
14
|
+
name: string;
|
|
15
|
+
message: string;
|
|
16
|
+
file?: string;
|
|
17
|
+
line?: number;
|
|
18
|
+
}
|
|
19
|
+
/** The structured outcome of one test-suite execution. */
|
|
20
|
+
export interface TestRunResult {
|
|
21
|
+
/** `exitCode === 0`, full stop — never inferred from output text. */
|
|
22
|
+
passed: boolean;
|
|
23
|
+
/** The command's exit code; `null` when killed by signal or never spawned. */
|
|
24
|
+
exitCode: number | null;
|
|
25
|
+
/** Measured wall time of the execution. */
|
|
26
|
+
durationMs: number;
|
|
27
|
+
/** Total tests, only when a parser confidently extracted it. */
|
|
28
|
+
total?: number;
|
|
29
|
+
/** Best-effort extracted failures; may be empty even when `passed` is false. */
|
|
30
|
+
failures: TestFailure[];
|
|
31
|
+
/** Tail-biased captured stdout+stderr, capped at `test.captureBytes`. */
|
|
32
|
+
output: string;
|
|
33
|
+
/** Whether the head of the output was dropped to honor the byte cap. */
|
|
34
|
+
outputTruncated: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Execution bounds handed to a runner per run. */
|
|
37
|
+
export interface TestRunOptions {
|
|
38
|
+
cwd: string;
|
|
39
|
+
/** Kill the run (and its process tree) after this many ms. */
|
|
40
|
+
timeoutMs: number;
|
|
41
|
+
/** Cap on captured output bytes (tail-biased). */
|
|
42
|
+
captureBytes: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The swappable execution seam (same discipline as VectorStore/ForgeProvider):
|
|
46
|
+
* the shipped {@link CommandTestRunner} spawns the command via the system
|
|
47
|
+
* shell; tests inject fakes, and a future framework-native runner (e.g. a
|
|
48
|
+
* vitest API runner) slots in without touching the tool.
|
|
49
|
+
*/
|
|
50
|
+
export interface TestRunner {
|
|
51
|
+
run(command: string, opts: TestRunOptions): Promise<TestRunResult>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A pluggable best-effort failure extractor: given the captured output,
|
|
55
|
+
* return whatever structure it can positively recognize — and nothing it
|
|
56
|
+
* can't. Parsers never touch `passed` and never fabricate counts.
|
|
57
|
+
*/
|
|
58
|
+
export type FailureParser = (output: string) => {
|
|
59
|
+
failures: TestFailure[];
|
|
60
|
+
total?: number;
|
|
61
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the test-execution loop (C.13): run the project's test suite,
|
|
3
|
+
* parse what's honestly parseable, and let the agent iterate on failures
|
|
4
|
+
* under a hard cap. The cardinal rule lives in `runner.ts`: **passed is
|
|
5
|
+
* derived from the exit code and nothing else.**
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
package/dist/tools/registry.js
CHANGED
|
@@ -3,6 +3,7 @@ import { listFilesTool } from "./list-files.js";
|
|
|
3
3
|
import { gitStatusTool } from "./git-status.js";
|
|
4
4
|
import { readFileTool, writeFileTool, editFileTool, applyPatchTool, globTool, grepFilesTool, } from "./file/index.js";
|
|
5
5
|
import { runCommandTool } from "./shell/index.js";
|
|
6
|
+
import { makeRunTestsTool } from "../testing/run-tests-tool.js";
|
|
6
7
|
import { searchCodebaseTool } from "./search-codebase.js";
|
|
7
8
|
import { listSkillsTool } from "./list-skills.js";
|
|
8
9
|
import { loadSkillTool } from "./load-skill.js";
|
|
@@ -63,6 +64,8 @@ export function buildDefaultRegistry() {
|
|
|
63
64
|
registry.register(grepFilesTool);
|
|
64
65
|
registry.register(gitStatusTool);
|
|
65
66
|
registry.register(runCommandTool);
|
|
67
|
+
// A fresh tool per registry — its iteration budget (C.13) is session-scoped.
|
|
68
|
+
registry.register(makeRunTestsTool());
|
|
66
69
|
registry.register(searchCodebaseTool);
|
|
67
70
|
registry.register(listSkillsTool);
|
|
68
71
|
registry.register(loadSkillTool);
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -101,10 +101,10 @@ export type ActionPreview =
|
|
|
101
101
|
*/
|
|
102
102
|
export interface ApproveAction {
|
|
103
103
|
/** The category of side effect being requested. */
|
|
104
|
-
kind: "write" | "edit" | "shell" | "patch" | "vcs" | "rollback";
|
|
104
|
+
kind: "write" | "edit" | "shell" | "patch" | "vcs" | "rollback" | "test";
|
|
105
105
|
/** Absolute resolved path the action targets (write/edit). */
|
|
106
106
|
path?: string;
|
|
107
|
-
/** The command to run (shell). */
|
|
107
|
+
/** The command to run (shell / test). */
|
|
108
108
|
command?: string;
|
|
109
109
|
/** Exact-change preview rendered above the prompt (write/edit/patch/vcs/rollback). */
|
|
110
110
|
preview?: ActionPreview;
|