@mjasnikovs/pi-task 0.18.30 → 0.18.31
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 +74 -6
- 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/package.json +1 -1
|
@@ -45,6 +45,14 @@ export interface AutoDeps extends GateDeps {
|
|
|
45
45
|
* Leave-failed / Accept, exactly the pre-autofix behavior.
|
|
46
46
|
*/
|
|
47
47
|
finalGateFix?: FinalGateFixFn;
|
|
48
|
+
/**
|
|
49
|
+
* Paths currently uncommitted in the working tree (`git status` shape), used to
|
|
50
|
+
* detect SUB-FIXES a non-converging final-gate autofix left behind (mx5 run 13
|
|
51
|
+
* PROMPT 4 item 3). Every task is committed by the time the final gate runs, so
|
|
52
|
+
* anything dirty here is the fix pass's own work. Absent → the stranded-fix
|
|
53
|
+
* handling is skipped entirely (prior behavior).
|
|
54
|
+
*/
|
|
55
|
+
pendingChanges?: (cwd: string) => Promise<string[]>;
|
|
48
56
|
}
|
|
49
57
|
/**
|
|
50
58
|
* Expand any @file references in the feature text by appending each referenced
|
|
@@ -24,12 +24,12 @@ import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../rem
|
|
|
24
24
|
import { pushNotify } from '../remote/push.js';
|
|
25
25
|
import { startAutoLoader } from './widget.js';
|
|
26
26
|
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
27
|
-
import { buildGateDeps } from './gate-deps.js';
|
|
27
|
+
import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
|
|
28
28
|
import { runGatesForTask } from './task-gates.js';
|
|
29
29
|
import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
|
|
30
30
|
import { runFinalIntegrationGate } from './final-gate.js';
|
|
31
31
|
import { describeDebt } from './accept-debt.js';
|
|
32
|
-
import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE } from './final-gate-fix.js';
|
|
32
|
+
import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
|
|
33
33
|
import { getConfig } from '../config/config.js';
|
|
34
34
|
import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
|
|
35
35
|
import { configureResearchRun } from '../workers/research-cache.js';
|
|
@@ -904,7 +904,14 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
904
904
|
// run-level half of the same verification story.
|
|
905
905
|
finalGate: (cwd2, planText) => getConfig().verifyWork ?
|
|
906
906
|
runFinalIntegrationGate(cwd2, undefined, undefined, undefined, planText)
|
|
907
|
-
: Promise.resolve({ ok: true, reason: 'disabled' })
|
|
907
|
+
: Promise.resolve({ ok: true, reason: 'disabled' }),
|
|
908
|
+
// Uncommitted paths, for the stranded-sub-fix handling around the final-gate
|
|
909
|
+
// picker (mx5 run 13 PROMPT 4 item 3). Every task is committed by the time
|
|
910
|
+
// the gate runs, so whatever is dirty here belongs to the fix pass.
|
|
911
|
+
pendingChanges: async (cwd2) => {
|
|
912
|
+
const changes = await collectTreeChanges(cwd2, signal);
|
|
913
|
+
return [...changes.modified, ...changes.added, ...changes.deleted].sort();
|
|
914
|
+
}
|
|
908
915
|
};
|
|
909
916
|
}
|
|
910
917
|
// ─── Loop ────────────────────────────────────────────────────────────────────
|
|
@@ -1009,6 +1016,21 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1009
1016
|
// after MAX_FINAL_GATE_AUTOFIX attempts that still FAIL the
|
|
1010
1017
|
// autofix card is withdrawn so the loop cannot run unbounded.
|
|
1011
1018
|
let fixAttempts = 0;
|
|
1019
|
+
// Sub-fixes a non-converging autofix attempt left uncommitted.
|
|
1020
|
+
// Refreshed after every attempt; drives the picker note and the
|
|
1021
|
+
// accept-time commit (mx5 run 13 PROMPT 4 item 3).
|
|
1022
|
+
let stranded = [];
|
|
1023
|
+
const refreshStranded = async () => {
|
|
1024
|
+
if (!deps.pendingChanges)
|
|
1025
|
+
return;
|
|
1026
|
+
try {
|
|
1027
|
+
stranded = await deps.pendingChanges(cwd);
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
// Inconclusive: say nothing rather than claim a clean tree.
|
|
1031
|
+
stranded = [];
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1012
1034
|
while (!fin.ok) {
|
|
1013
1035
|
const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
|
|
1014
1036
|
// The picker question shows the debts (the HUMAN weighs them);
|
|
@@ -1019,7 +1041,11 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1019
1041
|
+ '(the project’s own test/build/static commands, run unaided).'
|
|
1020
1042
|
+ (fixAttempts > 0 ?
|
|
1021
1043
|
`\n\nAutofix attempts so far: ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}.`
|
|
1022
|
-
: '')
|
|
1044
|
+
: '')
|
|
1045
|
+
// Never let a partial repair be invisible at the moment
|
|
1046
|
+
// the human decides (run 13: a bunfig fix that made
|
|
1047
|
+
// `bun run test` pass 116/116 was stranded by an ACCEPT).
|
|
1048
|
+
+ strandedFixNote(stranded);
|
|
1023
1049
|
// YOLO: keep autofixing WHILE the card is still offered — the
|
|
1024
1050
|
// loop withdraws it after MAX_FINAL_GATE_AUTOFIX, so the cap
|
|
1025
1051
|
// that bounds a non-converging fix pass still bounds this —
|
|
@@ -1058,7 +1084,30 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1058
1084
|
const choice = classifyFinalGateAnswer(answer);
|
|
1059
1085
|
if (choice.action === 'accept') {
|
|
1060
1086
|
await recGate('final-gate: FAIL accepted by user');
|
|
1061
|
-
|
|
1087
|
+
// STRANDED SUB-FIXES: the run completes here, so anything
|
|
1088
|
+
// the fix pass repaired but never committed would be lost
|
|
1089
|
+
// to the next `git checkout` while HEAD keeps the defect
|
|
1090
|
+
// it fixed. Commit it as its own, named commit — the
|
|
1091
|
+
// ACCEPT is a decision about the FAILING gate, never an
|
|
1092
|
+
// instruction to throw away work (mx5 run 13 item 3).
|
|
1093
|
+
if (stranded.length > 0) {
|
|
1094
|
+
try {
|
|
1095
|
+
const sha = await deps.commit(cwd, STRANDED_FIX_COMMIT(id));
|
|
1096
|
+
await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
|
|
1097
|
+
+ `${sha ? ` as ${sha}` : ''} — ${stranded.slice(0, 8).join(', ')}`);
|
|
1098
|
+
}
|
|
1099
|
+
catch (err) {
|
|
1100
|
+
// Never break the completion path over this — but
|
|
1101
|
+
// say so, so the changes are not silently lost.
|
|
1102
|
+
await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
|
|
1103
|
+
+ `change(s) (${err instanceof Error ? err.message : String(err)}) — `
|
|
1104
|
+
+ `they remain UNCOMMITTED in the working tree: ${stranded.slice(0, 8).join(', ')}`);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`
|
|
1108
|
+
+ (stranded.length > 0 ?
|
|
1109
|
+
` ${stranded.length} uncommitted fix-pass change(s) committed separately.`
|
|
1110
|
+
: ''), 'warning');
|
|
1062
1111
|
break;
|
|
1063
1112
|
}
|
|
1064
1113
|
if (choice.action === 'autofix' && canAutofix) {
|
|
@@ -1077,6 +1126,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1077
1126
|
break;
|
|
1078
1127
|
}
|
|
1079
1128
|
await recGate(`final-gate: autofix attempt ${fixAttempts} failed — ${fix.reason.slice(0, 200)}`);
|
|
1129
|
+
// The attempt's edits survive a non-convergence (only a
|
|
1130
|
+
// guard trip discards). Find out what they are NOW, so
|
|
1131
|
+
// the next picker shows them and an ACCEPT can commit them.
|
|
1132
|
+
await refreshStranded();
|
|
1133
|
+
if (stranded.length > 0) {
|
|
1134
|
+
await recGate(`final-gate: autofix attempt ${fixAttempts} left ${stranded.length} `
|
|
1135
|
+
+ `uncommitted change(s) — ${stranded.slice(0, 8).join(', ')}`);
|
|
1136
|
+
}
|
|
1080
1137
|
active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
|
|
1081
1138
|
// Work from the FRESH gate failure when the fix pass got
|
|
1082
1139
|
// as far as re-running the gate; otherwise keep the last.
|
|
@@ -1102,8 +1159,19 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1102
1159
|
await recGate(yoloFinal !== null ?
|
|
1103
1160
|
`final-gate: left failed — autofix budget spent, nobody to ask ${YOLO_STAMP}`
|
|
1104
1161
|
: 'final-gate: left failed (user)');
|
|
1162
|
+
// Leaving the run failed hands the working tree back to the
|
|
1163
|
+
// user, so uncommitted fix-pass edits are theirs to keep or
|
|
1164
|
+
// drop — but they must be VISIBLE, not discovered later by a
|
|
1165
|
+
// stray `git status` (mx5 run 13 item 3).
|
|
1166
|
+
if (stranded.length > 0) {
|
|
1167
|
+
await recGate(`final-gate: ${stranded.length} uncommitted fix-pass change(s) left in the `
|
|
1168
|
+
+ `working tree — ${stranded.slice(0, 8).join(', ')}`);
|
|
1169
|
+
}
|
|
1105
1170
|
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
1106
|
-
announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs)
|
|
1171
|
+
announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`
|
|
1172
|
+
+ (stranded.length > 0 ?
|
|
1173
|
+
` NOTE: ${stranded.length} uncommitted fix-pass change(s) are in your working tree (${stranded.slice(0, 4).join(', ')}).`
|
|
1174
|
+
: ''), 'error');
|
|
1107
1175
|
return;
|
|
1108
1176
|
}
|
|
1109
1177
|
}
|
|
@@ -58,6 +58,31 @@ export declare function parseFinalFixMarker(text: string): {
|
|
|
58
58
|
blocked: boolean;
|
|
59
59
|
note?: string;
|
|
60
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* STRANDED SUB-FIXES (mx5 run 13, PROMPT 4 item 3).
|
|
63
|
+
*
|
|
64
|
+
* A fix attempt that does not converge keeps its edits: they are NOT discarded
|
|
65
|
+
* (only a guard trip discards), and `deps.commit` runs only on `fix.ok`. So a
|
|
66
|
+
* partial fix that genuinely repaired something sits in the working tree, uncommitted,
|
|
67
|
+
* and if the user then ACCEPTs the FAIL the run completes around it — leaving HEAD
|
|
68
|
+
* broken while the repair is invisible unless someone runs `git status`.
|
|
69
|
+
*
|
|
70
|
+
* That is exactly what run 13 shipped: the fix child's bunfig.toml change made
|
|
71
|
+
* `bun run test` pass 116/116, attempt 1 did not converge overall, the user accepted
|
|
72
|
+
* the FAIL, and the tree still shows the file modified while HEAD's `bun run test` is
|
|
73
|
+
* broken. The repair and the breakage were BOTH real; only the repair was discarded
|
|
74
|
+
* by default.
|
|
75
|
+
*
|
|
76
|
+
* The rule: a partial fix is either committed (its own commit, named in the trail) or
|
|
77
|
+
* explicitly surfaced — never silently stranded.
|
|
78
|
+
*/
|
|
79
|
+
/** Commit subject for partial fixes committed alongside an accepted gate FAIL. */
|
|
80
|
+
export declare const STRANDED_FIX_COMMIT: (runId: string) => string;
|
|
81
|
+
/**
|
|
82
|
+
* The picker/trail line describing what a non-converging fix pass left behind.
|
|
83
|
+
* Empty string when the tree is clean — the caller then says nothing at all.
|
|
84
|
+
*/
|
|
85
|
+
export declare function strandedFixNote(paths: string[]): string;
|
|
61
86
|
export interface FinalFixResult {
|
|
62
87
|
/** true → the fix child ran AND the re-run gate passed. */
|
|
63
88
|
ok: boolean;
|
|
@@ -161,6 +161,39 @@ export function parseFinalFixMarker(text) {
|
|
|
161
161
|
}
|
|
162
162
|
return { blocked: false, note: last[2].trim() || undefined };
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* STRANDED SUB-FIXES (mx5 run 13, PROMPT 4 item 3).
|
|
166
|
+
*
|
|
167
|
+
* A fix attempt that does not converge keeps its edits: they are NOT discarded
|
|
168
|
+
* (only a guard trip discards), and `deps.commit` runs only on `fix.ok`. So a
|
|
169
|
+
* partial fix that genuinely repaired something sits in the working tree, uncommitted,
|
|
170
|
+
* and if the user then ACCEPTs the FAIL the run completes around it — leaving HEAD
|
|
171
|
+
* broken while the repair is invisible unless someone runs `git status`.
|
|
172
|
+
*
|
|
173
|
+
* That is exactly what run 13 shipped: the fix child's bunfig.toml change made
|
|
174
|
+
* `bun run test` pass 116/116, attempt 1 did not converge overall, the user accepted
|
|
175
|
+
* the FAIL, and the tree still shows the file modified while HEAD's `bun run test` is
|
|
176
|
+
* broken. The repair and the breakage were BOTH real; only the repair was discarded
|
|
177
|
+
* by default.
|
|
178
|
+
*
|
|
179
|
+
* The rule: a partial fix is either committed (its own commit, named in the trail) or
|
|
180
|
+
* explicitly surfaced — never silently stranded.
|
|
181
|
+
*/
|
|
182
|
+
/** Commit subject for partial fixes committed alongside an accepted gate FAIL. */
|
|
183
|
+
export const STRANDED_FIX_COMMIT = (runId) => `FINAL GATE PARTIAL FIX (${runId}) — accepted with gate still failing`;
|
|
184
|
+
/**
|
|
185
|
+
* The picker/trail line describing what a non-converging fix pass left behind.
|
|
186
|
+
* Empty string when the tree is clean — the caller then says nothing at all.
|
|
187
|
+
*/
|
|
188
|
+
export function strandedFixNote(paths) {
|
|
189
|
+
if (paths.length === 0)
|
|
190
|
+
return '';
|
|
191
|
+
const shown = paths.slice(0, 8).join(', ');
|
|
192
|
+
return (`\n\nUNCOMMITTED: the fix pass left ${paths.length} change(s) in the working tree `
|
|
193
|
+
+ `(${shown}${paths.length > 8 ? ', …' : ''}). These are NOT in HEAD. Accepting will `
|
|
194
|
+
+ `commit them as their own commit so they are not lost; leaving the run failed keeps `
|
|
195
|
+
+ `them in your working tree.`);
|
|
196
|
+
}
|
|
164
197
|
/**
|
|
165
198
|
* Run one bounded final-gate fix attempt: snapshot discovery → child → write-guard
|
|
166
199
|
* stack (diff capture → frozen-path revert → deletion guard → shrink guard → probe
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AddedLine } from './probe-gaming.js';
|
|
2
|
+
/** One leaked absolute path found in a committed file. */
|
|
3
|
+
export interface ForeignPathFinding {
|
|
4
|
+
/** Repo-relative file the leaked path was committed into. */
|
|
5
|
+
file: string;
|
|
6
|
+
/** The absolute path exactly as written in the file. */
|
|
7
|
+
absolute: string;
|
|
8
|
+
/** The repo-relative path its tail resolves to (the repair target). */
|
|
9
|
+
repoPath: string;
|
|
10
|
+
/** The leading segments that are foreign to this host (e.g. `/workspace`). */
|
|
11
|
+
foreignPrefix: string;
|
|
12
|
+
/** The verbatim line carrying the leak, trimmed. */
|
|
13
|
+
line: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a repo-relative path to the real thing it names, honouring the
|
|
17
|
+
* extensionless module specifiers config files use (`src/client/api` →
|
|
18
|
+
* `src/client/api.ts`). Returns the resolved repo-relative path, or null.
|
|
19
|
+
*
|
|
20
|
+
* This mirrors how the tools that CONSUME these paths (vite/tsconfig aliases,
|
|
21
|
+
* bundler resolvers) look them up — without it the mx5 true positive
|
|
22
|
+
* `/workspace/src/client/api` would be missed, since only `src/client/api.ts` exists.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveRepoPath(rel: string, exists: (rel: string) => boolean): string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Scan a task's added lines for sandbox-leaked absolute paths.
|
|
27
|
+
*
|
|
28
|
+
* `existsOnHost` answers for an ABSOLUTE path; `existsInRepo` for a REPO-RELATIVE
|
|
29
|
+
* one. Both are injected so the detector is pure and unit-testable against a
|
|
30
|
+
* synthetic tree.
|
|
31
|
+
*
|
|
32
|
+
* The tail search strips the FEWEST leading segments that still resolve, so the
|
|
33
|
+
* finding names the smallest foreign prefix (`/workspace`, not `/workspace/src`)
|
|
34
|
+
* and the largest repo-relative target — the reading that matches how a mount
|
|
35
|
+
* actually shadows a tree.
|
|
36
|
+
*/
|
|
37
|
+
export declare function findForeignPaths(lines: AddedLine[], existsOnHost: (abs: string) => boolean, existsInRepo: (rel: string) => boolean): ForeignPathFinding[];
|
|
38
|
+
/**
|
|
39
|
+
* The repair for one finding: the leaked absolute path rewritten relative to the
|
|
40
|
+
* FILE that carries it, in the `./`-prefixed form config resolvers expect. A target
|
|
41
|
+
* in a parent directory keeps its `../` form (already relative, no prefix needed).
|
|
42
|
+
*/
|
|
43
|
+
export declare function relativeRepairFor(finding: ForeignPathFinding): string;
|
|
44
|
+
/**
|
|
45
|
+
* Apply every finding for ONE file to that file's text, as literal substitutions.
|
|
46
|
+
* Returns the new text and the substitutions made; `text` is returned untouched
|
|
47
|
+
* when nothing applied (the caller then writes nothing).
|
|
48
|
+
*
|
|
49
|
+
* Deliberately a plain string replacement of a path literal for a path literal: it
|
|
50
|
+
* cannot reflow, reformat, or restructure the file, so a rewrite that turns out to
|
|
51
|
+
* be semantically wrong is still trivially reviewable in the diff. A finding whose
|
|
52
|
+
* absolute string is no longer present (the file moved on) is skipped.
|
|
53
|
+
*/
|
|
54
|
+
export declare function applyForeignPathRepairs(text: string, findings: ForeignPathFinding[]): {
|
|
55
|
+
text: string;
|
|
56
|
+
applied: Array<{
|
|
57
|
+
from: string;
|
|
58
|
+
to: string;
|
|
59
|
+
}>;
|
|
60
|
+
};
|
|
61
|
+
/** IO seam for the repair pass; injected so the orchestration is unit-testable. */
|
|
62
|
+
export interface ForeignPathRepairIO {
|
|
63
|
+
readFile: (rel: string) => Promise<string>;
|
|
64
|
+
writeFile: (rel: string, text: string) => Promise<void>;
|
|
65
|
+
/** Does this repo-relative path exist? Used to RE-VALIDATE each repair. */
|
|
66
|
+
existsInRepo: (rel: string) => boolean;
|
|
67
|
+
}
|
|
68
|
+
export interface ForeignPathRepairResult {
|
|
69
|
+
/** Human-readable repairs actually written, e.g. `cfg.ts: /workspace/src → ./src`. */
|
|
70
|
+
repaired: string[];
|
|
71
|
+
/** Findings left alone — these become the verify finding. */
|
|
72
|
+
remaining: ForeignPathFinding[];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Deterministically repair what can be repaired, and hand back the rest.
|
|
76
|
+
*
|
|
77
|
+
* Every repair is RE-VALIDATED before it is written: the file-relative form is
|
|
78
|
+
* resolved back to a repo path and must land on the same real file the finding
|
|
79
|
+
* named. A repair that does not re-resolve is dropped and its finding stays in
|
|
80
|
+
* `remaining`, where the verify block will raise it for a human or an AUTOFIX
|
|
81
|
+
* round. An unreadable or unwritable file does the same. Nothing here can turn a
|
|
82
|
+
* working path into a broken one — the only edit it makes is swapping a path that
|
|
83
|
+
* provably does not resolve for one that provably does.
|
|
84
|
+
*/
|
|
85
|
+
export declare function repairForeignPaths(findings: ForeignPathFinding[], io: ForeignPathRepairIO): Promise<ForeignPathRepairResult>;
|
|
86
|
+
/**
|
|
87
|
+
* Verify-child prompt lines: one per leak, naming the file, the leaked path, and
|
|
88
|
+
* the real repo path it shadows. Empty findings → empty array (caller emits no
|
|
89
|
+
* block), matching every other probe's contract.
|
|
90
|
+
*/
|
|
91
|
+
export declare function foreignPathVerifyFindings(findings: ForeignPathFinding[]): string[];
|
|
92
|
+
/**
|
|
93
|
+
* Render findings as a critique/enforce defect block — the same shape as
|
|
94
|
+
* skipEscapeDefectText: a numbered list the rewrite must resolve.
|
|
95
|
+
*/
|
|
96
|
+
export declare function foreignPathDefectText(findings: ForeignPathFinding[]): string;
|
|
Binary file
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* path-revisit disabled because re-running the same check IS the job), each with a
|
|
14
14
|
* status widget and a per-gate debug log under .pi-tasks/.
|
|
15
15
|
*/
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
16
17
|
import * as fsp from 'node:fs/promises';
|
|
17
18
|
import * as path from 'node:path';
|
|
18
19
|
import { tasksDir, readTaskFile, appendGateRecord } from './task-io.js';
|
|
@@ -34,6 +35,9 @@ import { parseTreeChanges, parseNameStatusChanges, formatTreeChanges } from './w
|
|
|
34
35
|
import { taskThatIntroduced, findCrossTaskDeletions } from './task-provenance.js';
|
|
35
36
|
import { findTestRebuiltAssemblies, testAssemblyVerifyFindings } from './test-assembly.js';
|
|
36
37
|
import { runBoundedLintFix } from './lint-fix.js';
|
|
38
|
+
import { findForeignPaths, foreignPathVerifyFindings, repairForeignPaths } from './foreign-path.js';
|
|
39
|
+
import { findScriptEscapesInManifest, scriptEscapeVerifyFindings } from './script-escape.js';
|
|
40
|
+
import { assessRunnerGlobs, runnerGlobVerifyFindings } from './runner-globs.js';
|
|
37
41
|
import { captureGitState, reconcileGitState } from './git-state-guard.js';
|
|
38
42
|
import { runWorker } from '../workers/pi-worker-core.js';
|
|
39
43
|
import { formatLoopHint } from './child-runner.js';
|
|
@@ -123,6 +127,121 @@ export async function collectAddedLines(cwd, signal) {
|
|
|
123
127
|
}
|
|
124
128
|
return lines;
|
|
125
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Deterministic sandbox-path-leak pass (see foreign-path.ts, mx5 run 13 PROMPT 4
|
|
132
|
+
* item 1): find absolute paths the task committed that resolve nowhere on this
|
|
133
|
+
* machine while the real file sits in the repo, REPAIR the ones whose relative
|
|
134
|
+
* form provably resolves, and return verify findings for whatever is left.
|
|
135
|
+
*
|
|
136
|
+
* The repair runs here, before the verify child, for the same reason lint-fix
|
|
137
|
+
* does: the defect is mechanical and the correct target is already known, so
|
|
138
|
+
* spending an AUTOFIX round (or a human) on a path substitution is waste. What it
|
|
139
|
+
* cannot repair still reaches the child under rule 4e. Failures degrade to no
|
|
140
|
+
* findings — a sharpener, never a blocker.
|
|
141
|
+
*/
|
|
142
|
+
async function collectForeignPathFindings(cwd, signal, logDebug) {
|
|
143
|
+
const lines = await collectAddedLines(cwd, signal);
|
|
144
|
+
if (lines.length === 0)
|
|
145
|
+
return [];
|
|
146
|
+
const findings = findForeignPaths(lines, abs => existsSync(abs), rel => existsSync(path.join(cwd, rel)));
|
|
147
|
+
if (findings.length === 0)
|
|
148
|
+
return [];
|
|
149
|
+
const { repaired, remaining } = await repairForeignPaths(findings, {
|
|
150
|
+
readFile: rel => fsp.readFile(path.join(cwd, rel), 'utf8'),
|
|
151
|
+
writeFile: (rel, text) => fsp.writeFile(path.join(cwd, rel), text, 'utf8'),
|
|
152
|
+
existsInRepo: rel => existsSync(path.join(cwd, rel))
|
|
153
|
+
});
|
|
154
|
+
for (const r of repaired)
|
|
155
|
+
logDebug?.(`sandbox path leak repaired — ${r}`);
|
|
156
|
+
for (const f of remaining) {
|
|
157
|
+
logDebug?.(`sandbox path leak NOT repaired — ${f.file}: ${f.absolute}`);
|
|
158
|
+
}
|
|
159
|
+
return foreignPathVerifyFindings(remaining);
|
|
160
|
+
}
|
|
161
|
+
/** Manifests whose `scripts` the check-script scanner understands. */
|
|
162
|
+
const MANIFEST_RE = /(^|\/)package\.json$/;
|
|
163
|
+
/**
|
|
164
|
+
* Deterministic neutered-check-script pass (see script-escape.ts, mx5 run 13 PROMPT
|
|
165
|
+
* 4 item 4): check-class scripts that cannot report failure, in a manifest THIS
|
|
166
|
+
* task changed.
|
|
167
|
+
*
|
|
168
|
+
* Scoped to manifests the task touched, so the finding lands on the task that
|
|
169
|
+
* authored the script rather than being re-served to every later task. A script
|
|
170
|
+
* neutered by an earlier task is the whole-repo final gate's business, which
|
|
171
|
+
* re-checks the shipped manifest at run end regardless of who wrote it.
|
|
172
|
+
*
|
|
173
|
+
* Failures degrade to no findings — a sharpener, never a blocker.
|
|
174
|
+
*/
|
|
175
|
+
async function collectScriptEscapeFindings(cwd, signal) {
|
|
176
|
+
const changed = await collectChangedFiles(cwd, signal);
|
|
177
|
+
const manifests = changed.map(f => f.path).filter(p => MANIFEST_RE.test(p));
|
|
178
|
+
const findings = [];
|
|
179
|
+
for (const rel of manifests) {
|
|
180
|
+
try {
|
|
181
|
+
findings.push(...findScriptEscapesInManifest(await fsp.readFile(path.join(cwd, rel), 'utf8')));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// unreadable/absent manifest — nothing to report
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return scriptEscapeVerifyFindings(findings);
|
|
188
|
+
}
|
|
189
|
+
/** Playwright config filenames, in the order playwright itself resolves them. */
|
|
190
|
+
const PLAYWRIGHT_CONFIGS = [
|
|
191
|
+
'playwright.config.ts',
|
|
192
|
+
'playwright.config.js',
|
|
193
|
+
'playwright.config.mts',
|
|
194
|
+
'playwright-ct.config.ts',
|
|
195
|
+
'playwright-ct.config.js'
|
|
196
|
+
];
|
|
197
|
+
/** Read a repo file as text, or null when absent/unreadable. */
|
|
198
|
+
async function readOrNull(cwd, rel) {
|
|
199
|
+
try {
|
|
200
|
+
return await fsp.readFile(path.join(cwd, rel), 'utf8');
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Deterministic test-runner glob-collision pass (see runner-globs.ts, mx5 runs 7 AND
|
|
208
|
+
* 13, PROMPT 4 item 2): the manifest declares both `bun test` and `playwright test`
|
|
209
|
+
* without a provably disjoint file set, so `bun test` imports the playwright specs
|
|
210
|
+
* and dies during collection.
|
|
211
|
+
*
|
|
212
|
+
* Whole-repo rather than diff-scoped, unlike the neutered-script probe: a collision
|
|
213
|
+
* is a property of the PAIR of declarations, and the task that completes the pair is
|
|
214
|
+
* rarely the one that will be blamed by a diff. It is cheap (two small file reads)
|
|
215
|
+
* and silent unless both runners are actually declared. Failures degrade to no
|
|
216
|
+
* findings — a sharpener, never a blocker.
|
|
217
|
+
*/
|
|
218
|
+
async function collectRunnerGlobFindings(cwd) {
|
|
219
|
+
const manifestText = await readOrNull(cwd, 'package.json');
|
|
220
|
+
if (manifestText === null)
|
|
221
|
+
return [];
|
|
222
|
+
let scripts;
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(manifestText);
|
|
225
|
+
const raw = parsed?.scripts;
|
|
226
|
+
if (typeof raw !== 'object' || raw === null)
|
|
227
|
+
return [];
|
|
228
|
+
scripts = raw;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
let playwrightConfig = null;
|
|
234
|
+
for (const name of PLAYWRIGHT_CONFIGS) {
|
|
235
|
+
playwrightConfig = await readOrNull(cwd, name);
|
|
236
|
+
if (playwrightConfig !== null)
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
return runnerGlobVerifyFindings(assessRunnerGlobs({
|
|
240
|
+
scripts,
|
|
241
|
+
bunfig: await readOrNull(cwd, 'bunfig.toml'),
|
|
242
|
+
playwrightConfig
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
126
245
|
/**
|
|
127
246
|
* The working tree's current changes as a summary (write-guard shape): what a
|
|
128
247
|
* write-capable gate child changed, given the tree was clean when it started.
|
|
@@ -497,6 +616,28 @@ export function buildGateDeps(params) {
|
|
|
497
616
|
// destroyed (typically to green a check). Injected under rule 4d and
|
|
498
617
|
// carried on a FAIL so an ACCEPT records durable debts.
|
|
499
618
|
crossTaskDeletionProbe: () => collectTaskTreeChanges(cwd2, signal).then(changes => findCrossTaskDeletions(changes, taskId, rel => taskThatIntroduced(cwd2, rel))),
|
|
619
|
+
// Deterministic sandbox-path-leak probe (mx5 run 13 PROMPT 4 item
|
|
620
|
+
// 1): absolute paths committed from the authoring child's own
|
|
621
|
+
// environment (`/workspace/src/shared`) that resolve nowhere here.
|
|
622
|
+
// Repaired deterministically where the relative form provably
|
|
623
|
+
// resolves; the remainder is injected under rule 4e, whose point is
|
|
624
|
+
// that such a path breaks the BUILD — so the checks that would have
|
|
625
|
+
// caught it report nothing rather than failing.
|
|
626
|
+
foreignPathProbe: () => collectForeignPathFindings(cwd2, signal, msg => void fsp
|
|
627
|
+
.appendFile(path.join(tasksDir(cwd2), 'verify-debug.log'), `${new Date().toISOString()} ${msg}\n`)
|
|
628
|
+
.catch(() => { })),
|
|
629
|
+
// Deterministic neutered-check-script probe (mx5 run 13 PROMPT 4
|
|
630
|
+
// item 4): a check script this task authored that cannot fail
|
|
631
|
+
// (`… || true`, an inverted-grep launder). Injected under rule 4f,
|
|
632
|
+
// because the child provably cannot find this by running the
|
|
633
|
+
// script — it passes, which IS the defect.
|
|
634
|
+
scriptEscapeProbe: () => collectScriptEscapeFindings(cwd2, signal),
|
|
635
|
+
// Deterministic runner glob-collision probe (mx5 runs 7 AND 13,
|
|
636
|
+
// PROMPT 4 item 2): both `bun test` and `playwright test` declared
|
|
637
|
+
// with no proof their file sets are disjoint. Injected under rule
|
|
638
|
+
// 4g — the collision kills the suite during COLLECTION, which does
|
|
639
|
+
// not look like a test failure.
|
|
640
|
+
runnerGlobProbe: () => collectRunnerGlobFindings(cwd2),
|
|
500
641
|
// Deterministic prohibition probe: paths the spec forbids modifying
|
|
501
642
|
// that the task's diff modified anyway become prompt-level findings
|
|
502
643
|
// under the no-waiver rule — the child otherwise rarely runs `git
|
package/dist/task/phases.js
CHANGED
|
@@ -27,6 +27,7 @@ import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyTool
|
|
|
27
27
|
import { compressTitle } from './title-label.js';
|
|
28
28
|
import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
|
|
29
29
|
import { findSkipEscapes, skipEscapeDefectText } from './skip-escape.js';
|
|
30
|
+
import { findScriptEscapesInText, scriptEscapeDefectText } from './script-escape.js';
|
|
30
31
|
import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
|
|
31
32
|
import { findAbsenceConflicts, absenceProbeText, siblingTitlesFromPlanContext } from './verify-reconcile.js';
|
|
32
33
|
import { findFrozenPathConflicts, frozenConflictProbeText } from './frozen-conflict.js';
|
|
@@ -939,6 +940,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
939
940
|
if (grepOnlyProbe) {
|
|
940
941
|
deps.logDebug?.('grep-theater VERIFY flagged in spec: ' + grepOnly.map(f => f.target).join(' | '));
|
|
941
942
|
}
|
|
943
|
+
// DETERMINISTIC neutered-check-script probe (mx5 run 13, PROMPT 4 item 4): a
|
|
944
|
+
// spec that DICTATES a check script which cannot fail — `"lint": "… || true"`,
|
|
945
|
+
// or a checker laundered through an inverted grep. Whatever task implements
|
|
946
|
+
// that spec writes the disarmed script into package.json, and from then on
|
|
947
|
+
// every gate that runs it (repo-health verify, the final integration gate)
|
|
948
|
+
// reads a constant. Cheapest to kill here, in the spec, before it is authored.
|
|
949
|
+
const scriptEscapes = findScriptEscapesInText(spec);
|
|
950
|
+
const scriptProbe = scriptEscapes.length > 0 ? scriptEscapeDefectText(scriptEscapes) : null;
|
|
951
|
+
if (scriptProbe) {
|
|
952
|
+
deps.logDebug?.('neutered check script dictated by spec: ' + scriptEscapes.map(f => f.name).join(' | '));
|
|
953
|
+
}
|
|
942
954
|
let triageDefects = null;
|
|
943
955
|
if (parseVerifyBlock(spec) !== null) {
|
|
944
956
|
const tTriage = Date.now();
|
|
@@ -956,15 +968,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
956
968
|
deps.recordSubStep?.('triage', Date.now() - tTriage);
|
|
957
969
|
if (verdict !== null) {
|
|
958
970
|
// A deterministic skip-escape, synthesized-wiring, plan-contradiction,
|
|
959
|
-
// unsatisfiable-pair,
|
|
960
|
-
//
|
|
961
|
-
//
|
|
971
|
+
// unsatisfiable-pair, grep-theater, or neutered-check-script finding
|
|
972
|
+
// overrides a CLEAN triage: the draft must be rewritten to resolve it
|
|
973
|
+
// even if the model judged the rest clean (the model does not
|
|
974
|
+
// self-discover any of them reliably).
|
|
962
975
|
if (isCritiqueClean(verdict)) {
|
|
963
976
|
if (skipDefects === null
|
|
964
977
|
&& wiringProbe === null
|
|
965
978
|
&& absenceProbe === null
|
|
966
979
|
&& frozenProbe === null
|
|
967
|
-
&& grepOnlyProbe === null
|
|
980
|
+
&& grepOnlyProbe === null
|
|
981
|
+
&& scriptProbe === null) {
|
|
968
982
|
return spec;
|
|
969
983
|
}
|
|
970
984
|
}
|
|
@@ -974,9 +988,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
974
988
|
}
|
|
975
989
|
}
|
|
976
990
|
// Merge the deterministic skip-escape + synthesized-wiring + plan-contradiction
|
|
977
|
-
// + unsatisfiable-pair + grep-theater defects with any triage
|
|
978
|
-
// rewrite (all are forced FOCUS items).
|
|
979
|
-
const rewriteDefects = [
|
|
991
|
+
// + unsatisfiable-pair + grep-theater + neutered-script defects with any triage
|
|
992
|
+
// defects for the rewrite (all are forced FOCUS items).
|
|
993
|
+
const rewriteDefects = [
|
|
994
|
+
skipDefects,
|
|
995
|
+
wiringProbe,
|
|
996
|
+
absenceProbe,
|
|
997
|
+
frozenProbe,
|
|
998
|
+
grepOnlyProbe,
|
|
999
|
+
scriptProbe,
|
|
1000
|
+
triageDefects
|
|
1001
|
+
]
|
|
980
1002
|
.filter(Boolean)
|
|
981
1003
|
.join('\n\n') || null;
|
|
982
1004
|
const tRewrite = Date.now();
|
|
@@ -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
|
package/dist/task/verify-work.js
CHANGED
|
@@ -140,7 +140,14 @@ export function extractSpecForVerification(taskBody) {
|
|
|
140
140
|
* Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
|
|
141
141
|
* paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
|
|
142
142
|
*/
|
|
143
|
-
export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts, testAssemblyFindings, probeGamingFindings, crossTaskDeletionFindings
|
|
143
|
+
export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts, testAssemblyFindings, probeGamingFindings, crossTaskDeletionFindings,
|
|
144
|
+
/**
|
|
145
|
+
* The mx5 run-13 (PROMPT 4) probes, grouped rather than appended as three more
|
|
146
|
+
* positional parameters — this signature was already at its limit. Each key is
|
|
147
|
+
* an independent finding list; absent/empty emits no block.
|
|
148
|
+
*/
|
|
149
|
+
projectSurface = {}) {
|
|
150
|
+
const { foreignPaths: foreignPathFindings, scriptEscapes: scriptEscapeFindings, runnerGlobs: runnerGlobFindings } = projectSurface;
|
|
144
151
|
const probeBlock = probeFindings && probeFindings.length > 0 ?
|
|
145
152
|
[
|
|
146
153
|
'SELF-VERIFICATION NOTICE (deterministic, computed by the orchestrator from the diff):',
|
|
@@ -216,6 +223,57 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
|
|
|
216
223
|
''
|
|
217
224
|
]
|
|
218
225
|
: [];
|
|
226
|
+
const foreignPathBlock = foreignPathFindings && foreignPathFindings.length > 0 ?
|
|
227
|
+
[
|
|
228
|
+
'SANDBOX PATH LEAK NOTICE (deterministic, computed by the orchestrator by',
|
|
229
|
+
"resolving every absolute path in the task's diff against THIS machine): this",
|
|
230
|
+
'task committed absolute paths that do not exist here, while the real file they',
|
|
231
|
+
'name sits inside this repo:',
|
|
232
|
+
...foreignPathFindings.map(f => `- ${f}`),
|
|
233
|
+
"These are paths from the authoring agent's OWN environment, baked into a file",
|
|
234
|
+
'that ships. The command that reads such a path does not fail a check — it fails',
|
|
235
|
+
'to BUILD, so the checks that would have caught it never run and report nothing',
|
|
236
|
+
'(mx5 run 13: a leaked `/workspace` vite alias made `test:ct` collect 63 tests',
|
|
237
|
+
'and run 0, and the suite stayed dead for the rest of the run). A green or',
|
|
238
|
+
'EMPTY result from any command that reads these files is therefore NOT evidence.',
|
|
239
|
+
'Run the affected command yourself and confirm it actually EXECUTES work — count',
|
|
240
|
+
'the tests/steps that ran, not the exit code. Unless the leaked path resolves on',
|
|
241
|
+
'this machine, the verdict is FAIL naming the file and the path (rule 4e).',
|
|
242
|
+
''
|
|
243
|
+
]
|
|
244
|
+
: [];
|
|
245
|
+
const scriptEscapeBlock = scriptEscapeFindings && scriptEscapeFindings.length > 0 ?
|
|
246
|
+
[
|
|
247
|
+
'NEUTERED CHECK SCRIPT NOTICE (deterministic, computed by the orchestrator from',
|
|
248
|
+
'the manifest THIS task changed): these check scripts cannot report failure —',
|
|
249
|
+
'their exit status is 0 no matter what the checker finds:',
|
|
250
|
+
...scriptEscapeFindings.map(f => `- ${f}`),
|
|
251
|
+
'You CANNOT discover this by running the script: it passes, which is the whole',
|
|
252
|
+
'defect (mx5 run 13 shipped a `lint` whose typecheck was disarmed by an inverted',
|
|
253
|
+
'grep and a `|| true` tail; every gate that ran it reported success without',
|
|
254
|
+
'measuring anything). A green result from one of these scripts is NOT evidence',
|
|
255
|
+
'for any acceptance criterion. To judge the area it claims to cover, run the',
|
|
256
|
+
'underlying checker DIRECTLY and unmodified (e.g. `tsc --noEmit` rather than',
|
|
257
|
+
'`npm run lint`) and judge THAT output. Unless the task spec explicitly requires',
|
|
258
|
+
'the script to tolerate failure, the verdict is FAIL naming the script (rule 4f).',
|
|
259
|
+
''
|
|
260
|
+
]
|
|
261
|
+
: [];
|
|
262
|
+
const runnerGlobBlock = runnerGlobFindings && runnerGlobFindings.length > 0 ?
|
|
263
|
+
[
|
|
264
|
+
'TEST-RUNNER GLOB COLLISION NOTICE (deterministic, computed by the orchestrator',
|
|
265
|
+
"from the manifest's declared runners and their config): two test runners claim",
|
|
266
|
+
'the same files:',
|
|
267
|
+
...runnerGlobFindings.map(f => `- ${f}`),
|
|
268
|
+
"The scanning runner will import the other's spec files and abort on a module",
|
|
269
|
+
'loaded outside its own runner — so the suite dies WHOLESALE rather than',
|
|
270
|
+
'reporting failures (this is the THIRD occurrence: mx5 runs 7 and 13). Run both',
|
|
271
|
+
'test commands yourself and confirm each collects and runs its own files and only',
|
|
272
|
+
'its own. A run that errors during collection has verified nothing, whatever its',
|
|
273
|
+
'exit code says (rule 4g).',
|
|
274
|
+
''
|
|
275
|
+
]
|
|
276
|
+
: [];
|
|
219
277
|
const testAssemblyBlock = testAssemblyFindings && testAssemblyFindings.length > 0 ?
|
|
220
278
|
[
|
|
221
279
|
'TEST-ASSEMBLY NOTICE (deterministic, computed by the orchestrator from pure',
|
|
@@ -255,6 +313,9 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
|
|
|
255
313
|
...crossTaskDeletionBlock,
|
|
256
314
|
...probeGamingBlock,
|
|
257
315
|
...skipEscapeBlock,
|
|
316
|
+
...foreignPathBlock,
|
|
317
|
+
...scriptEscapeBlock,
|
|
318
|
+
...runnerGlobBlock,
|
|
258
319
|
...testAssemblyBlock,
|
|
259
320
|
'How to verify — verify the REAL, shipped deliverable exactly as an unaided fresh',
|
|
260
321
|
'checkout (or CI run) would experience it:',
|
|
@@ -388,6 +449,32 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
|
|
|
388
449
|
' tree). Otherwise the verdict is FAIL naming the deleted file and the task that',
|
|
389
450
|
' owns it.',
|
|
390
451
|
'',
|
|
452
|
+
'4e. AN ABSOLUTE PATH TO PROJECT FILES IS A DEFECT — a committed path like',
|
|
453
|
+
" `/workspace/src/shared` or `/home/<someone>/proj/src` names the authoring agent's",
|
|
454
|
+
' OWN machine, not this one. Its distinctive damage is that it breaks the run BEFORE',
|
|
455
|
+
' any check reports: a bad alias/config path makes the tool fail to RESOLVE or BUILD,',
|
|
456
|
+
' so a suite "passes" having executed nothing. Treat a command that reports no',
|
|
457
|
+
' failures but also no WORK — 0 tests run, 0 files emitted, an empty report — as',
|
|
458
|
+
' unverified, never as green. Confirm the count of things that actually ran. A path',
|
|
459
|
+
' to project-internal files must be relative to the file that carries it, or computed',
|
|
460
|
+
' at runtime; if one does not resolve here, the verdict is FAIL naming file and path.',
|
|
461
|
+
'',
|
|
462
|
+
'4f. A CHECK THAT CANNOT FAIL PROVES NOTHING — before you cite a check script',
|
|
463
|
+
" (`npm run lint`, `bun run test`) as evidence, read its DEFINITION in the project's",
|
|
464
|
+
' manifest. A script ending in `|| true`, `; exit 0`, or piping a checker into an',
|
|
465
|
+
' inverted grep exits 0 unconditionally: its green result is a constant, not a',
|
|
466
|
+
' measurement, and running it again only reproduces the constant. When a script is',
|
|
467
|
+
' built that way, run the underlying checker directly and judge its real output; and',
|
|
468
|
+
' unless the spec required that tolerance, the script itself is a defect — the',
|
|
469
|
+
' verdict is FAIL naming it.',
|
|
470
|
+
'',
|
|
471
|
+
'4g. TWO RUNNERS, ONE FILE SET, NO RESULTS — when a project declares more than one',
|
|
472
|
+
' test runner, check that each collects only its own files. A runner that scans for',
|
|
473
|
+
" `*.test.*` / `*.spec.*` project-wide will import another runner's specs and abort",
|
|
474
|
+
' during COLLECTION. That failure mode looks nothing like a test failure: you get an',
|
|
475
|
+
' import error, or a suite that reports zero tests. Always read how many tests each',
|
|
476
|
+
' command actually COLLECTED and RAN; zero collected is never a pass.',
|
|
477
|
+
'',
|
|
391
478
|
'5. The ONLY thing you may assume is already provided is a genuinely EXTERNAL running',
|
|
392
479
|
' service or network resource (a database server, an API host) that the project',
|
|
393
480
|
' documents as a prerequisite. Before you rely on that assumption, PROBE for the',
|
|
@@ -572,6 +659,39 @@ export async function runWorkVerification(deps) {
|
|
|
572
659
|
crossDeletions = [];
|
|
573
660
|
}
|
|
574
661
|
}
|
|
662
|
+
// Sandbox-path-leak findings the deterministic repair could NOT fix, injected
|
|
663
|
+
// under rule 4e. A probe failure must never block verification.
|
|
664
|
+
let foreignPaths = [];
|
|
665
|
+
if (deps.foreignPathProbe) {
|
|
666
|
+
try {
|
|
667
|
+
foreignPaths = await deps.foreignPathProbe();
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
foreignPaths = [];
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Neutered check scripts in a manifest this task changed, injected under rule
|
|
674
|
+
// 4f. A probe failure must never block verification.
|
|
675
|
+
let scriptEscapes = [];
|
|
676
|
+
if (deps.scriptEscapeProbe) {
|
|
677
|
+
try {
|
|
678
|
+
scriptEscapes = await deps.scriptEscapeProbe();
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
scriptEscapes = [];
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// Colliding test-runner globs, injected under rule 4g. A probe failure must
|
|
685
|
+
// never block verification.
|
|
686
|
+
let runnerGlobs = [];
|
|
687
|
+
if (deps.runnerGlobProbe) {
|
|
688
|
+
try {
|
|
689
|
+
runnerGlobs = await deps.runnerGlobProbe();
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
runnerGlobs = [];
|
|
693
|
+
}
|
|
694
|
+
}
|
|
575
695
|
// Environment facts from earlier gate children (best-effort; a cache failure
|
|
576
696
|
// must never block verification).
|
|
577
697
|
let envNotes = '';
|
|
@@ -607,7 +727,11 @@ export async function runWorkVerification(deps) {
|
|
|
607
727
|
for (let attempt = 1;; attempt++) {
|
|
608
728
|
let text;
|
|
609
729
|
try {
|
|
610
|
-
text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts, testAssembly, probeGaming, crossTaskDeletionVerifyFindings(crossDeletions)
|
|
730
|
+
text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts, testAssembly, probeGaming, crossTaskDeletionVerifyFindings(crossDeletions), {
|
|
731
|
+
foreignPaths,
|
|
732
|
+
scriptEscapes,
|
|
733
|
+
runnerGlobs
|
|
734
|
+
}), deps.signal);
|
|
611
735
|
}
|
|
612
736
|
catch (err) {
|
|
613
737
|
if (err instanceof Error && err.message === USER_CANCELLED)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.31",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|