@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.
- package/dist/task/auto-orchestrator.d.ts +8 -0
- package/dist/task/auto-orchestrator.js +83 -10
- package/dist/task/final-gate-fix.d.ts +25 -0
- package/dist/task/final-gate-fix.js +33 -0
- package/dist/task/foreign-path.d.ts +96 -0
- package/dist/task/foreign-path.js +0 -0
- package/dist/task/gate-deps.js +141 -0
- package/dist/task/phases.js +29 -7
- package/dist/task/runner-globs.d.ts +74 -0
- package/dist/task/runner-globs.js +155 -0
- package/dist/task/script-escape.d.ts +83 -0
- package/dist/task/script-escape.js +189 -0
- package/dist/task/verify-work.d.ts +39 -1
- package/dist/task/verify-work.js +126 -2
- package/dist/workers/research-cache.d.ts +27 -0
- package/dist/workers/research-cache.js +94 -1
- package/package.json +1 -1
|
@@ -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[]
|
|
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
|