@cruxy/cli 0.10.0 → 0.12.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 +21 -17
- package/dist/approval/types.d.ts +5 -0
- package/dist/cli/commands/checkpoint.js +6 -4
- package/dist/cli/commands/config.js +10 -7
- package/dist/cli/commands/index.js +16 -15
- package/dist/cli/commands/init.js +5 -3
- package/dist/cli/commands/login.js +5 -3
- package/dist/cli/commands/pr.js +8 -7
- package/dist/cli/commands/rollback.js +7 -6
- package/dist/cli/commands/run.js +7 -6
- package/dist/cli/commands/skills.js +12 -10
- package/dist/cli/commands/test.d.ts +9 -0
- package/dist/cli/commands/test.js +47 -0
- package/dist/cli/program.js +9 -6
- package/dist/cli/repl.js +11 -9
- package/dist/cli/session-factory.js +6 -2
- package/dist/components/frame.js +3 -1
- package/dist/components/fuzzy.d.ts +4 -4
- package/dist/components/fuzzy.js +14 -13
- package/dist/components/select.js +8 -7
- package/dist/config/schema.d.ts +47 -0
- package/dist/config/schema.js +20 -0
- package/dist/errors/constructors.d.ts +5 -0
- package/dist/errors/constructors.js +16 -0
- package/dist/errors/format.js +8 -8
- package/dist/errors/types.d.ts +3 -0
- package/dist/errors/types.js +8 -0
- package/dist/onboarding/flow.js +6 -6
- package/dist/onboarding/steps.js +11 -11
- package/dist/plan/approve.js +6 -6
- package/dist/plan/render.js +26 -18
- package/dist/render/capabilities.js +4 -0
- package/dist/render/diff.d.ts +6 -7
- package/dist/render/diff.js +33 -22
- package/dist/render/highlight.d.ts +3 -3
- package/dist/render/highlight.js +15 -15
- package/dist/render/index.d.ts +1 -1
- package/dist/render/plain-renderer.d.ts +2 -1
- package/dist/render/plain-renderer.js +7 -6
- package/dist/render/state.d.ts +7 -2
- package/dist/render/state.js +16 -10
- package/dist/render/tty-renderer.d.ts +2 -1
- package/dist/render/tty-renderer.js +20 -17
- package/dist/render/types.d.ts +7 -0
- package/dist/subagent/orchestrator.js +21 -6
- 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/theme/index.d.ts +2 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/resolve.d.ts +32 -0
- package/dist/theme/resolve.js +73 -0
- package/dist/theme/tokens.d.ts +104 -0
- package/dist/theme/tokens.js +52 -0
- package/dist/tools/registry.js +3 -0
- package/dist/tools/types.d.ts +2 -2
- package/dist/utils/logger.d.ts +2 -0
- package/dist/utils/logger.js +7 -4
- package/package.json +1 -1
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { resolveTheme } from "../theme/index.js";
|
|
2
2
|
import { createStreamPrinter } from "../cli/stream-print.js";
|
|
3
3
|
import { renderActionPreview } from "./diff.js";
|
|
4
4
|
import { createStreamHighlighter, } from "./highlight.js";
|
|
5
5
|
import { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
|
|
6
6
|
/** Erase the current line and return the cursor to column 0. */
|
|
7
7
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
8
|
-
/** Spinner frames (braille); a static glyph when animation is disabled. */
|
|
9
|
-
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
10
|
-
const STATIC_FRAME = "◐";
|
|
11
8
|
const SPINNER_INTERVAL_MS = 100;
|
|
12
9
|
/**
|
|
13
10
|
* The interactive renderer: committed content is append-only; the one transient
|
|
@@ -37,7 +34,7 @@ const SPINNER_INTERVAL_MS = 100;
|
|
|
37
34
|
export class TtyRenderer {
|
|
38
35
|
caps;
|
|
39
36
|
out;
|
|
40
|
-
|
|
37
|
+
theme;
|
|
41
38
|
print;
|
|
42
39
|
highlighter;
|
|
43
40
|
wroteInSegment = false;
|
|
@@ -59,8 +56,8 @@ export class TtyRenderer {
|
|
|
59
56
|
constructor(caps, out) {
|
|
60
57
|
this.caps = caps;
|
|
61
58
|
this.out = out;
|
|
62
|
-
this.
|
|
63
|
-
this.highlighter = createStreamHighlighter(this.
|
|
59
|
+
this.theme = resolveTheme(caps);
|
|
60
|
+
this.highlighter = createStreamHighlighter(this.theme);
|
|
64
61
|
this.print = this.newPrinter();
|
|
65
62
|
}
|
|
66
63
|
newPrinter() {
|
|
@@ -115,7 +112,7 @@ export class TtyRenderer {
|
|
|
115
112
|
const elapsed = this.phase !== null && this.caps.spinner
|
|
116
113
|
? Date.now() - this.phaseStartedAt
|
|
117
114
|
: undefined;
|
|
118
|
-
return composeStatusLine(this.progressState, this.phase, elapsed);
|
|
115
|
+
return composeStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph);
|
|
119
116
|
}
|
|
120
117
|
/** Redraw the live line from current state, or hide it when there is none. */
|
|
121
118
|
refresh() {
|
|
@@ -138,16 +135,19 @@ export class TtyRenderer {
|
|
|
138
135
|
}
|
|
139
136
|
drawLine(text) {
|
|
140
137
|
this.lineVisible = true;
|
|
138
|
+
const frames = this.theme.glyph.spinnerFrames;
|
|
141
139
|
const glyph = this.caps.spinner
|
|
142
|
-
?
|
|
143
|
-
:
|
|
140
|
+
? frames[this.frame % frames.length]
|
|
141
|
+
: this.theme.glyph.spinnerStatic;
|
|
144
142
|
// Reserve glyph + space; truncate so the live line can never soft-wrap.
|
|
145
143
|
const room = Math.max(1, this.caps.width - 2);
|
|
146
|
-
const line = text.length > room
|
|
147
|
-
|
|
144
|
+
const line = text.length > room
|
|
145
|
+
? text.slice(0, Math.max(0, room - 1)) + this.theme.glyph.ellipsis
|
|
146
|
+
: text;
|
|
147
|
+
this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${this.theme.muted(line)}`);
|
|
148
148
|
}
|
|
149
149
|
beginTurn() {
|
|
150
|
-
this.highlighter = createStreamHighlighter(this.
|
|
150
|
+
this.highlighter = createStreamHighlighter(this.theme);
|
|
151
151
|
this.print = this.newPrinter();
|
|
152
152
|
this.wroteInSegment = false;
|
|
153
153
|
}
|
|
@@ -169,13 +169,15 @@ export class TtyRenderer {
|
|
|
169
169
|
if (this.closed)
|
|
170
170
|
return;
|
|
171
171
|
const room = Math.max(1, this.caps.width);
|
|
172
|
-
const line = text.length > room
|
|
173
|
-
|
|
172
|
+
const line = text.length > room
|
|
173
|
+
? text.slice(0, room - 1) + this.theme.glyph.ellipsis
|
|
174
|
+
: text;
|
|
175
|
+
this.commit(this.theme.muted(line) + "\n");
|
|
174
176
|
}
|
|
175
177
|
preview(preview) {
|
|
176
178
|
if (this.closed)
|
|
177
179
|
return;
|
|
178
|
-
const block = renderActionPreview(preview, this.
|
|
180
|
+
const block = renderActionPreview(preview, this.theme);
|
|
179
181
|
if (block)
|
|
180
182
|
this.commit(block + "\n");
|
|
181
183
|
}
|
|
@@ -237,7 +239,8 @@ export class TtyRenderer {
|
|
|
237
239
|
// honest even with CRUXY_NO_SPINNER; shown only once it means something.
|
|
238
240
|
const elapsed = started === null ? 0 : Date.now() - started.at;
|
|
239
241
|
const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
|
|
240
|
-
|
|
242
|
+
const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
|
|
243
|
+
this.note(`${mark} ${event.label}${suffix}`);
|
|
241
244
|
}
|
|
242
245
|
promptResolved() {
|
|
243
246
|
if (this.closed)
|
package/dist/render/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import type { Theme } from "../theme/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* The streaming render seam (U.2): the agent loop talks to a
|
|
4
5
|
* {@link StreamRenderer}, never to raw stdout. Two implementations exist —
|
|
@@ -21,6 +22,9 @@ export interface RenderCapabilities {
|
|
|
21
22
|
cursor: boolean;
|
|
22
23
|
/** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
|
|
23
24
|
spinner: boolean;
|
|
25
|
+
/** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
|
|
26
|
+
* independent of `color`. Drives the theme's glyph table, not its stylers. */
|
|
27
|
+
unicode: boolean;
|
|
24
28
|
/** Terminal columns; 80 when unknown (non-TTY). */
|
|
25
29
|
width: number;
|
|
26
30
|
}
|
|
@@ -97,6 +101,9 @@ export type ToolLifecycleEvent = {
|
|
|
97
101
|
*/
|
|
98
102
|
export interface StreamRenderer {
|
|
99
103
|
readonly caps: RenderCapabilities;
|
|
104
|
+
/** The one resolved design system (U.1) — glyphs/roles for chrome a
|
|
105
|
+
* surface emits through this renderer (e.g. the subagent trail notes). */
|
|
106
|
+
readonly theme: Theme;
|
|
100
107
|
/** Start a user turn: reset leading-newline trim and code-fence state. */
|
|
101
108
|
beginTurn(): void;
|
|
102
109
|
/**
|
|
@@ -67,7 +67,9 @@ export class SubagentOrchestrator {
|
|
|
67
67
|
},
|
|
68
68
|
};
|
|
69
69
|
const label = taskLabel(spec.task);
|
|
70
|
-
deps.renderer
|
|
70
|
+
if (deps.renderer) {
|
|
71
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.play} subagent: ${label}`);
|
|
72
|
+
}
|
|
71
73
|
deps.renderer?.setPhase({ kind: "subagent", label });
|
|
72
74
|
// The isolation seam: a brand-new history seeded with ONLY the task. The
|
|
73
75
|
// parent's messages are never in scope here, and this array dies with the
|
|
@@ -96,7 +98,9 @@ export class SubagentOrchestrator {
|
|
|
96
98
|
deps.renderer?.setPhase(null);
|
|
97
99
|
throw err;
|
|
98
100
|
}
|
|
99
|
-
deps.renderer
|
|
101
|
+
if (deps.renderer) {
|
|
102
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.failure} subagent failed: ${label}`);
|
|
103
|
+
}
|
|
100
104
|
deps.renderer?.setPhase(null);
|
|
101
105
|
return {
|
|
102
106
|
status: "failed",
|
|
@@ -123,7 +127,9 @@ export class SubagentOrchestrator {
|
|
|
123
127
|
usage: run.usage,
|
|
124
128
|
};
|
|
125
129
|
if (run.stop === "completed") {
|
|
126
|
-
this.deps.renderer
|
|
130
|
+
const r = this.deps.renderer;
|
|
131
|
+
if (r)
|
|
132
|
+
r.note(`${r.theme.glyph.success} subagent done: ${label}`);
|
|
127
133
|
return { status: "done", ...base };
|
|
128
134
|
}
|
|
129
135
|
// Both cap paths are the same outcome for the parent: a truncated, partial
|
|
@@ -132,7 +138,9 @@ export class SubagentOrchestrator {
|
|
|
132
138
|
const reason = run.stop === "budget"
|
|
133
139
|
? (run.stopReason ?? "budget cap reached")
|
|
134
140
|
: `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
|
|
135
|
-
this.deps.renderer
|
|
141
|
+
const r = this.deps.renderer;
|
|
142
|
+
if (r)
|
|
143
|
+
r.note(`${r.theme.glyph.failure} subagent stopped (budget): ${label}`);
|
|
136
144
|
return {
|
|
137
145
|
status: "budget-exceeded",
|
|
138
146
|
...base,
|
|
@@ -195,12 +203,16 @@ function lastAssistantText(messages) {
|
|
|
195
203
|
*/
|
|
196
204
|
class SubagentRenderer {
|
|
197
205
|
caps;
|
|
206
|
+
theme;
|
|
198
207
|
inner;
|
|
199
208
|
label;
|
|
209
|
+
prefix;
|
|
200
210
|
constructor(inner, label) {
|
|
201
211
|
this.inner = inner;
|
|
202
212
|
this.label = label;
|
|
203
213
|
this.caps = inner.caps;
|
|
214
|
+
this.theme = inner.theme;
|
|
215
|
+
this.prefix = `subagent ${inner.theme.glyph.sep} `;
|
|
204
216
|
}
|
|
205
217
|
/** Turn framing belongs to the parent's turn — the child's is dropped. */
|
|
206
218
|
beginTurn() { }
|
|
@@ -209,7 +221,7 @@ class SubagentRenderer {
|
|
|
209
221
|
write() { }
|
|
210
222
|
endSegment() { }
|
|
211
223
|
note(text) {
|
|
212
|
-
this.inner.note(
|
|
224
|
+
this.inner.note(`${this.prefix}${text}`);
|
|
213
225
|
}
|
|
214
226
|
preview(preview) {
|
|
215
227
|
this.inner.preview(preview);
|
|
@@ -231,7 +243,10 @@ class SubagentRenderer {
|
|
|
231
243
|
/** The plan executor owns the progress register (C.31) — never the child. */
|
|
232
244
|
progress() { }
|
|
233
245
|
toolLifecycle(event) {
|
|
234
|
-
this.inner.toolLifecycle({
|
|
246
|
+
this.inner.toolLifecycle({
|
|
247
|
+
...event,
|
|
248
|
+
label: `${this.prefix}${event.label}`,
|
|
249
|
+
});
|
|
235
250
|
}
|
|
236
251
|
promptResolved() {
|
|
237
252
|
this.inner.promptResolved();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the project's test command (C.13). Order: explicit `test.command`
|
|
5
|
+
* config, then package.json `scripts.test`. Returns `null` when neither
|
|
6
|
+
* yields one — cruxy NEVER invents a test command; the caller surfaces the
|
|
7
|
+
* coded not-configured error instead.
|
|
8
|
+
*/
|
|
9
|
+
/** npm's scaffold placeholder is an error message, not a test suite. */
|
|
10
|
+
const NPM_PLACEHOLDER = /no test specified/i;
|
|
11
|
+
export function detectTestCommand(cwd, config) {
|
|
12
|
+
if (config.test.command) {
|
|
13
|
+
return { command: config.test.command, source: "config" };
|
|
14
|
+
}
|
|
15
|
+
const script = readTestScript(cwd);
|
|
16
|
+
if (script === null)
|
|
17
|
+
return null;
|
|
18
|
+
return { command: `${packageManager(cwd)} test`, source: "package-json" };
|
|
19
|
+
}
|
|
20
|
+
/** The package.json `scripts.test` value, or null if absent/placeholder/unreadable. */
|
|
21
|
+
function readTestScript(cwd) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = readFileSync(path.join(cwd, "package.json"), "utf8");
|
|
24
|
+
const pkg = JSON.parse(raw);
|
|
25
|
+
const script = pkg.scripts?.test;
|
|
26
|
+
if (typeof script !== "string" || script.trim() === "")
|
|
27
|
+
return null;
|
|
28
|
+
if (NPM_PLACEHOLDER.test(script))
|
|
29
|
+
return null;
|
|
30
|
+
return script;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// No package.json / unparseable → not detected (never a crash).
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Pick the package manager by lockfile; npm when nothing identifies one. */
|
|
38
|
+
function packageManager(cwd) {
|
|
39
|
+
if (existsSync(path.join(cwd, "pnpm-lock.yaml")))
|
|
40
|
+
return "pnpm";
|
|
41
|
+
if (existsSync(path.join(cwd, "yarn.lock")))
|
|
42
|
+
return "yarn";
|
|
43
|
+
return "npm";
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FailureParser, TestFailure } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort failure extraction (C.13). Two conservative parsers ship —
|
|
4
|
+
* vitest-style and jest-style — behind the pluggable {@link FailureParser}
|
|
5
|
+
* seam. The contract: extract only what a pattern positively recognizes;
|
|
6
|
+
* when nothing matches, return NOTHING (the caller falls back to the raw
|
|
7
|
+
* tail). Parsers never decide pass/fail and never invent counts — `message`
|
|
8
|
+
* fields are verbatim runner output, not summaries we authored.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Vitest: per-test failure lines (`FAIL src/x.test.ts > suite > name`, also
|
|
12
|
+
* `×`/`✗` markers) and the `Tests 2 failed | 570 passed (572)` summary.
|
|
13
|
+
* Plain file-level `FAIL <file>` lines are deliberately left to the jest
|
|
14
|
+
* parser, which owns the file+bullet association.
|
|
15
|
+
*/
|
|
16
|
+
export declare const parseVitest: FailureParser;
|
|
17
|
+
/**
|
|
18
|
+
* Jest: `FAIL <file>` headers with `● <name>` bullets underneath (the bullet's
|
|
19
|
+
* following indented lines are its message, verbatim), and the
|
|
20
|
+
* `Tests: …, N total` summary. A FAIL header with no bullets (e.g. a suite
|
|
21
|
+
* that failed to load) becomes one file-level failure.
|
|
22
|
+
*/
|
|
23
|
+
export declare const parseJest: FailureParser;
|
|
24
|
+
/** Parser order: most-specific first. The pluggable seam for new frameworks. */
|
|
25
|
+
export declare const defaultParsers: readonly FailureParser[];
|
|
26
|
+
/**
|
|
27
|
+
* Run the parser chain; the first parser that recognizes anything wins.
|
|
28
|
+
* Nothing recognized → empty failures, no total — the raw tail is the result.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseFailures(output: string): {
|
|
31
|
+
failures: TestFailure[];
|
|
32
|
+
total?: number;
|
|
33
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort failure extraction (C.13). Two conservative parsers ship —
|
|
3
|
+
* vitest-style and jest-style — behind the pluggable {@link FailureParser}
|
|
4
|
+
* seam. The contract: extract only what a pattern positively recognizes;
|
|
5
|
+
* when nothing matches, return NOTHING (the caller falls back to the raw
|
|
6
|
+
* tail). Parsers never decide pass/fail and never invent counts — `message`
|
|
7
|
+
* fields are verbatim runner output, not summaries we authored.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Vitest: per-test failure lines (`FAIL src/x.test.ts > suite > name`, also
|
|
11
|
+
* `×`/`✗` markers) and the `Tests 2 failed | 570 passed (572)` summary.
|
|
12
|
+
* Plain file-level `FAIL <file>` lines are deliberately left to the jest
|
|
13
|
+
* parser, which owns the file+bullet association.
|
|
14
|
+
*/
|
|
15
|
+
export const parseVitest = (output) => {
|
|
16
|
+
const failures = [];
|
|
17
|
+
for (const line of output.split("\n")) {
|
|
18
|
+
const match = /^\s*(?:FAIL|✗|×)\s+(\S+)\s+>\s+(.+?)\s*$/.exec(line);
|
|
19
|
+
if (match) {
|
|
20
|
+
failures.push({
|
|
21
|
+
name: match[2],
|
|
22
|
+
message: line.trim(),
|
|
23
|
+
file: match[1],
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
attachLines(failures, output);
|
|
28
|
+
const total = /^\s*Tests\s+.*\((\d+)\)\s*$/m.exec(output);
|
|
29
|
+
return {
|
|
30
|
+
failures,
|
|
31
|
+
...(total ? { total: Number(total[1]) } : {}),
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Jest: `FAIL <file>` headers with `● <name>` bullets underneath (the bullet's
|
|
36
|
+
* following indented lines are its message, verbatim), and the
|
|
37
|
+
* `Tests: …, N total` summary. A FAIL header with no bullets (e.g. a suite
|
|
38
|
+
* that failed to load) becomes one file-level failure.
|
|
39
|
+
*/
|
|
40
|
+
export const parseJest = (output) => {
|
|
41
|
+
const failures = [];
|
|
42
|
+
const lines = output.split("\n");
|
|
43
|
+
let currentFile;
|
|
44
|
+
const filesWithBullets = new Set();
|
|
45
|
+
const bareFiles = [];
|
|
46
|
+
for (let i = 0; i < lines.length; i++) {
|
|
47
|
+
const fail = /^\s*FAIL\s+(\S+)\s*$/.exec(lines[i]);
|
|
48
|
+
if (fail) {
|
|
49
|
+
currentFile = fail[1];
|
|
50
|
+
bareFiles.push(fail[1]);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const bullet = /^\s*●\s+(.+?)\s*$/.exec(lines[i]);
|
|
54
|
+
if (bullet) {
|
|
55
|
+
failures.push({
|
|
56
|
+
name: bullet[1],
|
|
57
|
+
message: bulletMessage(lines, i),
|
|
58
|
+
...(currentFile ? { file: currentFile } : {}),
|
|
59
|
+
});
|
|
60
|
+
if (currentFile)
|
|
61
|
+
filesWithBullets.add(currentFile);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// A failed suite with no per-test bullets is still one honest failure.
|
|
65
|
+
for (const file of bareFiles) {
|
|
66
|
+
if (!filesWithBullets.has(file)) {
|
|
67
|
+
failures.push({ name: file, message: `FAIL ${file}`, file });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
attachLines(failures, output);
|
|
71
|
+
const total = /^Tests:.*?(\d+)\s+total\s*$/m.exec(output);
|
|
72
|
+
return {
|
|
73
|
+
failures,
|
|
74
|
+
...(total ? { total: Number(total[1]) } : {}),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Up to three non-blank lines under a jest bullet — its verbatim message.
|
|
79
|
+
* Jest separates the bullet from its detail with a blank line, so leading
|
|
80
|
+
* blanks are skipped; collection stops at the next blank, the next bullet,
|
|
81
|
+
* or the cap.
|
|
82
|
+
*/
|
|
83
|
+
function bulletMessage(lines, bulletIndex) {
|
|
84
|
+
const body = [];
|
|
85
|
+
for (let j = bulletIndex + 1; j < lines.length && body.length < 3; j++) {
|
|
86
|
+
const text = lines[j].trim();
|
|
87
|
+
if (text === "") {
|
|
88
|
+
if (body.length === 0)
|
|
89
|
+
continue; // the separator blank under the bullet
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (/^\s*●\s+/.test(lines[j]))
|
|
93
|
+
break;
|
|
94
|
+
body.push(text);
|
|
95
|
+
}
|
|
96
|
+
return body.length > 0 ? body.join("\n") : lines[bulletIndex].trim();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Attach `line` to failures whose file appears in a `file:line:col` stack
|
|
100
|
+
* reference anywhere in the output (vitest `❯ file:39:5`, jest
|
|
101
|
+
* `at … (file:12:15)`). First reference per file wins; no reference → no line.
|
|
102
|
+
*/
|
|
103
|
+
function attachLines(failures, output) {
|
|
104
|
+
if (failures.length === 0)
|
|
105
|
+
return;
|
|
106
|
+
const firstLineFor = new Map();
|
|
107
|
+
for (const match of output.matchAll(/([^\s():]+):(\d+):\d+/g)) {
|
|
108
|
+
if (!firstLineFor.has(match[1])) {
|
|
109
|
+
firstLineFor.set(match[1], Number(match[2]));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const failure of failures) {
|
|
113
|
+
if (failure.file === undefined || failure.line !== undefined)
|
|
114
|
+
continue;
|
|
115
|
+
const line = firstLineFor.get(failure.file);
|
|
116
|
+
if (line !== undefined)
|
|
117
|
+
failure.line = line;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Parser order: most-specific first. The pluggable seam for new frameworks. */
|
|
121
|
+
export const defaultParsers = [
|
|
122
|
+
parseVitest,
|
|
123
|
+
parseJest,
|
|
124
|
+
];
|
|
125
|
+
/**
|
|
126
|
+
* Run the parser chain; the first parser that recognizes anything wins.
|
|
127
|
+
* Nothing recognized → empty failures, no total — the raw tail is the result.
|
|
128
|
+
*/
|
|
129
|
+
export function parseFailures(output) {
|
|
130
|
+
for (const parser of defaultParsers) {
|
|
131
|
+
const result = parser(output);
|
|
132
|
+
if (result.failures.length > 0 || result.total !== undefined) {
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { failures: [] };
|
|
137
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Tool, ToolContext } from "../tools/types.js";
|
|
3
|
+
import type { TestCommand, TestRunner } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `run_tests` tool (C.13): execute the project's test suite and return a
|
|
6
|
+
* structured result the model can iterate on (edit → re-run → repeat). The
|
|
7
|
+
* iteration loop itself is the ordinary agent loop; this module contributes
|
|
8
|
+
* the two guarantees that make it trustworthy — honest green (exit-code-only)
|
|
9
|
+
* and a hard cap on consecutive failing runs.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Counts consecutive FAILING test executions; a green run resets it. When the
|
|
13
|
+
* count reaches the cap, the next attempt is refused with the coded
|
|
14
|
+
* CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes), and the counter
|
|
15
|
+
* resets — so the episode ends loudly, but a deliberate later attempt (a new
|
|
16
|
+
* user instruction) starts fresh rather than finding a permanently dead tool.
|
|
17
|
+
* Per-turn work stays bounded regardless via `agent.maxIterations`.
|
|
18
|
+
*/
|
|
19
|
+
export declare class TestIterationBudget {
|
|
20
|
+
private failedRuns;
|
|
21
|
+
/** Runs already spent in the current failing streak. */
|
|
22
|
+
get spent(): number;
|
|
23
|
+
/** True when the next run must be refused; resets the streak as it trips. */
|
|
24
|
+
trip(maxIterations: number): boolean;
|
|
25
|
+
record(passed: boolean): void;
|
|
26
|
+
}
|
|
27
|
+
declare const parameters: z.ZodObject<{
|
|
28
|
+
command: z.ZodOptional<z.ZodString>;
|
|
29
|
+
}, "strip", z.ZodTypeAny, {
|
|
30
|
+
command?: string | undefined;
|
|
31
|
+
}, {
|
|
32
|
+
command?: string | undefined;
|
|
33
|
+
}>;
|
|
34
|
+
export interface RunTestsToolDeps {
|
|
35
|
+
/** Execution seam (tests inject a fake; default spawns the real command). */
|
|
36
|
+
runner?: TestRunner;
|
|
37
|
+
/** Detection seam (defaults to config + package.json detection). */
|
|
38
|
+
detect?: (ctx: ToolContext) => TestCommand | null;
|
|
39
|
+
}
|
|
40
|
+
/** Build the `run_tests` tool. One instance = one session's iteration budget. */
|
|
41
|
+
export declare function makeRunTestsTool(deps?: RunTestsToolDeps): Tool<typeof parameters>;
|
|
42
|
+
export {};
|
|
@@ -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
|
+
}
|