@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.
Files changed (48) hide show
  1. package/dist/approval/classify.js +21 -0
  2. package/dist/approval/policy.js +6 -0
  3. package/dist/approval/prompt.js +8 -29
  4. package/dist/approval/types.d.ts +5 -0
  5. package/dist/cli/commands/rollback.js +45 -0
  6. package/dist/cli/commands/test.d.ts +9 -0
  7. package/dist/cli/commands/test.js +47 -0
  8. package/dist/cli/program.js +2 -0
  9. package/dist/cli/repl.d.ts +5 -0
  10. package/dist/cli/repl.js +17 -0
  11. package/dist/cli/session-factory.js +6 -2
  12. package/dist/components/autocomplete.d.ts +32 -0
  13. package/dist/components/autocomplete.js +50 -0
  14. package/dist/components/frame.d.ts +25 -0
  15. package/dist/components/frame.js +49 -0
  16. package/dist/components/fuzzy.d.ts +61 -0
  17. package/dist/components/fuzzy.js +174 -0
  18. package/dist/components/index.d.ts +6 -0
  19. package/dist/components/index.js +6 -0
  20. package/dist/components/input.d.ts +78 -0
  21. package/dist/components/input.js +111 -0
  22. package/dist/components/keys.d.ts +48 -0
  23. package/dist/components/keys.js +105 -0
  24. package/dist/components/select.d.ts +28 -0
  25. package/dist/components/select.js +69 -0
  26. package/dist/config/schema.d.ts +47 -0
  27. package/dist/config/schema.js +20 -0
  28. package/dist/errors/constructors.d.ts +12 -0
  29. package/dist/errors/constructors.js +31 -0
  30. package/dist/errors/types.d.ts +4 -0
  31. package/dist/errors/types.js +10 -0
  32. package/dist/onboarding/io.d.ts +3 -2
  33. package/dist/onboarding/io.js +35 -81
  34. package/dist/testing/detect.d.ts +3 -0
  35. package/dist/testing/detect.js +44 -0
  36. package/dist/testing/index.d.ts +5 -0
  37. package/dist/testing/index.js +5 -0
  38. package/dist/testing/parse.d.ts +33 -0
  39. package/dist/testing/parse.js +137 -0
  40. package/dist/testing/run-tests-tool.d.ts +42 -0
  41. package/dist/testing/run-tests-tool.js +128 -0
  42. package/dist/testing/runner.d.ts +26 -0
  43. package/dist/testing/runner.js +124 -0
  44. package/dist/testing/types.d.ts +61 -0
  45. package/dist/testing/types.js +7 -0
  46. package/dist/tools/registry.js +3 -0
  47. package/dist/tools/types.d.ts +2 -2
  48. package/package.json +1 -1
@@ -219,6 +219,27 @@ export declare const CheckpointConfigSchema: z.ZodObject<{
219
219
  enabled?: boolean | undefined;
220
220
  retention?: number | undefined;
221
221
  }>;
222
+ /**
223
+ * Test-execution loop (C.13): how the agent runs the project's test suite and
224
+ * iterates on failures. The command is detected from package.json when unset;
225
+ * cruxy never invents one.
226
+ */
227
+ export declare const TestConfigSchema: z.ZodObject<{
228
+ /** Explicit test command (overrides package.json detection). */
229
+ command: z.ZodOptional<z.ZodString>;
230
+ /** Consecutive failing runs before the edit→re-run loop trips its cap. */
231
+ maxIterations: z.ZodDefault<z.ZodNumber>;
232
+ /** Cap on captured test output bytes (tail-biased — failures come last). */
233
+ captureBytes: z.ZodDefault<z.ZodNumber>;
234
+ }, "strict", z.ZodTypeAny, {
235
+ maxIterations: number;
236
+ captureBytes: number;
237
+ command?: string | undefined;
238
+ }, {
239
+ maxIterations?: number | undefined;
240
+ command?: string | undefined;
241
+ captureBytes?: number | undefined;
242
+ }>;
222
243
  /**
223
244
  * Subagent orchestration (C.14): scoped child agents the main agent can spawn
224
245
  * for bounded subtasks. Every cap here is a hard bound — a subagent can narrow
@@ -514,6 +535,22 @@ export declare const CruxyConfigSchema: z.ZodObject<{
514
535
  timeoutMs?: number | undefined;
515
536
  } | undefined;
516
537
  }>>;
538
+ test: z.ZodDefault<z.ZodObject<{
539
+ /** Explicit test command (overrides package.json detection). */
540
+ command: z.ZodOptional<z.ZodString>;
541
+ /** Consecutive failing runs before the edit→re-run loop trips its cap. */
542
+ maxIterations: z.ZodDefault<z.ZodNumber>;
543
+ /** Cap on captured test output bytes (tail-biased — failures come last). */
544
+ captureBytes: z.ZodDefault<z.ZodNumber>;
545
+ }, "strict", z.ZodTypeAny, {
546
+ maxIterations: number;
547
+ captureBytes: number;
548
+ command?: string | undefined;
549
+ }, {
550
+ maxIterations?: number | undefined;
551
+ command?: string | undefined;
552
+ captureBytes?: number | undefined;
553
+ }>>;
517
554
  mcpServers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
518
555
  command: z.ZodOptional<z.ZodString>;
519
556
  args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -592,6 +629,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
592
629
  timeoutMs?: number | undefined;
593
630
  };
594
631
  };
632
+ test: {
633
+ maxIterations: number;
634
+ captureBytes: number;
635
+ command?: string | undefined;
636
+ };
595
637
  mcpServers: Record<string, {
596
638
  command?: string | undefined;
597
639
  args?: string[] | undefined;
@@ -662,6 +704,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
662
704
  timeoutMs?: number | undefined;
663
705
  } | undefined;
664
706
  } | undefined;
707
+ test?: {
708
+ maxIterations?: number | undefined;
709
+ command?: string | undefined;
710
+ captureBytes?: number | undefined;
711
+ } | undefined;
665
712
  mcpServers?: Record<string, {
666
713
  command?: string | undefined;
667
714
  args?: string[] | undefined;
@@ -149,6 +149,25 @@ export const CheckpointConfigSchema = z
149
149
  retention: z.number().int().positive().default(10),
150
150
  })
151
151
  .strict();
152
+ /**
153
+ * Test-execution loop (C.13): how the agent runs the project's test suite and
154
+ * iterates on failures. The command is detected from package.json when unset;
155
+ * cruxy never invents one.
156
+ */
157
+ export const TestConfigSchema = z
158
+ .object({
159
+ /** Explicit test command (overrides package.json detection). */
160
+ command: z.string().min(1).optional(),
161
+ /** Consecutive failing runs before the edit→re-run loop trips its cap. */
162
+ maxIterations: z.number().int().positive().default(4),
163
+ /** Cap on captured test output bytes (tail-biased — failures come last). */
164
+ captureBytes: z
165
+ .number()
166
+ .int()
167
+ .positive()
168
+ .default(64 * 1024),
169
+ })
170
+ .strict();
152
171
  /**
153
172
  * Subagent orchestration (C.14): scoped child agents the main agent can spawn
154
173
  * for bounded subtasks. Every cap here is a hard bound — a subagent can narrow
@@ -196,6 +215,7 @@ export const CruxyConfigSchema = z
196
215
  index: IndexConfigSchema.default({}),
197
216
  checkpoint: CheckpointConfigSchema.default({}),
198
217
  subagent: SubagentConfigSchema.default({}),
218
+ test: TestConfigSchema.default({}),
199
219
  mcpServers: z.record(z.string(), McpServerSchema).default({}),
200
220
  logLevel: z.enum(LOG_LEVELS).default("info"),
201
221
  })
@@ -2,6 +2,13 @@ import { CruxyError } from "./types.js";
2
2
  /** Best-effort human message for an arbitrary thrown value. */
3
3
  export declare function messageOf(underlying: unknown): string | undefined;
4
4
  export declare function usageError(title: string, nextSteps?: string[]): CruxyError;
5
+ /**
6
+ * An interactive component (picker, fuzzy finder) was needed but stdin is not
7
+ * an interactive terminal and the caller supplied no default (U.7). Never
8
+ * silently picks an option, never blocks on a pipe — same discipline as the
9
+ * approval/onboarding layers.
10
+ */
11
+ export declare function interactiveRequired(what: string, alternatives?: string[]): CruxyError;
5
12
  export declare function configKeyUnknown(key: string): CruxyError;
6
13
  export declare function providerUnsupported(provider: string): CruxyError;
7
14
  export declare function configParse(path: string, underlying?: unknown): CruxyError;
@@ -78,6 +85,11 @@ export declare function subagentDepthExceeded(depth: number, maxDepth: number):
78
85
  * reasons over; thrown only when the orchestrator itself cannot proceed.
79
86
  */
80
87
  export declare function subagentFailed(underlying?: unknown): CruxyError;
88
+ /**
89
+ * No test command could be detected and none is configured (C.13). cruxy never
90
+ * invents a test command — the fix is always to declare one.
91
+ */
92
+ export declare function testCommandNotFound(): CruxyError;
81
93
  export declare function internal(underlying?: unknown): CruxyError;
82
94
  /**
83
95
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
@@ -23,6 +23,21 @@ export function usageError(title, nextSteps) {
23
23
  nextSteps: nextSteps ?? ["run `cruxy --help` for usage"],
24
24
  });
25
25
  }
26
+ /**
27
+ * An interactive component (picker, fuzzy finder) was needed but stdin is not
28
+ * an interactive terminal and the caller supplied no default (U.7). Never
29
+ * silently picks an option, never blocks on a pipe — same discipline as the
30
+ * approval/onboarding layers.
31
+ */
32
+ export function interactiveRequired(what, alternatives = []) {
33
+ return new CruxyError({
34
+ code: ErrorCode.InteractiveRequired,
35
+ title: `${what} needs an interactive terminal`,
36
+ cause: "stdin is not a TTY (or the terminal cannot render an interactive picker)",
37
+ nextSteps: ["run cruxy in an interactive terminal", ...alternatives],
38
+ meta: { what },
39
+ });
40
+ }
26
41
  export function configKeyUnknown(key) {
27
42
  return new CruxyError({
28
43
  code: ErrorCode.ConfigKeyUnknown,
@@ -421,6 +436,22 @@ export function subagentFailed(underlying) {
421
436
  underlying,
422
437
  });
423
438
  }
439
+ // ── testing (exit 2) ──────────────────────────────────────────────────────────
440
+ /**
441
+ * No test command could be detected and none is configured (C.13). cruxy never
442
+ * invents a test command — the fix is always to declare one.
443
+ */
444
+ export function testCommandNotFound() {
445
+ return new CruxyError({
446
+ code: ErrorCode.TestCommandNotFound,
447
+ title: "no test command found for this project",
448
+ cause: "package.json has no usable `scripts.test` and `test.command` is not configured",
449
+ nextSteps: [
450
+ 'set `test.command` in your cruxy config (e.g. `cruxy config set test.command "pnpm test"`)',
451
+ "or add a `test` script to package.json",
452
+ ],
453
+ });
454
+ }
424
455
  // ── internal (exit 1) ─────────────────────────────────────────────────────────
425
456
  export function internal(underlying) {
426
457
  return new CruxyError({
@@ -14,6 +14,7 @@
14
14
  export declare const ErrorCode: {
15
15
  readonly Internal: "CRUXY_E_INTERNAL";
16
16
  readonly Usage: "CRUXY_E_USAGE";
17
+ readonly InteractiveRequired: "CRUXY_E_INTERACTIVE_REQUIRED";
17
18
  readonly ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN";
18
19
  readonly ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED";
19
20
  readonly GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
@@ -48,6 +49,9 @@ export declare const ErrorCode: {
48
49
  /** Carried inside a SubagentResult (informational) — never fatal by itself. */
49
50
  readonly SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET";
50
51
  readonly SubagentFailed: "CRUXY_E_SUBAGENT_FAILED";
52
+ readonly TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND";
53
+ /** Carried inside a run_tests result (informational) — never fatal by itself. */
54
+ readonly TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT";
51
55
  };
52
56
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
53
57
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -16,6 +16,7 @@ export const ErrorCode = {
16
16
  Internal: "CRUXY_E_INTERNAL",
17
17
  // usage (exit 2)
18
18
  Usage: "CRUXY_E_USAGE",
19
+ InteractiveRequired: "CRUXY_E_INTERACTIVE_REQUIRED",
19
20
  ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN",
20
21
  ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED",
21
22
  GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
@@ -59,6 +60,10 @@ export const ErrorCode = {
59
60
  /** Carried inside a SubagentResult (informational) — never fatal by itself. */
60
61
  SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET",
61
62
  SubagentFailed: "CRUXY_E_SUBAGENT_FAILED",
63
+ // testing (exit 2 / 11)
64
+ TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND",
65
+ /** Carried inside a run_tests result (informational) — never fatal by itself. */
66
+ TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT",
62
67
  };
63
68
  /**
64
69
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -67,6 +72,7 @@ export const ErrorCode = {
67
72
  const EXIT_CODES = {
68
73
  [ErrorCode.Internal]: 1,
69
74
  [ErrorCode.Usage]: 2,
75
+ [ErrorCode.InteractiveRequired]: 2,
70
76
  [ErrorCode.ConfigKeyUnknown]: 2,
71
77
  [ErrorCode.ProviderUnsupported]: 2,
72
78
  [ErrorCode.GitProtectedBranch]: 2,
@@ -102,6 +108,10 @@ const EXIT_CODES = {
102
108
  [ErrorCode.SubagentDepthExceeded]: 2,
103
109
  [ErrorCode.SubagentBudget]: 11,
104
110
  [ErrorCode.SubagentFailed]: 11,
111
+ // No test command is a configuration gap (usage); the iteration limit
112
+ // surfaces inside a run_tests result and is never fatal by itself.
113
+ [ErrorCode.TestCommandNotFound]: 2,
114
+ [ErrorCode.TestIterationLimit]: 11,
105
115
  };
106
116
  /** The process exit code for an error code (defaults to 1 for safety). */
107
117
  export function exitCodeFor(code) {
@@ -2,7 +2,8 @@ import type { OnboardingIO } from "./types.js";
2
2
  /**
3
3
  * The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
4
4
  * stays clean for piping); the secret reader echoes `*` per keystroke and never
5
- * the real character. All readers restore cooked mode on the way out, even on
6
- * Ctrl-C / EOF — the terminal is never left in raw mode.
5
+ * the real character. Raw-mode key handling is the shared U.7 reader
6
+ * (`components/input.ts`) — the single owner of `setRawMode` so cooked mode
7
+ * is restored on every exit path, Ctrl-C / EOF included.
7
8
  */
8
9
  export declare function defaultOnboardingIO(color?: boolean): OnboardingIO;
@@ -1,54 +1,21 @@
1
+ import { createKeyReader, readSingleKey } from "../components/input.js";
1
2
  import { shouldUseColor } from "../errors/index.js";
2
3
  /**
3
4
  * The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
4
5
  * stays clean for piping); the secret reader echoes `*` per keystroke and never
5
- * the real character. All readers restore cooked mode on the way out, even on
6
- * Ctrl-C / EOF — the terminal is never left in raw mode.
6
+ * the real character. Raw-mode key handling is the shared U.7 reader
7
+ * (`components/input.ts`) — the single owner of `setRawMode` so cooked mode
8
+ * is restored on every exit path, Ctrl-C / EOF included.
7
9
  */
8
10
  export function defaultOnboardingIO(color = shouldUseColor()) {
9
11
  return {
10
12
  write: (text) => void process.stderr.write(text),
11
13
  readLine: readLineFromStdin,
12
- readKey: readKeyFromStdin,
14
+ readKey: () => readSingleKey(),
13
15
  readSecret: readSecretFromStdin,
14
16
  color,
15
17
  };
16
18
  }
17
- const CTRL_C = 0x03;
18
- const CTRL_D = 0x04;
19
- const BACKSPACE = 0x08;
20
- const DELETE = 0x7f;
21
- const LF = 0x0a;
22
- const CR = 0x0d;
23
- /** Read one keypress in raw mode; "" on EOF / Ctrl-C / Ctrl-D. Restores cooked mode. */
24
- function readKeyFromStdin() {
25
- const stdin = process.stdin;
26
- return new Promise((resolve) => {
27
- const cleanup = () => {
28
- stdin.removeListener("data", onData);
29
- stdin.removeListener("end", onEnd);
30
- if (stdin.isTTY)
31
- stdin.setRawMode(false);
32
- stdin.pause();
33
- };
34
- const onData = (buf) => {
35
- cleanup();
36
- const code = buf[0];
37
- resolve(code === CTRL_C || code === CTRL_D
38
- ? ""
39
- : buf.toString("utf8").slice(0, 1));
40
- };
41
- const onEnd = () => {
42
- cleanup();
43
- resolve("");
44
- };
45
- if (stdin.isTTY)
46
- stdin.setRawMode(true);
47
- stdin.resume();
48
- stdin.once("data", onData);
49
- stdin.once("end", onEnd);
50
- });
51
- }
52
19
  /** Read one line in cooked mode; "" on EOF. */
53
20
  function readLineFromStdin() {
54
21
  const stdin = process.stdin;
@@ -80,54 +47,41 @@ function readLineFromStdin() {
80
47
  }
81
48
  /**
82
49
  * Read a secret with no echo: each printable keystroke shows a `*`, backspace
83
- * erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The real
84
- * characters are never written anywhere.
50
+ * erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The
51
+ * real characters are never written anywhere. Built on the shared key reader,
52
+ * which also keeps arrow/escape sequences from leaking into the secret.
85
53
  */
86
- function readSecretFromStdin() {
87
- const stdin = process.stdin;
54
+ async function readSecretFromStdin() {
88
55
  const out = process.stderr;
89
- return new Promise((resolve) => {
90
- let buf = "";
91
- let done = false;
92
- const cleanup = () => {
93
- stdin.removeListener("data", onData);
94
- stdin.removeListener("end", onEnd);
95
- if (stdin.isTTY)
96
- stdin.setRawMode(false);
97
- stdin.pause();
98
- };
99
- const finish = (value) => {
100
- if (done)
101
- return;
102
- done = true;
103
- cleanup();
104
- out.write("\n");
105
- resolve(value);
106
- };
107
- const onData = (chunk) => {
108
- for (const byte of chunk) {
109
- if (byte === CR || byte === LF)
110
- return finish(buf); // Enter → submit
111
- if (byte === CTRL_C || byte === CTRL_D)
112
- return finish(""); // abort
113
- if (byte === DELETE || byte === BACKSPACE) {
56
+ const keys = createKeyReader(process.stdin);
57
+ keys.begin();
58
+ let buf = "";
59
+ try {
60
+ for (;;) {
61
+ const key = await keys.read();
62
+ switch (key.kind) {
63
+ case "enter":
64
+ return buf;
65
+ case "ctrl-c":
66
+ case "eof":
67
+ return "";
68
+ case "backspace":
114
69
  if (buf.length > 0) {
115
70
  buf = buf.slice(0, -1);
116
71
  out.write("\b \b"); // erase one star
117
72
  }
118
- continue;
119
- }
120
- if (byte < 0x20)
121
- continue; // ignore other control chars
122
- buf += String.fromCharCode(byte);
123
- out.write("*");
73
+ break;
74
+ case "char":
75
+ buf += key.char;
76
+ out.write("*");
77
+ break;
78
+ default:
79
+ break; // arrows / tab / escape: ignored, never echoed
124
80
  }
125
- };
126
- const onEnd = () => finish("");
127
- if (stdin.isTTY)
128
- stdin.setRawMode(true);
129
- stdin.resume();
130
- stdin.on("data", onData);
131
- stdin.once("end", onEnd);
132
- });
81
+ }
82
+ }
83
+ finally {
84
+ keys.restore();
85
+ out.write("\n");
86
+ }
133
87
  }
@@ -0,0 +1,3 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { TestCommand } from "./types.js";
3
+ export declare function detectTestCommand(cwd: string, config: CruxyConfig): TestCommand | null;
@@ -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,5 @@
1
+ export * from "./types.js";
2
+ export * from "./detect.js";
3
+ export * from "./runner.js";
4
+ export * from "./parse.js";
5
+ export * from "./run-tests-tool.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./detect.js";
3
+ export * from "./runner.js";
4
+ export * from "./parse.js";
5
+ export * from "./run-tests-tool.js";
@@ -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 {};