jeo-code 0.12.26 → 0.12.28

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.
@@ -82,57 +82,308 @@ export const GUARD_LIMITS = Object.freeze({
82
82
  /**
83
83
  * Recognized standalone verification commands. Matching the full command prevents
84
84
  * filenames, prose, and shell output from impersonating verification evidence.
85
+ *
86
+ * Two tables share one argument grammar: TEST_CORES run tests and print a pass summary, so output can
87
+ * corroborate them; SILENT_CORES (type checkers, linters, formatters in check mode, builds) report only through
88
+ * their exit status and print little or nothing on success.
89
+ */
90
+ // Argument tail: plain tokens or balanced quoted strings (`pytest -k "foo and bar"`); `$`, backticks and
91
+ // backslashes never appear inside it, so nothing in a quoted argument can expand or escape.
92
+ const ARG = String.raw`(?:\s+(?:[A-Za-z0-9_./:@+*?,=-]+|"[^"$\x60\\\r\n]*"|'[^'$\x60\\\r\n]*'))*`;
93
+ const PM_VALUE = String.raw`[A-Za-z0-9_./:@+*?,~^-]+`;
94
+ // Workspace / directory selection that may sit between a package manager and its script.
95
+ const PM_OPT = String.raw`(?:\s+(?:-r|--recursive|--workspaces|--silent|-s|--quiet|-q)|\s+(?:--filter|-F|--prefix|--cwd|-C|--workspace|-w|--dir)(?:=|\s+)${PM_VALUE})*`;
96
+ const PM = String.raw`(?:(?:bun|npm|pnpm|yarn)${PM_OPT}|yarn\s+workspace\s+[A-Za-z0-9_./@-]+)(?:\s+(?:run|exec|x|dlx))?${PM_OPT}`;
97
+ // make/just/rake/gradle/mvn take TARGETS (further commands), not script arguments: `make ci deploy` would run
98
+ // `deploy`. Their tails are therefore a closed grammar: any flag (judged by TASK_DENY below) together with the value a
99
+ // value-taking flag consumes, NAME=VALUE, and allow-listed targets. Any other bare token rejects the command.
100
+ const TARGETS = ["test", "tests", "check", "ci", "verify", "lint", "typecheck", "type-check", "vet", "clean", "build", "compile", "all", "unit", "integration", "e2e", "coverage", "format-check", "spec"];
101
+ const BUILD_TEST_TARGETS = ["test", "tests", "ci", "verify", "spec", "unit", "integration", "e2e", "coverage"];
102
+ const BUILD_SILENT_TARGETS = ["check", "lint", "typecheck", "type-check", "vet", "build", "compile", "format-check"];
103
+ // NAME=VALUE: never an update/snapshot/skip switch and never one of make's program-selecting variables.
104
+ const ASSIGN = String.raw`(?![A-Za-z0-9_]*(?:UPDATE|SNAPSHOT|GOLDEN|SKIP|IGNORE)[A-Za-z0-9_]*=)(?!(?:SHELL|MAKE|MAKEFLAGS|MAKEFILES)=)[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_./:@+,%=-]*`;
105
+ const TASK_VALUE = String.raw`[A-Za-z0-9_./:@+*?,~^#!%=-]`;
106
+ const QUOTED_VALUE = String.raw`(?:${TASK_VALUE}+|"[^"$\x60\\\r\n]*"|'[^'$\x60\\\r\n]*')`;
107
+ const DOTNET_PROJECT = String.raw`(?!-)[A-Za-z0-9_~-]*[./][A-Za-z0-9_./~-]*`;
108
+ interface TaskSpec {
109
+ /** Flags that consume the next token (or `=value`) as their argument, as a regex alternation. */
110
+ value: string;
111
+ /** Flags whose argument is an OPTIONAL number (`make -j` vs `make -j 4`). */
112
+ numeric?: string;
113
+ /** Whether bare `NAME=VALUE` tokens are accepted (make, just, rake). */
114
+ assign?: boolean;
115
+ /** Whether targets may carry a Gradle project path (`:app:test`); only the LAST segment must be allow-listed. */
116
+ path?: boolean;
117
+ /** Value token shape; defaults to unquoted TASK_VALUE. */
118
+ token?: string;
119
+ }
120
+ const MAKE_SPEC: TaskSpec = { value: "-C|--directory|-f|--file|--makefile|-I|--include-dir", numeric: "-j|--jobs|-l|--load-average|--max-load", assign: true };
121
+ const JUST_SPEC: TaskSpec = { value: "-f|--justfile|-d|--working-directory", assign: true };
122
+ const RAKE_SPEC: TaskSpec = { value: "-f|--rakefile|-I|--libdir|-R|--rakelibdir", numeric: "-j|--jobs", assign: true };
123
+ const GRADLE_SPEC: TaskSpec = { value: "-p|--project-dir|-b|--build-file|-c|--settings-file|-g|--gradle-user-home|--tests|--max-workers|--console|--warning-mode|--project-cache-dir", path: true };
124
+ const MVN_SPEC: TaskSpec = { value: "-pl|--projects|-f|--file|-T|--threads|-P|--activate-profiles|-s|--settings|-gs|--global-settings|-t|--toolchains|-rf|--resume-from|-l|--log-file" };
125
+ const DOTNET_SPEC: TaskSpec = { value: "-c|--configuration|-f|--framework|-v|--verbosity|-l|--logger|--filter|-r|--runtime|-a|--arch|--os|-s|--settings" };
126
+ /**
127
+ * One flag token: a value-taking flag with its argument, an optional-number flag, any other `-x`/`--long[=v]` flag, or
128
+ * (if enabled) NAME=VALUE. The generic alternative excludes the exact names handled by the first two, so every token
129
+ * has ONE parse (`-C test` consumes `test` as the directory) and matching stays linear. Whether a flag is acceptable is
130
+ * decided afterwards by TASK_DENY on the whole command, never by this grammar.
85
131
  */
86
- export const VERIFY_SIGNAL_RE = /^(?:(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?(?:test|tests|typecheck|lint|build|check|spec|tsc|eslint|vitest|jest)(?::[\w-]+)?|(?:npx|bunx)\s+(?:tsc|eslint|vitest|jest|pytest)|(?:tsc|eslint|vitest|jest|pytest)|(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:(?:-m\s+)?pytest|-m\s+(?:unittest|py_compile|mypy|flake8|ruff\s+check))|node\s+(?:--test|--check|-c)|deno\s+(?:test|check|lint)|cargo\s+(?:test|check|build|clippy)|go\s+(?:test|vet|build)|(?:make|just)\s+(?:test|check|lint|build|typecheck))(?:\s+[A-Za-z0-9_./:@+*?,=-]+)*$/i;
87
- const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--co|--show-?config|--init|--dry-run|--no-run|--passWithNoTests|--if-present)(?=$|\s|=)/i;
88
- const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--write|--update|-u|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
89
- const MUTATING_VERIFY_SCRIPT_RE = /^(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?[\w-]+:[^\s]*(?:fix|update|write|deploy|publish)[^\s]*/i;
132
+ function taskFlag(spec: TaskSpec): string {
133
+ const token = QUOTED_VALUE;
134
+ const names = spec.numeric ? `${spec.value}|${spec.numeric}` : spec.value;
135
+ const alts = [
136
+ String.raw`(?:${spec.value})(?:=|\s+)${token}`,
137
+ String.raw`(?!(?:${names})(?=[=\s]|$))--?[A-Za-z][\w.:-]*(?:=${TASK_VALUE}*)?`,
138
+ ];
139
+ if (spec.numeric) alts.push(String.raw`(?:${spec.numeric})(?:(?:=|\s+)\d+(?:\.\d+)?)?`);
140
+ if (spec.assign) alts.push(ASSIGN);
141
+ return alts.join("|");
142
+ }
143
+ /**
144
+ * `head (flag | non-primary target)* PRIMARY (flag | allow-listed target)*`. The leading run excludes the primary
145
+ * targets so the split point is unique; every token after `head` must be a flag, NAME=VALUE or an allow-listed target.
146
+ */
147
+ function taskCore(head: string, spec: TaskSpec, primary: readonly string[]): string {
148
+ const flag = taskFlag(spec);
149
+ const pre = spec.path ? String.raw`(?:(?!-)[\w.-]*:)*` : "";
150
+ const rest = TARGETS.filter(target => !primary.includes(target)).join("|");
151
+ return String.raw`${head}(?:\s+(?:${flag}|${pre}(?:${rest})))*\s+${pre}(?:${primary.join("|")})(?:\s+(?:${flag}|${pre}(?:${TARGETS.join("|")})))*`;
152
+ }
153
+ const PRETTIER = String.raw`(?:(?:npx|bunx)\s+|(?:pnpm|npm|yarn|bun)\s+(?:exec|x|dlx)\s+)?prettier`;
154
+ const TEST_CORES: readonly string[] = [
155
+ String.raw`${PM}\s+(?:test|tests|spec|vitest|jest)(?::[\w-]+)?`,
156
+ String.raw`(?:npx|bunx)\s+(?:vitest|jest|pytest)`,
157
+ String.raw`(?:(?:npx|bunx)|(?:pnpm|npm|yarn|bun)\s+(?:exec|x|dlx))\s+playwright\s+test`,
158
+ String.raw`(?:vitest|jest|pytest|phpunit|playwright\s+test)`,
159
+ String.raw`(?:bundle\s+exec\s+)?rspec`,
160
+ String.raw`(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:(?:-m\s+)?pytest|-m\s+unittest)`,
161
+ String.raw`node\s+--test`,
162
+ String.raw`deno\s+test`,
163
+ String.raw`cargo\s+test`,
164
+ String.raw`go\s+test`,
165
+ ];
166
+ const SILENT_CORES: readonly string[] = [
167
+ String.raw`${PM}\s+(?:typecheck|lint|build|check|tsc|eslint)(?::[\w-]+)?`,
168
+ String.raw`(?:npx|bunx)\s+(?:tsc|eslint)`,
169
+ String.raw`(?:tsc|eslint|mypy|flake8|staticcheck|ruff\s+check|golangci-lint\s+run)`,
170
+ String.raw`(?:python(?:3(?:\.\d+)?)?|uv)\s+-m\s+(?:py_compile|mypy|flake8|ruff\s+check)`,
171
+ String.raw`node\s+(?:--check|-c)`,
172
+ String.raw`deno\s+(?:check|lint)`,
173
+ String.raw`cargo\s+(?:check|build|clippy)`,
174
+ String.raw`cargo\s+fmt(?=(?:\s+\S+)*\s+--check(?:\s|$))`,
175
+ String.raw`go\s+(?:vet|build)`,
176
+ String.raw`${PRETTIER}(?=(?:\s+\S+)*\s+(?:--check|-c|--list-different|-l)(?:\s|$))`,
177
+ ];
178
+ // Task-runner cores carry their own closed tail (see taskCore); VERIFY_SIGNAL_RE must not append ARG to them.
179
+ const STRICT_TEST_CORES: readonly string[] = [
180
+ taskCore("make", MAKE_SPEC, BUILD_TEST_TARGETS),
181
+ taskCore("just", JUST_SPEC, BUILD_TEST_TARGETS),
182
+ taskCore(String.raw`(?:bundle\s+exec\s+)?rake`, RAKE_SPEC, ["test", "tests", "spec"]),
183
+ taskCore("(?:gradle|gradlew)", GRADLE_SPEC, ["test"]),
184
+ taskCore("(?:mvn|mvnw)", MVN_SPEC, ["test", "verify"]),
185
+ String.raw`dotnet\s+test(?:\s+(?:${taskFlag(DOTNET_SPEC)}|${DOTNET_PROJECT}))*`,
186
+ ];
187
+ const STRICT_SILENT_CORES: readonly string[] = [
188
+ taskCore("make", MAKE_SPEC, BUILD_SILENT_TARGETS),
189
+ taskCore("just", JUST_SPEC, BUILD_SILENT_TARGETS),
190
+ taskCore("(?:gradle|gradlew)", GRADLE_SPEC, ["check", "build"]),
191
+ ];
192
+ // Loose cores match case-insensitively; the task-runner cores stay case-sensitive because their flags differ by case
193
+ // (`mvn -B` batch vs `-b` builder, `-P` profile vs `-p`, `make -C` vs `-c`): `i` would let one swallow the target.
194
+ /** Anchored match of every recognized verifier core (test runners and silent checkers) with its tail. */
195
+ export const VERIFY_SIGNAL_RE = new RegExp(`^(?:(?i:${[...TEST_CORES, ...SILENT_CORES].join("|")})${ARG}|${[...STRICT_TEST_CORES, ...STRICT_SILENT_CORES].join("|")})$`);
196
+ /** Cores that run tests and print a pass summary: the only ones whose output can stand in for a trustworthy exit status. */
197
+ const TEST_CORE_RE = new RegExp(`^(?:(?i:${TEST_CORES.join("|")})${ARG}|${STRICT_TEST_CORES.join("|")})$`);
198
+ // `-x test` (gradle skip) and `-u` (jest update) are lowercase-only: mvn's `-X` (debug) and `-U` (update dependencies) are not them.
199
+ const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--co|--show-?config|--show-settings|--show-files|--init|--dry-run|--no-run|--passWithNoTests|--if-present|-DskipTests|-Dmaven\.test\.skip|-Dmaven\.test\.failure\.ignore|-Dtest\.failure\.ignore|-DfailIfNoTests|-Dsurefire\.failIfNoSpecifiedTests|(?-i:-x)\s+test)(?=$|\s|=)/i;
200
+ const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--fix-only|--unsafe-fixes|--add-noqa|--install-types|-fix|--write|--update|(?-i:-u)|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
201
+ // Judged on the first script token after any manager options, so `pnpm --filter web test:fix` is still caught.
202
+ const MUTATING_VERIFY_SCRIPT_RE = /^(?:bun|npm|pnpm|yarn)\b(?:\s+\S+)*?\s+[\w-]+:[^\s]*(?:fix|update|write|deploy|publish)[^\s]*/i;
203
+ // Any short-flag cluster containing `w` (`-w`, `-cw`, `-wc`) writes files; `--write` is caught by MUTATING_VERIFY_FLAG_RE.
204
+ const PRETTIER_WRITE_RE = new RegExp(String.raw`^${PRETTIER}\b.*(?:^|\s)-[A-Za-z]*w[A-Za-z]*(?=\s|$)`, "i");
205
+ // Per-tool flag deny-list, judged on the command text with quotes turned into spaces. The same letter means different
206
+ // things per tool (`-t` touches in make, selects toolchains in mvn; `-i` ignores errors in make, logs in gradle), so
207
+ // each tool has its own list. Denied: dry-run / listing / touch / "what if" modes and anything that skips tests, masks a
208
+ // failing exit status, or rewrites lock files. make/just/rake may bundle short flags (`-kn`), so those match clusters.
209
+ const PROP_DENY_RE = /(?:^|\s)(?:-D|-P|--define|--project-prop|--system-prop|--activate-profiles|-p:)\s*\S*?(?:skip|ignore|failure|dry.?run|update|snapshot|golden|exec=true)/i;
210
+ const TASK_DENY: readonly { head: RegExp; deny: readonly RegExp[] }[] = [
211
+ { head: /^make\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[ntqiWo][A-Za-z]*|--(?:dry-run|just-print|recon|touch|question|what-if|new-file|assume-new|old-file|assume-old|ignore-errors|eval))(?=\s|=|$)/] },
212
+ { head: /^just\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[nlse][A-Za-z]*|--(?:dry-run|list|show|evaluate|edit|init|dump|summary|choose|fmt|variables|changelog|completions|command))(?=\s|=|$)/] },
213
+ { head: /^(?:bundle\s+exec\s+)?rake\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[nTPDWperEhHV][A-Za-z]*|--(?:dry-run|tasks|prereqs|describe|where|execute|execute-print|execute-continue|require|help|version))(?=\s|=|$)/] },
214
+ { head: /^(?:gradle|gradlew)\b/, deny: [/(?:^|\s)(?:-[mta](?=\s|$)|-[xI]\S*|--(?:dry-run|exclude-task|continuous|init-script|scan|write-locks|update-locks|write-verification-metadata|refresh-dependencies|export-keys|no-rebuild)(?=\s|=|$))/, PROP_DENY_RE] },
215
+ { head: /^(?:mvn|mvnw)\b/, deny: [/(?:^|\s)(?:-v|--version|-h|--help|-fn|--fail-never)(?=\s|=|$)/, PROP_DENY_RE] },
216
+ { head: /^dotnet\b/, deny: [/(?:^|\s)(?:-t|--list-tests|-h|--help|-\?)(?=\s|=|$)/, PROP_DENY_RE] },
217
+ ];
90
218
 
91
219
  // ── Verifier command grammar ──────────────────────────────────────────────────
92
- // command := segment ( "&&" segment )* after removing every standalone `2>&1` token
93
- // segment[0] := cdSeg | verifierSeg cdSeg only as the first segment, and only when >=1 segment follows
94
- // segment[i] := verifierSeg (i >= 1)
95
- // verifierSeg:= prefix* core prefixes: `NAME=VALUE`, `env [-u NAME]... [NAME=VALUE]...`,
96
- // `timeout N[s|m]`, `uv|poetry|pipenv run`, `rtk [test]`
220
+ // The done-gate trusts the shell's exit status of a verifier. A form whose status is NOT the verifier's own can
221
+ // report success while the verifier failed, so it is "weak": it counts ONLY when the OUTPUT shows positive pass
222
+ // evidence AND no failure marker (see verificationProducedEvidence). Direct forms keep tool success + evidence.
223
+ //
224
+ // command := "(" body ")" | body one outer subshell level, always weak; `2>&1` tokens removed
225
+ // body := ( (set|cd) ";" )* [cdSeg "&&"] pipeline ( "&&" pipeline )* [ ("||" | ";") trailer ]
226
+ // pipeline := verifierSeg ( "|" display )* display := tail [-N | -n N] | tee [-a] (never a file, never head)
227
+ // trailer := true | ":" | echo <plain words> echo only at the top level; its text must hold no digit/pass/ok/success
228
+ // verifierSeg:= prefix* core | (bash|sh|zsh|dash) -c "<body>" nested -c is judged on the inner body, one level deep
229
+ // prefix := `NAME=VALUE`, `env [-u NAME]... [NAME=VALUE]...`, `timeout N[s|m]`, `time [-p]`,
230
+ // `uv|poetry|pipenv run`, `rtk [test]` each propagates the verifier's exit status
97
231
  // core := first `/`-bearing token replaced by its basename, then VERIFY_SIGNAL_RE with no
98
232
  // non-executing / mutating flag, no mutating script, and no short no-op form.
99
- // Only plain tokens are accepted: `$`, backticks, quotes and parentheses never match the token classes
100
- // and are rejected by the global metacharacter check. Every prefix propagates the verifier's exit code.
233
+ // Weak = any pipe, `||`/`;` trailer, `set`/`cd ;` prefix or subshell. A weak form is recognized only when EVERY core
234
+ // is a test runner (TEST_CORE_RE): a silent linter has no output to corroborate it, and `grep`/`awk`/`sed`/`sort`/
235
+ // `cut`/`head` stages, `rm`, `git commit`, installers or any other command never qualify as display or trailer.
236
+ // make/just/rake/gradle/mvn/dotnet cores use a closed tail (see taskCore): any flag not on that tool's TASK_DENY list,
237
+ // its value, NAME=VALUE and allow-listed targets; any other bare target (deploy, install, publish, db:drop) rejects.
238
+ // Only plain tokens or balanced quotes are accepted: `$`, backticks, backslashes, redirections and grouping
239
+ // outside the single subshell are rejected before any stage is read.
101
240
  const STDERR_MERGE_RE = /(^|\s)2>&1(?=\s|$)/g;
102
241
  const ASSIGN_PREFIX_RE = /^([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*\s+/;
103
242
  const ENV_PREFIX_RE = /^env(?:\s+-u\s+[A-Za-z_][A-Za-z0-9_]*)*(?:\s+([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*)*\s+/;
104
243
  const TIMEOUT_PREFIX_RE = /^timeout\s+\d+[sm]?\s+/;
105
244
  const RUNNER_PREFIX_RE = /^(?:uv|poetry|pipenv)\s+run\s+/;
106
245
  const RTK_PREFIX_RE = /^rtk\s+(?:test\s+)?/;
107
- const CD_SEGMENT_RE = /^cd\s+[A-Za-z0-9_./:@+,=~-]+$/;
246
+ const TIME_PREFIX_RE = /^time\s+(?:-p\s+)?/;
247
+ const CD_SEGMENT_RE = /^cd\s+(?:[A-Za-z0-9_./:@+,=~-]+|"[^"$`\\]*"|'[^'$`\\]*')$/;
248
+ const SET_STMT_RE = /^set(?:\s+(?:-[A-Za-z]+|-o\s+pipefail|pipefail))+$/;
108
249
  const BINARY_PATH_RE = /^([A-Za-z0-9_.@+-]*\/[A-Za-z0-9_./@+-]*)(?=\s|$)/;
250
+ const SHELL_C_RE = /^(?:ba|z|da)?sh\s+(?:-o\s+pipefail\s+)?-[elux]*c\s+(?:"([^"]*)"|'([^']*)')$/;
251
+ // `head` is not display-only (it closes the pipe early, hiding a later failure) and `tee <file>` could overwrite a
252
+ // tracked file through a symlink: a verified call is never snapshot-bracketed, so that write would be invisible.
253
+ const DISPLAY_STAGE_RE = /^(?:tail(?:\s+(?:-\d+|-n\s*\d+))?|tee(?:\s+-a)?)$/;
254
+ /** Echo text that could read as a pass summary (or contains a count) is refused rather than discounted. */
255
+ const ECHO_PASSLIKE_RE = /\d|\bpass(?:ed|ing)?\b|\bok\b|success/i;
256
+ const TRAILER_RE = /^(?:true|:|echo((?:\s+(?:[A-Za-z0-9_.,:@%+=\/][A-Za-z0-9_.,:@%+=\/-]*|"[^"$`\\]*"|'[^'$`\\]*'))*))$/;
109
257
  /** Env names that are the env-form of an update/snapshot flag (parity with MUTATING_VERIFY_FLAG_RE). */
110
258
  const MUTATING_VERIFY_ENV_RE = /UPDATE|SNAPSHOT|GOLDEN/i;
111
259
  const ENV_ASSIGNMENT_NAME_RE = /([A-Za-z_][A-Za-z0-9_]*)=/g;
112
260
 
261
+ /** Result of {@link parseVerifier}: the verifier invocations and how far the shell's exit status can be trusted. */
262
+ export interface VerifierParse {
263
+ /** Normalized verifier invocations (wrapper prefixes, `cd`, pipes and trailers stripped). */
264
+ cores: string[];
265
+ /** True when the shell's exit status IS the verifier's own, so tool success is itself the pass signal. */
266
+ trusted: boolean;
267
+ /** Literal text a trailing `echo` prints; the evidence check discounts it so it cannot fake a pass line. */
268
+ echoed: string | null;
269
+ }
270
+
271
+ interface Chain { cores: string[]; weak: boolean; echoed: string | null }
272
+
273
+ /**
274
+ * Parse a command as a recognized verification, or null when it is not one. See the grammar above for what is
275
+ * accepted and which forms are weak (`trusted: false`).
276
+ */
277
+ export function parseVerifier(cmd: string): VerifierParse | null {
278
+ let command = cmd.trim().replace(STDERR_MERGE_RE, "$1").trim();
279
+ if (!command) return null;
280
+ let wrapped = false;
281
+ if (command.startsWith("(") && command.endsWith(")")) {
282
+ command = command.slice(1, -1).trim();
283
+ wrapped = true;
284
+ }
285
+ const chain = parseChain(command, false);
286
+ if (!chain) return null;
287
+ const weak = wrapped || chain.weak;
288
+ if (weak && !chain.cores.every(core => TEST_CORE_RE.test(core))) return null;
289
+ return { cores: chain.cores, trusted: !weak, echoed: chain.echoed };
290
+ }
291
+
113
292
  /**
114
293
  * Normalized verifier invocations (cores) of a command, or null when the command is not a
115
- * pure recognized verification. Wrapper prefixes and a leading `cd <path> &&` are stripped,
294
+ * recognized verification. Wrapper prefixes and a leading `cd <path> &&` are stripped,
116
295
  * so flag/script exclusions are judged on the verifier itself, never on the wrappers.
117
296
  */
118
297
  export function verifierCores(cmd: string): string[] | null {
119
- const command = cmd.trim().replace(STDERR_MERGE_RE, "$1").trim();
120
- if (!command) return null;
121
- // Pipes, `;`, `||`, redirections, substitution, grouping and multi-line scripts.
122
- if (/[;&|<>`$(){}\n\r]/.test(command.replace(/&&/g, " "))) return null;
123
- const segments = command.split("&&").map(s => s.trim());
124
- if (segments.some(s => !s)) return null;
125
- const start = segments.length > 1 && CD_SEGMENT_RE.test(segments[0]) ? 1 : 0;
298
+ return parseVerifier(cmd)?.cores ?? null;
299
+ }
300
+
301
+ /**
302
+ * Split on top-level `&&`, `||`, `|` and `;` while leaving quoted text intact. Rejects anything that could change
303
+ * what runs: expansion, escapes, newlines, redirections, grouping and a lone `&` outside quotes.
304
+ */
305
+ function splitShell(command: string): { stages: string[]; ops: string[] } | null {
306
+ const stages: string[] = [];
307
+ const ops: string[] = [];
308
+ let start = 0;
309
+ let quote = "";
310
+ for (let i = 0; i < command.length; i++) {
311
+ const ch = command[i];
312
+ if (ch === "$" || ch === "`" || ch === "\\" || ch === "\n" || ch === "\r") return null;
313
+ if (quote) {
314
+ if (ch === quote) quote = "";
315
+ continue;
316
+ }
317
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
318
+ if ("<>(){}".includes(ch)) return null;
319
+ let op: string;
320
+ if (ch === "&") {
321
+ if (command[i + 1] !== "&") return null;
322
+ op = "&&";
323
+ } else if (ch === "|") op = command[i + 1] === "|" ? "||" : "|";
324
+ else if (ch === ";") op = ";";
325
+ else continue;
326
+ stages.push(command.slice(start, i).trim());
327
+ ops.push(op);
328
+ i += op.length - 1;
329
+ start = i + 1;
330
+ }
331
+ if (quote) return null;
332
+ stages.push(command.slice(start).trim());
333
+ return stages.some(stage => !stage) ? null : { stages, ops };
334
+ }
335
+
336
+ function parseChain(command: string, nested: boolean): Chain | null {
337
+ const shell = splitShell(command);
338
+ if (!shell) return null;
339
+ const { stages, ops } = shell;
340
+ const last = stages.length - 1;
341
+ let p = 0;
342
+ let weak = false;
343
+ // `set -e;` / `cd x;` change state only; they cannot mask the next command's status, but are not the verifier's own.
344
+ while (p < last && ops[p] === ";" && (SET_STMT_RE.test(stages[p]) || CD_SEGMENT_RE.test(stages[p]))) { weak = true; p++; }
345
+ if (p < last && ops[p] === "&&" && CD_SEGMENT_RE.test(stages[p])) p++;
126
346
  const cores: string[] = [];
127
- for (const segment of segments.slice(start)) {
128
- const core = verifierCore(segment);
129
- if (!core) return null;
130
- cores.push(core);
347
+ let echoed: string | null = null;
348
+ for (;;) {
349
+ const stage = parseStage(stages[p], nested);
350
+ if (!stage) return null;
351
+ cores.push(...stage.cores);
352
+ if (stage.weak) weak = true;
353
+ p++;
354
+ while (p <= last && ops[p - 1] === "|") {
355
+ if (!DISPLAY_STAGE_RE.test(stages[p])) return null;
356
+ weak = true;
357
+ p++;
358
+ }
359
+ if (p > last) break;
360
+ if (ops[p - 1] === "&&") continue;
361
+ // `||` or `;`: exactly one trailing stage that cannot run anything else.
362
+ if (p !== last) return null;
363
+ const trailer = TRAILER_RE.exec(stages[p]);
364
+ if (!trailer) return null;
365
+ if (trailer[1] !== undefined) {
366
+ if (nested) return null; // only a top-level echo is the tail of the whole output, where it can be discounted
367
+ echoed = [...trailer[1].matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(t => t[1] ?? t[2] ?? t[3]).join(" ");
368
+ if (ECHO_PASSLIKE_RE.test(echoed)) return null;
369
+ }
370
+ weak = true;
371
+ break;
131
372
  }
132
- return cores.length ? cores : null;
373
+ return { cores, weak, echoed };
374
+ }
375
+
376
+ function parseStage(segment: string, nested: boolean): Chain | null {
377
+ const rest = unwrap(segment);
378
+ if (rest === null) return null;
379
+ const shell = SHELL_C_RE.exec(rest);
380
+ if (shell) return nested ? null : parseChain(shell[1] ?? shell[2], true);
381
+ const core = verifierCore(rest);
382
+ return core ? { cores: [core], weak: false, echoed: null } : null;
133
383
  }
134
384
 
135
- function verifierCore(segment: string): string | null {
385
+ /** Strip exit-code-propagating wrapper prefixes and normalize a path-qualified binary; null on a mutating env. */
386
+ function unwrap(segment: string): string | null {
136
387
  let rest = segment;
137
388
  for (;;) { // terminates: every successful match strictly shortens `rest`
138
389
  const assign = ASSIGN_PREFIX_RE.exec(rest);
@@ -147,7 +398,7 @@ function verifierCore(segment: string): string | null {
147
398
  rest = rest.slice(env[0].length);
148
399
  continue;
149
400
  }
150
- const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest);
401
+ const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest) ?? TIME_PREFIX_RE.exec(rest);
151
402
  if (other) {
152
403
  rest = rest.slice(other[0].length);
153
404
  continue;
@@ -155,37 +406,110 @@ function verifierCore(segment: string): string | null {
155
406
  break;
156
407
  }
157
408
  const bin = BINARY_PATH_RE.exec(rest);
158
- if (bin) rest = bin[1].slice(bin[1].lastIndexOf("/") + 1) + rest.slice(bin[1].length);
159
- const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(rest)
160
- || /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(rest);
161
- return !shortNoOp && VERIFY_SIGNAL_RE.test(rest) && !NONEXECUTING_VERIFY_FLAG_RE.test(rest)
162
- && !MUTATING_VERIFY_FLAG_RE.test(rest) && !MUTATING_VERIFY_SCRIPT_RE.test(rest) ? rest : null;
409
+ return bin ? bin[1].slice(bin[1].lastIndexOf("/") + 1) + rest.slice(bin[1].length) : rest;
410
+ }
411
+
412
+ function verifierCore(rest: string): string | null {
413
+ // Flag exclusions are judged with quote characters turned into spaces: `bun test "--update-snapshots"` must not hide
414
+ // the flag behind a quote. A quoted `-k` expression that merely contains a flag-like word fails closed.
415
+ const flat = rest.replace(/["']/g, " ");
416
+ const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(flat)
417
+ || /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(flat)
418
+ || PRETTIER_WRITE_RE.test(flat);
419
+ const task = TASK_DENY.find(entry => entry.head.test(rest));
420
+ return !shortNoOp && VERIFY_SIGNAL_RE.test(rest) && !NONEXECUTING_VERIFY_FLAG_RE.test(flat)
421
+ && !MUTATING_VERIFY_FLAG_RE.test(flat) && !MUTATING_VERIFY_SCRIPT_RE.test(flat)
422
+ && !task?.deny.some(re => re.test(flat)) ? rest : null;
163
423
  }
164
424
 
165
425
  /**
166
426
  * True only when the command itself is a recognized verifier invocation (or a pure `&&`
167
427
  * chain of them, optionally led by `cd <path> &&` and wrapped in exit-code-propagating
168
- * prefixes — see {@link verifierCores}). Output is never evidence: arbitrary commands can
169
- * print words such as "tests passed".
428
+ * prefixes — see {@link parseVerifier}, which also admits the weak forms). Output is never what
429
+ * RECOGNIZES a command: arbitrary commands can print words such as "tests passed".
170
430
  */
171
431
  export function isVerificationSignal(cmd: string): boolean {
172
- return verifierCores(cmd) !== null;
432
+ return parseVerifier(cmd) !== null;
433
+ }
434
+
435
+ // Output evidence. Every pattern is anchored to a summary position so a test TITLE cannot impersonate a summary.
436
+ const PASS_LINE_RES: readonly RegExp[] = [
437
+ // bun "12 pass", mocha "12 passing", jest "Tests: 1 failed, 4 passed", vitest "Tests 5 passed", pytest "== 5 passed",
438
+ // playwright "5 passed (3s)", deno "ok | 3 passed"
439
+ /^[ \t=]*(?:ok[ \t]*\|[ \t]*)?(?:Tests?:?[ \t]+)?(?:\d+ [A-Za-z]+(?:[ \t]*[,|][ \t]*|[ \t]+))*[1-9]\d* pass(?:ed|ing)?\b/,
440
+ /^\s*(?:\u2139|#)\s*pass\s+[1-9]\d*\b/, // node --test
441
+ /^ok\s+\S+\s+(?:\(cached\)|[\d.]+s)/, // go (the `[no tests ...]` forms are excluded by the caller)
442
+ /^\s*--- PASS:/, // go test -v
443
+ /^\s*[1-9]\d* examples?, 0 failures?/, // rspec
444
+ /Tests run:\s*[1-9]\d*,\s*Failures:\s*0,\s*Errors:\s*0/, // maven
445
+ /^\s*Passed!.*\bPassed:\s*[1-9]\d*/, // dotnet
446
+ /^OK \([1-9]\d* tests?,/, // phpunit
447
+ ];
448
+ const FAILURE_LINE_RES: readonly RegExp[] = [
449
+ /(?:^|[^\d])[1-9]\d*\s+(?:fail(?:ed|ing|ures?)?|errors?)\b/i,
450
+ /\b(?:Failures|Errors|Failed):\s*[1-9]/,
451
+ /^\s*\(fail\)/,
452
+ /^\s*(?:FAIL|FAILED|ERROR)\b/,
453
+ /^\s*--- FAIL/,
454
+ /^\s*panic:|panicked at/,
455
+ /test result: FAILED|\.\.\. FAILED\b|^failures:\s*$/, // cargo
456
+ /^\s*error: .*(?:target|test).*failed|\bcould not compile\b/i, // cargo: the FAILED line can scroll out of a `tail` window
457
+ /^\[ERROR\]|BUILD (?:FAILED|FAILURE)|^FAILURE:/, // maven / gradle
458
+ /Tests run:.*(?:Failures|Errors): [1-9]/, // maven
459
+ /\b(?:ERRORS|FAILURES)!/, // phpunit
460
+ /^\s*Failed!/, // dotnet
461
+ /^\s*(?:\u2139|#)\s*fail\s+[1-9]/,
462
+ /\bSegmentation fault\b|\bBus error\b|\bKilled\b|\btimed out\b/,
463
+ /\berror TS\d+/,
464
+ ];
465
+ /** A line a runner prints for a test that PASSED: its title may legitimately mention "errors" or "failed". */
466
+ const PASSED_TITLE_RE = /^\s*(?:\((?:pass|skip|todo)\)|[\u2713\u2714\u221a])/;
467
+ /** Explicit "nothing ran" output; only consulted when no positive pass summary is present. */
468
+ const NO_TESTS_RE = /(?:^|[^\d])0 pass(?:ed|ing)?\b|(?:^|[^\d])0 examples?\b|\bno tests? (?:ran|found|to run|executed)\b|\bno test files found\b|\bNo test is available\b|\bcollected 0 items\b|\bRan 0 tests\b|\bTests run:\s*0\b|(?:\u2139|#)\s*pass\s+0\b/i;
469
+ const ANSI_RE = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
470
+
471
+ function hasPassSummary(text: string): boolean {
472
+ if (/\bBUILD SUCCESSFUL\b/.test(text) && !/\bNO-SOURCE\b/.test(text)) return true; // gradle prints no counts
473
+ if (/test result: ok\.\s+[1-9]\d* passed/.test(text)) return true;
474
+ return text.split(/\r?\n/).some(line => PASS_LINE_RES.some(re => re.test(line)) && !/\[no tests to run\]|\[no test files\]/i.test(line));
173
475
  }
174
476
 
175
477
  /**
176
- * Require test-run evidence for Go and Cargo's recognized text output. Judged on the
177
- * verifier cores, so a wrapped `timeout 60 go test ./...` or `cd x && cargo test` still
178
- * needs real test evidence.
478
+ * Drop the text a trailing `echo` printed so it cannot supply (or hide) a pass line. The bash tool joins stdout then
479
+ * stderr, so the echo can sit mid-output: every whole-line occurrence goes, and so does a glued trailing suffix.
480
+ */
481
+ function withoutEchoText(text: string, echoed: string | null): string {
482
+ if (!echoed) return text;
483
+ const kept = text.split(/\r?\n/).filter(line => line.trim() !== echoed).join("\n");
484
+ const trimmed = kept.trimEnd();
485
+ return trimmed.endsWith(echoed) ? trimmed.slice(0, trimmed.length - echoed.length) : kept;
486
+ }
487
+
488
+ /**
489
+ * Output-side judgment of a verifier run the caller already knows succeeded (exit 0).
490
+ * - Go and Cargo need real test-run evidence, judged on the verifier cores so a wrapped
491
+ * `timeout 60 go test ./...` or `cd x && cargo test` still needs it.
492
+ * - A direct test runner whose output says nothing ran (`0 pass`, "no tests ran") is not evidence unless a positive
493
+ * pass summary is also present; silent linters and empty output stay evidence on tool success alone.
494
+ * - A weak form (see the grammar above) has no trustworthy exit status: it needs a positive pass summary and no
495
+ * failure marker anywhere in the output.
179
496
  */
180
497
  export function verificationProducedEvidence(cmd: string, output: string): boolean {
181
- const cores = verifierCores(cmd) ?? [cmd.trim()];
498
+ const parsed = parseVerifier(cmd);
499
+ const cores = parsed?.cores ?? [cmd.trim()];
500
+ const text = withoutEchoText(output.replace(ANSI_RE, ""), parsed?.echoed ?? null);
182
501
  if (cores.some(c => /^go\s+test(?:\s|$)/.test(c))) {
183
- const summaries = output.split(/\r?\n/).filter(line => /^(?:ok|\?)\s+/.test(line));
502
+ const summaries = text.split(/\r?\n/).filter(line => /^(?:ok|\?)\s+/.test(line));
184
503
  if (!summaries.some(line => !/\[no tests to run\]|\[no test files\]/i.test(line))) return false;
185
504
  }
186
505
  if (cores.some(c => /^cargo\s+test(?:\s|$)/.test(c))
187
- && ![...output.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0)) return false;
188
- return true;
506
+ && ![...text.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0)) return false;
507
+ if (!parsed) return true;
508
+ if (!parsed.trusted) {
509
+ const failed = text.split(/\r?\n/).some(line => !PASSED_TITLE_RE.test(line) && FAILURE_LINE_RES.some(re => re.test(line)));
510
+ return hasPassSummary(text) && !failed;
511
+ }
512
+ return !(cores.some(c => TEST_CORE_RE.test(c)) && !hasPassSummary(text) && NO_TESTS_RE.test(text));
189
513
  }
190
514
 
191
515
  /** Marker that prefixes a candidate answer the completion gate never accepted. */
@@ -168,7 +168,21 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
168
168
  if (mount.Type === "tmpfs" && mount.Destination === "/tmp") continue;
169
169
  const normalizedSource = typeof mount.Source === "string" ? normalizeMountSource(mount.Source) : "";
170
170
  if (mount.Type !== "bind" || !normalizedSource || !path.isAbsolute(normalizedSource)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed mount metadata");
171
- const source = await canonicalSandboxPath(normalizedSource);
171
+ let source: string;
172
+ try { source = await canonicalSandboxPath(normalizedSource); }
173
+ catch (error) {
174
+ // A leaked/exited managed container whose temp workspace was deleted must not wedge every later launch.
175
+ // Still guard identical-or-disjoint overlap: resolve its nearest existing ancestor (so aliases such as
176
+ // macOS /var -> /private/var compare equal to canonical candidates), and compare lexically when even that
177
+ // cannot resolve (ENOTDIR). Every other failure (EACCES, loops, invalid metadata) still fails closed.
178
+ const lost = (value: unknown): boolean => { const code = (value as NodeJS.ErrnoException).code; return code === "ENOENT" || code === "ENOTDIR"; };
179
+ if (!lost(error)) throw error;
180
+ try { source = await canonicalSandboxPath(normalizedSource, false); }
181
+ catch (inner) {
182
+ if (!lost(inner)) throw inner;
183
+ source = path.resolve(normalizedSource);
184
+ }
185
+ }
172
186
  if (sources.some(candidate => candidate !== source && (sandboxPathContains(candidate, source) || sandboxPathContains(source, candidate)))) {
173
187
  throw new Error("SANDBOX_MOUNT_OVERLAP: managed mount roots must be identical or disjoint");
174
188
  }