@mjasnikovs/pi-task 0.18.29 → 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/config/config.d.ts +15 -0
- package/dist/config/config.js +8 -1
- package/dist/config/register.js +10 -0
- package/dist/task/accept-debt.d.ts +13 -1
- package/dist/task/accept-debt.js +18 -1
- package/dist/task/auto-orchestrator.d.ts +8 -0
- package/dist/task/auto-orchestrator.js +127 -22
- 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 +143 -1
- package/dist/task/parsers.d.ts +18 -0
- package/dist/task/parsers.js +4 -3
- package/dist/task/phases.js +56 -10
- 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/task-gates.d.ts +8 -0
- package/dist/task/task-gates.js +26 -4
- package/dist/task/verify-work.d.ts +39 -1
- package/dist/task/verify-work.js +126 -2
- package/dist/task/yolo.d.ts +74 -0
- package/dist/task/yolo.js +112 -0
- package/package.json +1 -1
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';
|
|
@@ -21,7 +22,7 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
|
|
|
21
22
|
import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
|
|
22
23
|
import { readEnvNotes, appendEnvNotes } from './env-notes.js';
|
|
23
24
|
import { readContracts } from './contracts.js';
|
|
24
|
-
import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt } from './accept-debt.js';
|
|
25
|
+
import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt } from './accept-debt.js';
|
|
25
26
|
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
26
27
|
import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
|
|
27
28
|
import { runFinalGateAutofix } from './final-gate-fix.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.
|
|
@@ -337,6 +456,7 @@ export function buildGateDeps(params) {
|
|
|
337
456
|
// Durable ACCEPT-despite-verify-FAIL ledger under .pi-tasks/ (survives
|
|
338
457
|
// discardEdits): the final integration gate re-checks each debt at run end.
|
|
339
458
|
recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
|
|
459
|
+
recordYoloAcceptDebt: (cwd2, taskId, reason) => recordYoloAcceptDebt(cwd2, taskId, reason),
|
|
340
460
|
recordEnforceRevertDebt: (cwd2, taskId, reason) => recordEnforceRevertDebt(cwd2, taskId, reason),
|
|
341
461
|
// Durable cross-task-contradiction ledger (PROMPT 1 layer B): a repo-health
|
|
342
462
|
// FAIL whose only fix is an edit to a path this task's spec froze — recorded
|
|
@@ -496,6 +616,28 @@ export function buildGateDeps(params) {
|
|
|
496
616
|
// destroyed (typically to green a check). Injected under rule 4d and
|
|
497
617
|
// carried on a FAIL so an ACCEPT records durable debts.
|
|
498
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),
|
|
499
641
|
// Deterministic prohibition probe: paths the spec forbids modifying
|
|
500
642
|
// that the task's diff modified anyway become prompt-level findings
|
|
501
643
|
// under the no-waiver rule — the child otherwise rarely runs `git
|
package/dist/task/parsers.d.ts
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Pure functions that parse raw model output into structured data.
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* WHY an auto-answer came back `unknown` — the producers are otherwise
|
|
8
|
+
* indistinguishable at the call site, and they are NOT equivalent: only
|
|
9
|
+
* 'api-synthesis' marks a recommendation a machine must never take (see
|
|
10
|
+
* task/yolo.ts). Mechanical tag, never a text pattern-match:
|
|
11
|
+
* - 'model-unknown' — the child itself emitted UNKNOWN (or the parser salvaged a
|
|
12
|
+
* bare recommendation): a genuine open fork carrying a best-effort suggestion.
|
|
13
|
+
* - 'api-synthesis' — the answer was ANSWERED, then DEMOTED because it names an
|
|
14
|
+
* API identifier absent from the research and the question, in a namespace the
|
|
15
|
+
* research covers (mx5 run 13: an invented `Bun.mkdirSync` reached requirements
|
|
16
|
+
* AND the VERIFY block). The suggestion rides along for a HUMAN to judge.
|
|
17
|
+
* - 'integration' — an integration/build-wiring unknown no fetched doc grounded;
|
|
18
|
+
* a wrong guess is a structural landmine, so the model's answer is offered as
|
|
19
|
+
* a recommendation instead of being taken silently.
|
|
20
|
+
* - 'threw' — the child failed; there is no recommendation at all.
|
|
21
|
+
*/
|
|
22
|
+
export type AutoAnswerUnknownReason = 'model-unknown' | 'api-synthesis' | 'integration' | 'threw';
|
|
6
23
|
export type AutoAnswer = {
|
|
7
24
|
kind: 'answered';
|
|
8
25
|
text: string;
|
|
@@ -12,6 +29,7 @@ export type AutoAnswer = {
|
|
|
12
29
|
suggested?: string;
|
|
13
30
|
alt?: string;
|
|
14
31
|
raw: string;
|
|
32
|
+
reason?: AutoAnswerUnknownReason;
|
|
15
33
|
};
|
|
16
34
|
/** One /task-auto clarify question with its model-recommended default answer. */
|
|
17
35
|
export interface ClarifyQuestion {
|
package/dist/task/parsers.js
CHANGED
|
@@ -148,7 +148,8 @@ export function parseAutoAnswer(raw) {
|
|
|
148
148
|
kind: 'unknown',
|
|
149
149
|
...(suggested !== undefined && { suggested }),
|
|
150
150
|
...(alt !== undefined && { alt }),
|
|
151
|
-
raw
|
|
151
|
+
raw,
|
|
152
|
+
reason: 'model-unknown'
|
|
152
153
|
};
|
|
153
154
|
}
|
|
154
155
|
// Last-resort salvage: the model emitted no tag at all. Take the first line
|
|
@@ -159,8 +160,8 @@ export function parseAutoAnswer(raw) {
|
|
|
159
160
|
// answer than to pre-fill a meaningless preamble.
|
|
160
161
|
const salvaged = lines.find(l => !l.endsWith(':'));
|
|
161
162
|
if (salvaged)
|
|
162
|
-
return { kind: 'unknown', suggested: salvaged, raw };
|
|
163
|
-
return { kind: 'unknown', raw };
|
|
163
|
+
return { kind: 'unknown', suggested: salvaged, raw, reason: 'model-unknown' };
|
|
164
|
+
return { kind: 'unknown', raw, reason: 'model-unknown' };
|
|
164
165
|
}
|
|
165
166
|
// ─── Verify tooling output parser ────────────────────────────────────────────
|
|
166
167
|
export function parseVerifyToolingOutput(output) {
|
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';
|
|
@@ -37,6 +38,7 @@ import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from '.
|
|
|
37
38
|
import { readRequirements, buildRequirementsBlock } from './requirements.js';
|
|
38
39
|
import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
39
40
|
import { SessionUI } from '../remote/bridge.js';
|
|
41
|
+
import { isYoloMode, yoloPickAutoAnswer, YOLO_STAMP } from './yolo.js';
|
|
40
42
|
// ─── Re-export constants from their home modules ────────────────────────────
|
|
41
43
|
export { MAX_GRILL_QUESTIONS };
|
|
42
44
|
// ─── Tooling helpers ─────────────────────────────────────────────────────────
|
|
@@ -665,7 +667,15 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
|
|
|
665
667
|
const still = reasked ?? parsed;
|
|
666
668
|
const suggested = still.kind === 'answered' ? still.text : parsed.text;
|
|
667
669
|
deps.logDebug?.('grill-auto: answer still carries an unverified API — surfacing to user');
|
|
668
|
-
parsed = {
|
|
670
|
+
parsed = {
|
|
671
|
+
kind: 'unknown',
|
|
672
|
+
suggested,
|
|
673
|
+
raw: still.raw,
|
|
674
|
+
// Tagged so a call site can tell this producer from the other
|
|
675
|
+
// two: the suggestion is PROVEN to name an unverified API, so
|
|
676
|
+
// it may only be judged by a human (yolo.ts must not take it).
|
|
677
|
+
reason: 'api-synthesis'
|
|
678
|
+
};
|
|
669
679
|
}
|
|
670
680
|
else {
|
|
671
681
|
parsed = reasked;
|
|
@@ -683,13 +693,13 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
|
|
|
683
693
|
if (parsed.kind === 'answered' && !docResolved && isIntegrationUnknown(question)) {
|
|
684
694
|
deps.logDebug?.(`grill-auto: integration unknown unresolved by fetch — surfacing to user `
|
|
685
695
|
+ `instead of auto-answering: ${question.replace(/\s+/g, ' ').slice(0, 120)}`);
|
|
686
|
-
return { kind: 'unknown', suggested: parsed.text, raw: parsed.raw };
|
|
696
|
+
return { kind: 'unknown', suggested: parsed.text, raw: parsed.raw, reason: 'integration' };
|
|
687
697
|
}
|
|
688
698
|
return parsed;
|
|
689
699
|
}
|
|
690
700
|
catch (err) {
|
|
691
701
|
const msg = err instanceof Error ? err.message : String(err);
|
|
692
|
-
return { kind: 'unknown', raw: `(threw: ${msg})
|
|
702
|
+
return { kind: 'unknown', raw: `(threw: ${msg})`, reason: 'threw' };
|
|
693
703
|
}
|
|
694
704
|
}
|
|
695
705
|
export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
@@ -755,6 +765,21 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
|
755
765
|
// locally — each answer in its own bounding box, the recommended one
|
|
756
766
|
// tinted green; an open question shows the bare text prompt.
|
|
757
767
|
const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
|
|
768
|
+
// YOLO: take the recommended option and never build the prompt (which
|
|
769
|
+
// is also what suppresses its notification — see yolo.ts). An answer the
|
|
770
|
+
// anti-synthesis guard demoted, or a question with no recommendation at
|
|
771
|
+
// all, is SKIPPED instead: costing the spec one unanswered fork is the
|
|
772
|
+
// guard direction, promoting a hallucination is not.
|
|
773
|
+
const yolo = yoloPickAutoAnswer(isYoloMode(), auto);
|
|
774
|
+
if (yolo !== null) {
|
|
775
|
+
answer =
|
|
776
|
+
yolo.kind === 'answer' ?
|
|
777
|
+
stripInlineMarkdown(yolo.answer)
|
|
778
|
+
: `(skipped — ${yolo.note})`;
|
|
779
|
+
out.push(`A${n + 1}: ${answer} ${YOLO_STAMP}`);
|
|
780
|
+
qa.push(`Q${n + 1}: ${plainQ}\nA${n + 1}: ${answer} ${YOLO_STAMP}`);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
758
783
|
const options = twoOption ?
|
|
759
784
|
[
|
|
760
785
|
{
|
|
@@ -915,6 +940,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
915
940
|
if (grepOnlyProbe) {
|
|
916
941
|
deps.logDebug?.('grep-theater VERIFY flagged in spec: ' + grepOnly.map(f => f.target).join(' | '));
|
|
917
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
|
+
}
|
|
918
954
|
let triageDefects = null;
|
|
919
955
|
if (parseVerifyBlock(spec) !== null) {
|
|
920
956
|
const tTriage = Date.now();
|
|
@@ -932,15 +968,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
932
968
|
deps.recordSubStep?.('triage', Date.now() - tTriage);
|
|
933
969
|
if (verdict !== null) {
|
|
934
970
|
// A deterministic skip-escape, synthesized-wiring, plan-contradiction,
|
|
935
|
-
// unsatisfiable-pair,
|
|
936
|
-
//
|
|
937
|
-
//
|
|
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).
|
|
938
975
|
if (isCritiqueClean(verdict)) {
|
|
939
976
|
if (skipDefects === null
|
|
940
977
|
&& wiringProbe === null
|
|
941
978
|
&& absenceProbe === null
|
|
942
979
|
&& frozenProbe === null
|
|
943
|
-
&& grepOnlyProbe === null
|
|
980
|
+
&& grepOnlyProbe === null
|
|
981
|
+
&& scriptProbe === null) {
|
|
944
982
|
return spec;
|
|
945
983
|
}
|
|
946
984
|
}
|
|
@@ -950,9 +988,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
|
|
|
950
988
|
}
|
|
951
989
|
}
|
|
952
990
|
// Merge the deterministic skip-escape + synthesized-wiring + plan-contradiction
|
|
953
|
-
// + unsatisfiable-pair + grep-theater defects with any triage
|
|
954
|
-
// rewrite (all are forced FOCUS items).
|
|
955
|
-
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
|
+
]
|
|
956
1002
|
.filter(Boolean)
|
|
957
1003
|
.join('\n\n') || null;
|
|
958
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
|
+
}
|