@mjasnikovs/pi-task 0.18.30 → 0.18.32

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.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * runner-globs — deterministic detection of TWO TEST RUNNERS FIGHTING OVER THE SAME
3
+ * FILES, checked as soon as a project declares both rather than discovered at run end.
4
+ *
5
+ * The failure this closes — SECOND occurrence, runs 7 AND 13: a project declares both
6
+ * `bun test` and `playwright test`. Bun's runner scans the whole project for
7
+ * `*.test.*` / `*.spec.*`; Playwright's component/e2e specs ARE `*.spec.tsx`. So
8
+ * `bun test` imports Playwright spec files, which import `@playwright/test` outside a
9
+ * Playwright runner, and the whole suite dies on a module it was never meant to load.
10
+ *
11
+ * Run 7 found it in the final gate. Run 13 found it in the final gate AGAIN — and the
12
+ * fix (a `pathIgnorePatterns` line in bunfig.toml) was still sitting UNCOMMITTED in
13
+ * the working tree when the run ended, so HEAD shipped with `bun run test` broken. A
14
+ * defect that recurs across runs and survives its own fix is not a discovery problem;
15
+ * it is a missing invariant. This module states the invariant so it can be checked the
16
+ * moment both runners are declared:
17
+ *
18
+ * if two runners are declared, their file sets must be provably DISJOINT
19
+ *
20
+ * Disjointness has exactly two mechanical forms, and this module accepts either:
21
+ * - EXCLUSION: the scanning runner is configured to ignore the other's files
22
+ * (bunfig `[test] pathIgnorePatterns`), or
23
+ * - NAMING: the other runner's files are named so the scanner never claims them
24
+ * (Playwright `testMatch` on a suffix outside `*.test.*` / `*.spec.*`, e.g. `.e2e.ts`).
25
+ *
26
+ * Guard direction: UNKNOWN steps aside. A missing/unparseable manifest, one runner
27
+ * only, or a Playwright config whose testMatch cannot be read all return `unknown` —
28
+ * the check may cost time, never work.
29
+ */
30
+ export type GlobCollisionStatus = 'collision' | 'disjoint' | 'unknown';
31
+ export interface GlobCollisionAssessment {
32
+ status: GlobCollisionStatus;
33
+ /** Human- and prompt-readable explanation; '' when there is nothing to say. */
34
+ detail: string;
35
+ /** The script names that invoke each runner (for naming the finding). */
36
+ scanningScripts: string[];
37
+ otherScripts: string[];
38
+ }
39
+ export interface RunnerGlobInputs {
40
+ /** The manifest's `scripts` map. */
41
+ scripts: Record<string, string>;
42
+ /** bunfig.toml text, or null when absent. */
43
+ bunfig: string | null;
44
+ /** playwright config text (any of the playwright*.config.* files), or null. */
45
+ playwrightConfig: string | null;
46
+ }
47
+ /**
48
+ * Bun's declared ignore patterns (`[test] pathIgnorePatterns = [...]`). Returns null
49
+ * when bunfig is absent or the key is not present — absent is not "empty", it is
50
+ * unknown-shaped, and the caller distinguishes them.
51
+ */
52
+ export declare function parsePathIgnorePatterns(bunfig: string | null): string[] | null;
53
+ /**
54
+ * Playwright's `testMatch`, when the config states one. Null → the default, which
55
+ * matches `*.spec.*` and `*.test.*` — precisely Bun's claimed set.
56
+ */
57
+ export declare function parseTestMatch(playwrightConfig: string | null): string[] | null;
58
+ /** Playwright's `testDir`, when stated (used to explain the collision concretely). */
59
+ export declare function parseTestDir(playwrightConfig: string | null): string | null;
60
+ /**
61
+ * Assess whether the declared runners can collide. See the module doc for the
62
+ * invariant and the two accepted forms of disjointness.
63
+ */
64
+ export declare function assessRunnerGlobs(input: RunnerGlobInputs): GlobCollisionAssessment;
65
+ /**
66
+ * Verify-child prompt lines for a collision. Empty for any non-collision status —
67
+ * the caller emits no block.
68
+ */
69
+ export declare function runnerGlobVerifyFindings(a: GlobCollisionAssessment): string[];
70
+ /**
71
+ * A plan-time contract line: the invariant, recorded so slices that add a runner
72
+ * inherit it instead of rediscovering the collision. Empty when not applicable.
73
+ */
74
+ export declare function runnerGlobContractLine(a: GlobCollisionAssessment): string;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * runner-globs — deterministic detection of TWO TEST RUNNERS FIGHTING OVER THE SAME
3
+ * FILES, checked as soon as a project declares both rather than discovered at run end.
4
+ *
5
+ * The failure this closes — SECOND occurrence, runs 7 AND 13: a project declares both
6
+ * `bun test` and `playwright test`. Bun's runner scans the whole project for
7
+ * `*.test.*` / `*.spec.*`; Playwright's component/e2e specs ARE `*.spec.tsx`. So
8
+ * `bun test` imports Playwright spec files, which import `@playwright/test` outside a
9
+ * Playwright runner, and the whole suite dies on a module it was never meant to load.
10
+ *
11
+ * Run 7 found it in the final gate. Run 13 found it in the final gate AGAIN — and the
12
+ * fix (a `pathIgnorePatterns` line in bunfig.toml) was still sitting UNCOMMITTED in
13
+ * the working tree when the run ended, so HEAD shipped with `bun run test` broken. A
14
+ * defect that recurs across runs and survives its own fix is not a discovery problem;
15
+ * it is a missing invariant. This module states the invariant so it can be checked the
16
+ * moment both runners are declared:
17
+ *
18
+ * if two runners are declared, their file sets must be provably DISJOINT
19
+ *
20
+ * Disjointness has exactly two mechanical forms, and this module accepts either:
21
+ * - EXCLUSION: the scanning runner is configured to ignore the other's files
22
+ * (bunfig `[test] pathIgnorePatterns`), or
23
+ * - NAMING: the other runner's files are named so the scanner never claims them
24
+ * (Playwright `testMatch` on a suffix outside `*.test.*` / `*.spec.*`, e.g. `.e2e.ts`).
25
+ *
26
+ * Guard direction: UNKNOWN steps aside. A missing/unparseable manifest, one runner
27
+ * only, or a Playwright config whose testMatch cannot be read all return `unknown` —
28
+ * the check may cost time, never work.
29
+ */
30
+ /** `bun test` — the scanning runner: it claims every `*.test.*` / `*.spec.*` it finds. */
31
+ const BUN_TEST_RE = /\bbun\s+(?:--\S+\s+)*test\b/;
32
+ /** `playwright test` (incl. `bunx`/`npx`/`pnpm exec` prefixes). */
33
+ const PLAYWRIGHT_RE = /\bplaywright\s+test\b/;
34
+ /** The suffixes Bun's test runner claims by default. */
35
+ const BUN_CLAIMED_SUFFIX_RE = /\.(?:test|spec)\./;
36
+ /** Scripts that invoke a given runner, by name. */
37
+ function scriptsMatching(scripts, re) {
38
+ return Object.entries(scripts)
39
+ .filter(([, body]) => typeof body === 'string' && re.test(body))
40
+ .map(([name]) => name);
41
+ }
42
+ /**
43
+ * Bun's declared ignore patterns (`[test] pathIgnorePatterns = [...]`). Returns null
44
+ * when bunfig is absent or the key is not present — absent is not "empty", it is
45
+ * unknown-shaped, and the caller distinguishes them.
46
+ */
47
+ export function parsePathIgnorePatterns(bunfig) {
48
+ if (bunfig === null)
49
+ return null;
50
+ const m = /^[ \t]*pathIgnorePatterns[ \t]*=[ \t]*\[([\s\S]*?)\]/m.exec(bunfig);
51
+ if (!m)
52
+ return null;
53
+ return [...m[1].matchAll(/["']([^"']+)["']/g)].map(x => x[1]);
54
+ }
55
+ /**
56
+ * Playwright's `testMatch`, when the config states one. Null → the default, which
57
+ * matches `*.spec.*` and `*.test.*` — precisely Bun's claimed set.
58
+ */
59
+ export function parseTestMatch(playwrightConfig) {
60
+ if (playwrightConfig === null)
61
+ return null;
62
+ const m = /\btestMatch\s*:\s*(\[[\s\S]*?\]|['"][^'"]+['"]|\/[^/\n]+\/[gimsuy]*)/.exec(playwrightConfig);
63
+ if (!m)
64
+ return null;
65
+ const found = [...m[1].matchAll(/["']([^"']+)["']/g)].map(x => x[1]);
66
+ return found.length > 0 ? found : [m[1]];
67
+ }
68
+ /** Playwright's `testDir`, when stated (used to explain the collision concretely). */
69
+ export function parseTestDir(playwrightConfig) {
70
+ if (playwrightConfig === null)
71
+ return null;
72
+ const m = /\btestDir\s*:\s*['"]([^'"]+)['"]/.exec(playwrightConfig);
73
+ return m ? m[1].replace(/^\.\//, '').replace(/\/+$/, '') : null;
74
+ }
75
+ /**
76
+ * Does an ignore pattern plausibly cover Playwright's spec files? Deliberately
77
+ * generous — this decides whether to STAY SILENT, and a guard that may only cost
78
+ * time should resolve ambiguity toward silence. A pattern naming a `spec`/`test`
79
+ * suffix, or the Playwright testDir, counts.
80
+ */
81
+ function ignoreCoversSpecs(patterns, testDir) {
82
+ return patterns.some(p => {
83
+ if (BUN_CLAIMED_SUFFIX_RE.test(p) || /\bspec\b|\btest\b/.test(p))
84
+ return true;
85
+ return testDir !== null && testDir.length > 0 && p.includes(testDir);
86
+ });
87
+ }
88
+ /**
89
+ * Assess whether the declared runners can collide. See the module doc for the
90
+ * invariant and the two accepted forms of disjointness.
91
+ */
92
+ export function assessRunnerGlobs(input) {
93
+ const scripts = input.scripts ?? {};
94
+ const scanningScripts = scriptsMatching(scripts, BUN_TEST_RE);
95
+ const otherScripts = scriptsMatching(scripts, PLAYWRIGHT_RE);
96
+ const base = { scanningScripts, otherScripts };
97
+ // Only one runner (or none) — nothing to collide.
98
+ if (scanningScripts.length === 0 || otherScripts.length === 0) {
99
+ return { status: 'unknown', detail: '', ...base };
100
+ }
101
+ // NAMING form: Playwright's own testMatch keeps its files outside Bun's claim.
102
+ const testMatch = parseTestMatch(input.playwrightConfig);
103
+ if (testMatch !== null && !testMatch.some(p => BUN_CLAIMED_SUFFIX_RE.test(p))) {
104
+ return {
105
+ status: 'disjoint',
106
+ detail: `playwright testMatch (${testMatch.join(', ')}) names files outside bun test's `
107
+ + `\`*.test.*\` / \`*.spec.*\` claim — the two runners cannot pick up each other's files`,
108
+ ...base
109
+ };
110
+ }
111
+ // EXCLUSION form: bun is told to ignore them.
112
+ const ignore = parsePathIgnorePatterns(input.bunfig);
113
+ const testDir = parseTestDir(input.playwrightConfig);
114
+ if (ignore !== null && ignoreCoversSpecs(ignore, testDir)) {
115
+ return {
116
+ status: 'disjoint',
117
+ detail: `bunfig [test] pathIgnorePatterns (${ignore.join(', ')}) excludes the playwright `
118
+ + `spec files from bun test's scan`,
119
+ ...base
120
+ };
121
+ }
122
+ // Both declared, neither form of disjointness present.
123
+ return {
124
+ status: 'collision',
125
+ detail: `\`${scanningScripts.join('`, `')}\` runs bun test, which scans the whole project for `
126
+ + '`*.test.*` / `*.spec.*`, and `'
127
+ + otherScripts.join('`, `')
128
+ + '` runs playwright, whose specs use those same suffixes'
129
+ + (testDir ? ` (testDir: ${testDir})` : '')
130
+ + '. bun test will import the playwright specs and die on `@playwright/test` outside '
131
+ + 'its runner. Declare disjoint file sets: add a bunfig.toml `[test] '
132
+ + 'pathIgnorePatterns` entry excluding the playwright specs, or give them a suffix '
133
+ + 'bun does not claim (e.g. `*.e2e.ts` via playwright `testMatch`)',
134
+ ...base
135
+ };
136
+ }
137
+ /**
138
+ * Verify-child prompt lines for a collision. Empty for any non-collision status —
139
+ * the caller emits no block.
140
+ */
141
+ export function runnerGlobVerifyFindings(a) {
142
+ return a.status === 'collision' ? [a.detail] : [];
143
+ }
144
+ /**
145
+ * A plan-time contract line: the invariant, recorded so slices that add a runner
146
+ * inherit it instead of rediscovering the collision. Empty when not applicable.
147
+ */
148
+ export function runnerGlobContractLine(a) {
149
+ if (a.scanningScripts.length === 0 || a.otherScripts.length === 0)
150
+ return '';
151
+ return (`Test-runner file sets MUST be disjoint: \`${a.scanningScripts.join('`, `')}\` (bun test, `
152
+ + `scans \`*.test.*\`/\`*.spec.*\` project-wide) and \`${a.otherScripts.join('`, `')}\` `
153
+ + `(playwright) must not claim the same files — enforce via bunfig \`[test] `
154
+ + `pathIgnorePatterns\` or a playwright \`testMatch\` suffix bun does not scan.`);
155
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * script-escape — deterministic detection of CHECK SCRIPTS THAT CANNOT FAIL.
3
+ *
4
+ * The failure this closes (mx5 run 13, PROMPT 4 item 4): the shipped package.json
5
+ * declared, verbatim,
6
+ * "lint": "prettier … && eslint --fix … && (tsc --noEmit 2>&1 | grep -qv 'TS18003' || true)"
7
+ * The typecheck is neutered twice over — its output is piped into an INVERTED grep
8
+ * (so the status becomes "some line did not match", never tsc's verdict), and the
9
+ * whole group is closed with `|| true` (so the script exits 0 unconditionally). Every
10
+ * consumer of that script — the repo-health verify gate, the final integration gate,
11
+ * a human reading a green CI line — is reading a constant, not a measurement.
12
+ *
13
+ * It was harmless in run 13 only by luck: tsc happened to be clean (validated). The
14
+ * class is not harmless — this is the same defect as run-8's F2 skip-escape, moved
15
+ * one level out. findSkipEscapes (skip-escape.ts) scans a spec's own VERIFY block;
16
+ * nothing scanned the SCRIPT DEFINITIONS those VERIFY blocks then invoke by name, so
17
+ * `bun run lint` could be authored into a no-op and every gate would salute it.
18
+ *
19
+ * TWO SHAPES, both crisp, both scoped to CHECK-CLASS script names:
20
+ *
21
+ * A. ALWAYS-ZERO TAIL — the script's last command cannot fail (`… || true`,
22
+ * `… || :`, `… || exit 0`, `…; exit 0`, a trailing `|| echo` fallback). A
23
+ * shell script's status is its last command's status, so this is a proof, not
24
+ * a heuristic: the script exits 0 no matter what the checker found.
25
+ *
26
+ * B. INVERTED-GREP LAUNDERING — a checker piped into `grep -qv` / `grep -vq`.
27
+ * "Some line of the output does NOT match X" is never a checker's verdict; it
28
+ * is true of virtually any non-empty output, including a wall of errors.
29
+ *
30
+ * Deliberately NOT flagged, because each is legitimate and FP-measured against the
31
+ * real corpus (pi-task, aiz-server, aiz-client, gofer, mx5):
32
+ * - `|| exit 1` — a HARDENING, the opposite of an escape (aiz-server).
33
+ * - any `||`/pipe in a NON-check script (`clean`, `dev`, `start`, `copy-fonts`) —
34
+ * a teardown `rm -rf dist || true` is correct and common.
35
+ * - pipes into formatters/reporters (`| tap-spec`, `| tee`) — those propagate
36
+ * status or are presentational; only inverted grep is unambiguous.
37
+ * - a plain `| grep -q "expected"` — that is a real assertion on output.
38
+ */
39
+ /** One neutered check script. */
40
+ export interface ScriptEscapeFinding {
41
+ /** The script's name as declared (e.g. `lint`). */
42
+ name: string;
43
+ /** The script's body, verbatim. */
44
+ body: string;
45
+ /** Why it cannot fail (human- and prompt-readable). */
46
+ reason: string;
47
+ }
48
+ /**
49
+ * Judge one script definition. Returns null when the script is not check-class, or
50
+ * is check-class but can genuinely fail.
51
+ */
52
+ export declare function judgeScript(name: string, body: string): ScriptEscapeFinding | null;
53
+ /**
54
+ * Scan a parsed `scripts` map (package.json shape) for neutered check scripts.
55
+ * A non-object, or a map with no check-class scripts, yields no findings.
56
+ */
57
+ export declare function findScriptEscapes(scripts: unknown): ScriptEscapeFinding[];
58
+ /**
59
+ * Scan a package.json's TEXT. Invalid JSON yields no findings — a manifest that does
60
+ * not parse is a different problem, and guessing at its contents is how a scanner
61
+ * earns false positives.
62
+ */
63
+ export declare function findScriptEscapesInManifest(manifestText: string): ScriptEscapeFinding[];
64
+ /**
65
+ * Scan ARBITRARY text (a spec, a design doc) for script definitions written as JSON
66
+ * pairs — the form a spec uses when it dictates a script for the implementer to add
67
+ * (`"lint": "tsc --noEmit || true"`). This is what lets the critique catch the
68
+ * neutered script at SPEC time, before any task writes it into a manifest.
69
+ *
70
+ * Deliberately narrow: only `"name": "body"` pairs on one line, only check-class
71
+ * names. Prose describing a script in words extracts nothing.
72
+ */
73
+ export declare function findScriptEscapesInText(text: string): ScriptEscapeFinding[];
74
+ /**
75
+ * Verify-child prompt lines (probe+rule pattern): one per neutered script, naming
76
+ * the script, its body, and why it cannot fail.
77
+ */
78
+ export declare function scriptEscapeVerifyFindings(findings: ScriptEscapeFinding[]): string[];
79
+ /**
80
+ * Render findings as a critique/rewrite defect block — same shape as
81
+ * skipEscapeDefectText: a numbered list the rewrite must resolve.
82
+ */
83
+ export declare function scriptEscapeDefectText(findings: ScriptEscapeFinding[]): string;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * script-escape — deterministic detection of CHECK SCRIPTS THAT CANNOT FAIL.
3
+ *
4
+ * The failure this closes (mx5 run 13, PROMPT 4 item 4): the shipped package.json
5
+ * declared, verbatim,
6
+ * "lint": "prettier … && eslint --fix … && (tsc --noEmit 2>&1 | grep -qv 'TS18003' || true)"
7
+ * The typecheck is neutered twice over — its output is piped into an INVERTED grep
8
+ * (so the status becomes "some line did not match", never tsc's verdict), and the
9
+ * whole group is closed with `|| true` (so the script exits 0 unconditionally). Every
10
+ * consumer of that script — the repo-health verify gate, the final integration gate,
11
+ * a human reading a green CI line — is reading a constant, not a measurement.
12
+ *
13
+ * It was harmless in run 13 only by luck: tsc happened to be clean (validated). The
14
+ * class is not harmless — this is the same defect as run-8's F2 skip-escape, moved
15
+ * one level out. findSkipEscapes (skip-escape.ts) scans a spec's own VERIFY block;
16
+ * nothing scanned the SCRIPT DEFINITIONS those VERIFY blocks then invoke by name, so
17
+ * `bun run lint` could be authored into a no-op and every gate would salute it.
18
+ *
19
+ * TWO SHAPES, both crisp, both scoped to CHECK-CLASS script names:
20
+ *
21
+ * A. ALWAYS-ZERO TAIL — the script's last command cannot fail (`… || true`,
22
+ * `… || :`, `… || exit 0`, `…; exit 0`, a trailing `|| echo` fallback). A
23
+ * shell script's status is its last command's status, so this is a proof, not
24
+ * a heuristic: the script exits 0 no matter what the checker found.
25
+ *
26
+ * B. INVERTED-GREP LAUNDERING — a checker piped into `grep -qv` / `grep -vq`.
27
+ * "Some line of the output does NOT match X" is never a checker's verdict; it
28
+ * is true of virtually any non-empty output, including a wall of errors.
29
+ *
30
+ * Deliberately NOT flagged, because each is legitimate and FP-measured against the
31
+ * real corpus (pi-task, aiz-server, aiz-client, gofer, mx5):
32
+ * - `|| exit 1` — a HARDENING, the opposite of an escape (aiz-server).
33
+ * - any `||`/pipe in a NON-check script (`clean`, `dev`, `start`, `copy-fonts`) —
34
+ * a teardown `rm -rf dist || true` is correct and common.
35
+ * - pipes into formatters/reporters (`| tap-spec`, `| tee`) — those propagate
36
+ * status or are presentational; only inverted grep is unambiguous.
37
+ * - a plain `| grep -q "expected"` — that is a real assertion on output.
38
+ */
39
+ /**
40
+ * Script names whose whole purpose is to FAIL when something is wrong. A neutered
41
+ * `clean` or `dev` is nobody's business; a neutered `lint` silently disarms every
42
+ * gate that runs it. Suffixed variants (`test:ct`, `lint:fix`, `check-types`) match.
43
+ */
44
+ const CHECK_SCRIPT_RE = /^(?:lint|test|check|verify|validate|typecheck|types?|tsc|ci|audit|coverage|e2e|build|compile|fmt:check|format:check)(?:[:_-].*)?$/i;
45
+ /** `|| exit 1` and friends HARDEN a script — never mistake one for an escape. */
46
+ const HARDENING_TAIL_RE = /\|\|\s*exit\s+[1-9]\d*\s*$/;
47
+ /** Tails that make the script's exit status unconditionally 0. */
48
+ const ALWAYS_ZERO_TAILS = [
49
+ { re: /\|\|\s*true\s*$/, what: '`|| true`' },
50
+ { re: /\|\|\s*:\s*$/, what: '`|| :`' },
51
+ { re: /\|\|\s*exit\s+0\s*$/, what: '`|| exit 0`' },
52
+ { re: /;\s*true\s*$/, what: '`; true`' },
53
+ { re: /;\s*exit\s+0\s*$/, what: '`; exit 0`' },
54
+ { re: /\|\|\s*echo\b[^|&;]*$/, what: 'a trailing `|| echo …` fallback' }
55
+ ];
56
+ /** A checker piped into an INVERTED grep: status becomes "something didn't match". */
57
+ const INVERTED_GREP_RE = /\|\s*grep\s+(?:-\w*v\w*|-\w+\s+-\w*v\w*)/;
58
+ /**
59
+ * Strip trailing subshell/group closers and separators so the tail patterns see the
60
+ * real last command. mx5's script ends `… || true)` — without this the `)` hides it.
61
+ */
62
+ function tailOf(body) {
63
+ let s = body.trim();
64
+ while (true) {
65
+ const next = s.replace(/[)\s;]+$/, '').trimEnd();
66
+ if (next === s)
67
+ return s;
68
+ s = next;
69
+ }
70
+ }
71
+ /**
72
+ * Judge one script definition. Returns null when the script is not check-class, or
73
+ * is check-class but can genuinely fail.
74
+ */
75
+ export function judgeScript(name, body) {
76
+ if (!CHECK_SCRIPT_RE.test(name))
77
+ return null;
78
+ if (typeof body !== 'string' || body.trim().length === 0)
79
+ return null;
80
+ const reasons = [];
81
+ const tail = tailOf(body);
82
+ if (!HARDENING_TAIL_RE.test(tail)) {
83
+ for (const { re, what } of ALWAYS_ZERO_TAILS) {
84
+ if (re.test(tail)) {
85
+ reasons.push(`it ends in ${what}, so the script exits 0 whatever the check reports`);
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ if (INVERTED_GREP_RE.test(body)) {
91
+ reasons.push('it pipes a check into an INVERTED grep (`grep -v`), so the exit status becomes '
92
+ + '"some output line did not match" — true of almost any output, including errors');
93
+ }
94
+ if (reasons.length === 0)
95
+ return null;
96
+ return {
97
+ name,
98
+ body: body.trim(),
99
+ reason: reasons.join('; ')
100
+ };
101
+ }
102
+ /**
103
+ * Scan a parsed `scripts` map (package.json shape) for neutered check scripts.
104
+ * A non-object, or a map with no check-class scripts, yields no findings.
105
+ */
106
+ export function findScriptEscapes(scripts) {
107
+ if (typeof scripts !== 'object' || scripts === null || Array.isArray(scripts))
108
+ return [];
109
+ const out = [];
110
+ for (const [name, body] of Object.entries(scripts)) {
111
+ const f = judgeScript(name, typeof body === 'string' ? body : '');
112
+ if (f)
113
+ out.push(f);
114
+ }
115
+ return out;
116
+ }
117
+ /**
118
+ * Scan a package.json's TEXT. Invalid JSON yields no findings — a manifest that does
119
+ * not parse is a different problem, and guessing at its contents is how a scanner
120
+ * earns false positives.
121
+ */
122
+ export function findScriptEscapesInManifest(manifestText) {
123
+ try {
124
+ const parsed = JSON.parse(manifestText);
125
+ if (typeof parsed !== 'object' || parsed === null)
126
+ return [];
127
+ return findScriptEscapes(parsed.scripts);
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ }
133
+ /**
134
+ * Scan ARBITRARY text (a spec, a design doc) for script definitions written as JSON
135
+ * pairs — the form a spec uses when it dictates a script for the implementer to add
136
+ * (`"lint": "tsc --noEmit || true"`). This is what lets the critique catch the
137
+ * neutered script at SPEC time, before any task writes it into a manifest.
138
+ *
139
+ * Deliberately narrow: only `"name": "body"` pairs on one line, only check-class
140
+ * names. Prose describing a script in words extracts nothing.
141
+ */
142
+ export function findScriptEscapesInText(text) {
143
+ const out = [];
144
+ const seen = new Set();
145
+ for (const m of text.matchAll(/"([A-Za-z0-9:_-]{1,40})"\s*:\s*"((?:[^"\\]|\\.)*)"/g)) {
146
+ const name = m[1];
147
+ // Unescape the JSON string body so shell operators read normally.
148
+ let body;
149
+ try {
150
+ body = JSON.parse(`"${m[2]}"`);
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ const key = `${name}=${body}`;
156
+ if (seen.has(key))
157
+ continue;
158
+ const f = judgeScript(name, body);
159
+ if (!f)
160
+ continue;
161
+ seen.add(key);
162
+ out.push(f);
163
+ }
164
+ return out;
165
+ }
166
+ /**
167
+ * Verify-child prompt lines (probe+rule pattern): one per neutered script, naming
168
+ * the script, its body, and why it cannot fail.
169
+ */
170
+ export function scriptEscapeVerifyFindings(findings) {
171
+ return findings.map(f => `\`${f.name}\`: ${f.body} — ${f.reason}`);
172
+ }
173
+ /**
174
+ * Render findings as a critique/rewrite defect block — same shape as
175
+ * skipEscapeDefectText: a numbered list the rewrite must resolve.
176
+ */
177
+ export function scriptEscapeDefectText(findings) {
178
+ return [
179
+ 'NEUTERED CHECK SCRIPT — this spec defines a check script that CANNOT FAIL, so every',
180
+ 'gate that runs it reads a constant instead of a measurement (mx5 run 13 shipped',
181
+ '`"lint": "… && (tsc --noEmit 2>&1 | grep -qv \'TS18003\' || true)"` — the typecheck',
182
+ 'was fully disarmed, and it went unnoticed only because tsc happened to be clean).',
183
+ "Rewrite each so it PROPAGATES the checker's exit status: drop the `|| true` /",
184
+ '`; exit 0` tail, and never launder a checker through an inverted grep. If a',
185
+ 'specific diagnostic must genuinely be tolerated, suppress THAT diagnostic in the',
186
+ "checker's own config — never blanket-zero the whole script's exit code:",
187
+ ...findings.map((f, i) => ` ${i + 1}. "${f.name}": "${f.body}" — ${f.reason}`)
188
+ ].join('\n');
189
+ }
@@ -68,7 +68,20 @@ export declare function extractSpecForVerification(taskBody: string): string | n
68
68
  * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
69
69
  * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
70
70
  */
71
- export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[], skipEscapeFindings?: string[], contracts?: string, testAssemblyFindings?: string[], probeGamingFindings?: string[], crossTaskDeletionFindings?: string[]): string;
71
+ export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[], skipEscapeFindings?: string[], contracts?: string, testAssemblyFindings?: string[], probeGamingFindings?: string[], crossTaskDeletionFindings?: string[],
72
+ /**
73
+ * The mx5 run-13 (PROMPT 4) probes, grouped rather than appended as three more
74
+ * positional parameters — this signature was already at its limit. Each key is
75
+ * an independent finding list; absent/empty emits no block.
76
+ */
77
+ projectSurface?: {
78
+ /** Sandbox-leaked absolute paths (rule 4e) — see foreign-path.ts. */
79
+ foreignPaths?: string[];
80
+ /** Check scripts that cannot fail (rule 4f) — see script-escape.ts. */
81
+ scriptEscapes?: string[];
82
+ /** Colliding test-runner globs (rule 4g) — see runner-globs.ts. */
83
+ runnerGlobs?: string[];
84
+ }): string;
72
85
  /**
73
86
  * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL|UNOBSERVED`
74
87
  * marker (the model discusses before concluding, and bash output may echo the word
@@ -146,6 +159,31 @@ export interface VerificationDeps {
146
159
  * 0/5), and carried structurally on a FAIL outcome so an ACCEPT records each as
147
160
  * a durable debt. ABSENT or empty → no block. */
148
161
  crossTaskDeletionProbe?: () => Promise<CrossTaskDeletion[]>;
162
+ /**
163
+ * DETERMINISTIC sandbox-path-leak probe (see foreign-path.ts, mx5 run 13
164
+ * PROMPT 4 item 1): absolute paths this task committed that exist only inside
165
+ * the authoring child's own environment — `/workspace/src/shared` in a vite
166
+ * alias — while the real file sits at `src/shared` here. The probe REPAIRS
167
+ * what it can deterministically first; only leaks it could not repair reach
168
+ * this hook, injected under rule 4e (MANDATORY + verdict-gating, the same
169
+ * shape as 4d — a leak makes the affected command fail to BUILD, so the
170
+ * checks that would notice never run at all). ABSENT or empty → no block. */
171
+ foreignPathProbe?: () => Promise<string[]>;
172
+ /**
173
+ * DETERMINISTIC neutered-check-script probe (see script-escape.ts, mx5 run 13
174
+ * PROMPT 4 item 4): check-class scripts in a manifest THIS task changed whose
175
+ * exit status cannot be non-zero (`… || true`, an inverted-grep launder). The
176
+ * damage is second-order — the script still "passes", so the gates that run it
177
+ * report success without measuring anything — which is exactly why the child
178
+ * cannot discover it by running the check. ABSENT or empty → no block. */
179
+ scriptEscapeProbe?: () => Promise<string[]>;
180
+ /**
181
+ * DETERMINISTIC test-runner glob-collision probe (see runner-globs.ts, mx5 runs
182
+ * 7 AND 13, PROMPT 4 item 2): the manifest declares two runners whose file sets
183
+ * are not provably disjoint, so the scanning one imports the other's specs and
184
+ * dies during COLLECTION. Injected under rule 4g. UNKNOWN (one runner, or
185
+ * disjointness proven) yields nothing. ABSENT or empty → no block. */
186
+ runnerGlobProbe?: () => Promise<string[]>;
149
187
  /**
150
188
  * Result of the git-state guard for the MOST RECENT runChild call (see
151
189
  * git-state-guard.ts): did the child mutate repo state (stash/checkout/file