@mjasnikovs/pi-task 0.37.3 → 0.37.5
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-commit.d.ts +30 -0
- package/dist/task/auto-commit.js +49 -4
- package/dist/task/auto-orchestrator.js +24 -6
- package/dist/task/final-gate.js +93 -9
- package/dist/task/git-state-guard.js +6 -9
- package/dist/task/launch-config-gap.d.ts +53 -0
- package/dist/task/launch-config-gap.js +159 -0
- package/dist/task/launch-manifest.d.ts +37 -0
- package/dist/task/launch-manifest.js +128 -0
- package/dist/task/regenerable-artifacts.d.ts +66 -0
- package/dist/task/regenerable-artifacts.js +88 -0
- package/dist/task/task-gates.js +11 -0
- package/dist/task/write-guard.d.ts +15 -21
- package/dist/task/write-guard.js +17 -1
- package/package.json +1 -1
|
@@ -5,6 +5,11 @@ export interface CommitResult {
|
|
|
5
5
|
reason?: string;
|
|
6
6
|
/** Set when the commit needed a fallback (e.g. self-supplied identity). */
|
|
7
7
|
note?: string;
|
|
8
|
+
/** UNTRACKED regenerable test-runner output this commit deliberately left out
|
|
9
|
+
* of the index (see `stagePathspec`). Empty/absent when nothing was excluded.
|
|
10
|
+
* Present so the caller can TRAIL it: a silent exclusion is the same failure
|
|
11
|
+
* class as the silent ignored-path write nexttask 4 closed. */
|
|
12
|
+
excluded?: string[];
|
|
8
13
|
}
|
|
9
14
|
/**
|
|
10
15
|
* Does this git stderr describe a missing author identity? Seen live (mx5 run 4):
|
|
@@ -21,6 +26,31 @@ export declare function git(cwd: string, args: string[], signal: AbortSignal | u
|
|
|
21
26
|
exitCode: number;
|
|
22
27
|
aborted: boolean;
|
|
23
28
|
}>;
|
|
29
|
+
/**
|
|
30
|
+
* The UNTRACKED files a per-task snapshot must not sweep into the index: Playwright
|
|
31
|
+
* `test-results/`, `playwright-report/`, `coverage/`, `.nyc_output/`,
|
|
32
|
+
* `.last-run.json`, `*.tsbuildinfo`.
|
|
33
|
+
*
|
|
34
|
+
* This is the other half of mx5 run 20. TASK_0027's snapshot ran a bare
|
|
35
|
+
* `git add -A` over a tree the test run had just littered with three Playwright
|
|
36
|
+
* FAILURE screenshots (`*-actual.png` — written only when a screenshot assertion
|
|
37
|
+
* fails), committed them, and thereby made them tracked deliverables. Two whole
|
|
38
|
+
* final-gate fix attempts were then rejected for deleting them. The deletion guard
|
|
39
|
+
* fix (write-guard.ts) stops the rejection; this stops the tracking.
|
|
40
|
+
*
|
|
41
|
+
* ONLY UNTRACKED PATHS ARE EXCLUDED, and that is load-bearing rather than tidy.
|
|
42
|
+
* `git ls-files --others` lists untracked, non-ignored files and nothing else, so a
|
|
43
|
+
* path git ALREADY tracks can never appear here — meaning a project that
|
|
44
|
+
* deliberately commits, say, a `coverage/` badge keeps having its edits to it
|
|
45
|
+
* committed. Excluding by directory pathspec instead (`:(exclude)coverage/`) would
|
|
46
|
+
* silently stop committing those.
|
|
47
|
+
*
|
|
48
|
+
* Best-effort: any git failure yields an empty list, i.e. today's `git add -A`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function untrackedArtifacts(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string[]>;
|
|
51
|
+
/** `:(exclude)` pathspecs for `git add -A`; empty when nothing is excluded, so the
|
|
52
|
+
* common case is byte-identical to the previous bare `git add -A`. */
|
|
53
|
+
export declare function stagePathspec(excluded: readonly string[]): string[];
|
|
24
54
|
/**
|
|
25
55
|
* Paths with unmerged index entries (an in-progress merge conflict), deduped.
|
|
26
56
|
* Empty outside a git repo or on any git error — this is a GUARD input, and a
|
package/dist/task/auto-commit.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as fsp from 'node:fs/promises';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { runChildDefault } from '../shared/child-process.js';
|
|
12
|
+
import { isDeletionExemptArtifact } from './regenerable-artifacts.js';
|
|
12
13
|
/** The gate machinery's own state/forensic dir — the trail, debug logs, and per-run
|
|
13
14
|
* ledgers. Preserved verbatim across a revert (see gitDropLastCommit). */
|
|
14
15
|
const TRAIL_DIR = '.pi-tasks';
|
|
@@ -36,6 +37,44 @@ export async function git(cwd, args, signal, spawnFn) {
|
|
|
36
37
|
const r = await runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
|
|
37
38
|
return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode, aborted: r.aborted };
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The UNTRACKED files a per-task snapshot must not sweep into the index: Playwright
|
|
42
|
+
* `test-results/`, `playwright-report/`, `coverage/`, `.nyc_output/`,
|
|
43
|
+
* `.last-run.json`, `*.tsbuildinfo`.
|
|
44
|
+
*
|
|
45
|
+
* This is the other half of mx5 run 20. TASK_0027's snapshot ran a bare
|
|
46
|
+
* `git add -A` over a tree the test run had just littered with three Playwright
|
|
47
|
+
* FAILURE screenshots (`*-actual.png` — written only when a screenshot assertion
|
|
48
|
+
* fails), committed them, and thereby made them tracked deliverables. Two whole
|
|
49
|
+
* final-gate fix attempts were then rejected for deleting them. The deletion guard
|
|
50
|
+
* fix (write-guard.ts) stops the rejection; this stops the tracking.
|
|
51
|
+
*
|
|
52
|
+
* ONLY UNTRACKED PATHS ARE EXCLUDED, and that is load-bearing rather than tidy.
|
|
53
|
+
* `git ls-files --others` lists untracked, non-ignored files and nothing else, so a
|
|
54
|
+
* path git ALREADY tracks can never appear here — meaning a project that
|
|
55
|
+
* deliberately commits, say, a `coverage/` badge keeps having its edits to it
|
|
56
|
+
* committed. Excluding by directory pathspec instead (`:(exclude)coverage/`) would
|
|
57
|
+
* silently stop committing those.
|
|
58
|
+
*
|
|
59
|
+
* Best-effort: any git failure yields an empty list, i.e. today's `git add -A`.
|
|
60
|
+
*/
|
|
61
|
+
export async function untrackedArtifacts(cwd, signal, spawnFn) {
|
|
62
|
+
const r = await git(cwd, ['ls-files', '--others', '--exclude-standard', '-z'], signal, spawnFn);
|
|
63
|
+
if (r.aborted || r.exitCode !== 0)
|
|
64
|
+
return [];
|
|
65
|
+
return r.stdout
|
|
66
|
+
.split('\0')
|
|
67
|
+
.map(p => p.trim())
|
|
68
|
+
.filter(p => p.length > 0 && isDeletionExemptArtifact(p))
|
|
69
|
+
.sort();
|
|
70
|
+
}
|
|
71
|
+
/** `:(exclude)` pathspecs for `git add -A`; empty when nothing is excluded, so the
|
|
72
|
+
* common case is byte-identical to the previous bare `git add -A`. */
|
|
73
|
+
export function stagePathspec(excluded) {
|
|
74
|
+
if (excluded.length === 0)
|
|
75
|
+
return [];
|
|
76
|
+
return ['--', '.', ...excluded.map(p => `:(exclude)${p}`)];
|
|
77
|
+
}
|
|
39
78
|
/**
|
|
40
79
|
* Paths with unmerged index entries (an in-progress merge conflict), deduped.
|
|
41
80
|
* Empty outside a git repo or on any git error — this is a GUARD input, and a
|
|
@@ -93,8 +132,10 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
|
|
|
93
132
|
reason: `git commit blocked: unresolved merge conflict (${unmerged.slice(0, 3).join(', ')}${unmerged.length > 3 ? `, +${unmerged.length - 3} more` : ''})`
|
|
94
133
|
};
|
|
95
134
|
}
|
|
96
|
-
// 3. Stage all working-tree changes (new, modified, deleted)
|
|
97
|
-
|
|
135
|
+
// 3. Stage all working-tree changes (new, modified, deleted) EXCEPT untracked
|
|
136
|
+
// regenerable test-runner output — see stagePathspec.
|
|
137
|
+
const excluded = await untrackedArtifacts(cwd, signal, spawnFn);
|
|
138
|
+
const add = await git(cwd, ['add', '-A', ...stagePathspec(excluded)], signal, spawnFn);
|
|
98
139
|
if (add.aborted)
|
|
99
140
|
return { committed: false, reason: 'cancelled' };
|
|
100
141
|
if (add.exitCode !== 0) {
|
|
@@ -119,7 +160,11 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
|
|
|
119
160
|
if (retry.aborted)
|
|
120
161
|
return { committed: false, reason: 'cancelled' };
|
|
121
162
|
if (retry.exitCode === 0) {
|
|
122
|
-
return {
|
|
163
|
+
return {
|
|
164
|
+
committed: true,
|
|
165
|
+
note: 'no git identity configured — used pi-task fallback',
|
|
166
|
+
...(excluded.length > 0 ? { excluded } : {})
|
|
167
|
+
};
|
|
123
168
|
}
|
|
124
169
|
return {
|
|
125
170
|
committed: false,
|
|
@@ -131,7 +176,7 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
|
|
|
131
176
|
reason: `git commit failed: ${firstLine(commit.stderr || commit.stdout)}`
|
|
132
177
|
};
|
|
133
178
|
}
|
|
134
|
-
return { committed: true };
|
|
179
|
+
return { committed: true, ...(excluded.length > 0 ? { excluded } : {}) };
|
|
135
180
|
}
|
|
136
181
|
/**
|
|
137
182
|
* Drop the last commit, restoring the tree to its parent — the differential
|
|
@@ -1375,17 +1375,35 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1375
1375
|
+ `so the tree holds REJECTED edits: ${stranded.slice(0, 8).join(', ')}`);
|
|
1376
1376
|
return;
|
|
1377
1377
|
}
|
|
1378
|
+
// REPORT WHAT ACTUALLY HAPPENED (mx5 run 20). This bound the
|
|
1379
|
+
// CommitResult to `sha` and interpolated it, so the trail
|
|
1380
|
+
// read "committed 5 stranded fix-pass change(s) as
|
|
1381
|
+
// [object Object]". Worse than cosmetic: `commit` returns
|
|
1382
|
+
// {committed, reason?, note?} and NEVER a sha, the
|
|
1383
|
+
// `committed` field was never read, and gitCommitAll
|
|
1384
|
+
// returns {committed:false} WITHOUT throwing on an unmerged
|
|
1385
|
+
// index — so on that path the catch below never fires and
|
|
1386
|
+
// the trail claimed a commit over changes that were still
|
|
1387
|
+
// sitting in the working tree.
|
|
1388
|
+
const notCommitted = async (why) => {
|
|
1389
|
+
await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
|
|
1390
|
+
+ `change(s) (${why}) — they remain UNCOMMITTED in the working `
|
|
1391
|
+
+ `tree: ${stranded.slice(0, 8).join(', ')}`);
|
|
1392
|
+
};
|
|
1378
1393
|
try {
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1394
|
+
const res = await deps.commit(cwd, STRANDED_FIX_COMMIT(id, outcome));
|
|
1395
|
+
if (res.committed) {
|
|
1396
|
+
await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
|
|
1397
|
+
+ `${res.note ? ` (${res.note})` : ''} — ${stranded.slice(0, 8).join(', ')}`);
|
|
1398
|
+
}
|
|
1399
|
+
else {
|
|
1400
|
+
await notCommitted(res.reason ?? 'unknown');
|
|
1401
|
+
}
|
|
1382
1402
|
}
|
|
1383
1403
|
catch (err) {
|
|
1384
1404
|
// Never break the terminal path over this — but say so, so
|
|
1385
1405
|
// the changes are not silently lost.
|
|
1386
|
-
await
|
|
1387
|
-
+ `change(s) (${err instanceof Error ? err.message : String(err)}) — `
|
|
1388
|
-
+ `they remain UNCOMMITTED in the working tree: ${stranded.slice(0, 8).join(', ')}`);
|
|
1406
|
+
await notCommitted(err instanceof Error ? err.message : String(err));
|
|
1389
1407
|
}
|
|
1390
1408
|
};
|
|
1391
1409
|
while (!fin.ok) {
|
package/dist/task/final-gate.js
CHANGED
|
@@ -52,13 +52,15 @@ import * as path from 'node:path';
|
|
|
52
52
|
import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
|
|
53
53
|
import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote, annotateDebtConflicts } from './accept-debt.js';
|
|
54
54
|
import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
|
|
55
|
+
import { readLaunchManifest, inertLaunchContractNote } from './launch-manifest.js';
|
|
55
56
|
import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
|
|
56
57
|
import { runRenderCheck } from './render-check.js';
|
|
57
58
|
import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
|
|
58
59
|
import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
|
|
60
|
+
import { findLaunchConfigGap, probeEnv, configGapUnobservedNote } from './launch-config-gap.js';
|
|
59
61
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
60
62
|
import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
|
|
61
|
-
import { findMissingEnvDeclarations, envGateFailureText } from './env-template-closure.js';
|
|
63
|
+
import { findMissingEnvDeclarations, envGateFailureText, scanEnvTemplateClosure, inertClosure, trackedFiles } from './env-template-closure.js';
|
|
62
64
|
import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
|
|
63
65
|
import { makefileRecipe } from './command-shrink.js';
|
|
64
66
|
function packageScripts(cwd) {
|
|
@@ -1008,7 +1010,10 @@ export const INFRA_GAP_OUTPUT_RE = /ECONNREFUSED|connection refused|ENOTFOUND|EA
|
|
|
1008
1010
|
* passes `extraGapRe` (launch scripts), missing external infrastructure. Only a
|
|
1009
1011
|
* command that actually ran and exited non-zero for a real reason fails.
|
|
1010
1012
|
*/
|
|
1011
|
-
function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe
|
|
1013
|
+
function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe,
|
|
1014
|
+
/** Replaces the child's environment wholesale (config-gap probe re-run only —
|
|
1015
|
+
* see launch-config-gap.ts). Absent ⇒ `runnerEnv(runner)`, i.e. unchanged. */
|
|
1016
|
+
envOverride) {
|
|
1012
1017
|
// Runner resolution (mx5 run 16): a login-shell-stripped PATH left `bun`
|
|
1013
1018
|
// unspawnable, so every dynamic check skipped and the gate went blind. The
|
|
1014
1019
|
// resolved binary is spawned, and its directory rides on the child's PATH so
|
|
@@ -1021,7 +1026,7 @@ function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe) {
|
|
|
1021
1026
|
cwd,
|
|
1022
1027
|
encoding: 'utf8',
|
|
1023
1028
|
timeout: timeoutMs,
|
|
1024
|
-
env: runnerEnv(runner)
|
|
1029
|
+
env: envOverride ?? runnerEnv(runner)
|
|
1025
1030
|
});
|
|
1026
1031
|
if (r.error)
|
|
1027
1032
|
return { outcome: 'skip', spawnFailed: true };
|
|
@@ -1374,11 +1379,28 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1374
1379
|
// scripts that fell through decompose and shipped missing, unchecked. Diff the
|
|
1375
1380
|
// plan-time-extracted declared scripts against the manifest; a missing one is a
|
|
1376
1381
|
// launch-surface defect. FP-safe: empty declared list (nothing grounded) → no check.
|
|
1382
|
+
//
|
|
1383
|
+
// THE DIFF IS INERT WITHOUT A MANIFEST (nexttask 16A). It used to diff against
|
|
1384
|
+
// `Object.keys(packageScripts(cwd))`, whose catch returns {} — so a project with
|
|
1385
|
+
// NO package.json was indistinguishable from one with no scripts, and every
|
|
1386
|
+
// declared script was reported missing in wording naming a file the project was
|
|
1387
|
+
// never meant to have. Nothing upstream is npm-shaped (the extractor scrapes any
|
|
1388
|
+
// design that says "script"), and this text seeds the autofix child's prompt, so
|
|
1389
|
+
// on a CMake/cargo project the likely repair was to write a package.json.
|
|
1390
|
+
// readLaunchManifest resolves package.json, else a Makefile's targets, else
|
|
1391
|
+
// nothing — and nothing means no failure plus a note, never a silent pass.
|
|
1377
1392
|
const declared = await readDeclaredScripts(cwd);
|
|
1393
|
+
const contractNotes = [];
|
|
1378
1394
|
if (declared.length > 0) {
|
|
1379
|
-
const
|
|
1380
|
-
if (
|
|
1381
|
-
|
|
1395
|
+
const manifest = readLaunchManifest(cwd);
|
|
1396
|
+
if (manifest.kind === 'none') {
|
|
1397
|
+
contractNotes.push(inertLaunchContractNote(declared, manifest));
|
|
1398
|
+
}
|
|
1399
|
+
else {
|
|
1400
|
+
const missing = missingDeclaredScripts(declared, manifest.names);
|
|
1401
|
+
if (missing.length > 0) {
|
|
1402
|
+
fail(`launch contract: the design declares script(s) the shipped ${manifest.file} does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
|
|
1403
|
+
}
|
|
1382
1404
|
}
|
|
1383
1405
|
}
|
|
1384
1406
|
// Serve-entry closure (mx5 run 18, nexttask 2B): the tree builds a server app,
|
|
@@ -1412,7 +1434,12 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1412
1434
|
// harvest lever refuted at discoverIntegrationCommands it cannot inject a fabricated
|
|
1413
1435
|
// failure.
|
|
1414
1436
|
if (lockCmds.length === 0 && cmds.length === 0 && !boot && failures.length === 0) {
|
|
1415
|
-
|
|
1437
|
+
// The inert-contract note rides here too: a non-npm project carrying a launch
|
|
1438
|
+
// contract usually discovers no command either, and that is exactly the run
|
|
1439
|
+
// whose silence must not read as "the contract was checked and was fine".
|
|
1440
|
+
const note = [unobservedVerdict({ discovered: 0, observed: 0 }) ?? '', ...contractNotes]
|
|
1441
|
+
.filter(n => n !== '')
|
|
1442
|
+
.join(' ');
|
|
1416
1443
|
return withDebts({ ok: true, unobserved: note, reason: note });
|
|
1417
1444
|
}
|
|
1418
1445
|
const ran = [];
|
|
@@ -1457,13 +1484,37 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1457
1484
|
// real defect the gate could not reach here (run 11's "pre-existing .rows
|
|
1458
1485
|
// bug" note excused the exact scripts that shipped broken).
|
|
1459
1486
|
const warnings = [];
|
|
1487
|
+
/** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
|
|
1488
|
+
* They ride in `unobserved`, not `warnings`, so the caller's existing
|
|
1489
|
+
* recordFinalGateUnobservedDebt writes the debt — never a PASS. */
|
|
1490
|
+
const configGapNotes = [];
|
|
1460
1491
|
if (declared.length > 0) {
|
|
1461
1492
|
const covered = cmds.flatMap(([bin, args]) => (bin === 'bun' || bin === 'npm') && args[0] === 'run' && args[1] ? [args[1]] : []);
|
|
1462
1493
|
const skippedLaunch = [];
|
|
1463
1494
|
// A declared script the manifest doesn't expose is already a launch-contract
|
|
1464
1495
|
// failure above; executing it too would double-report (pre-aggregation the
|
|
1465
1496
|
// contract diff early-returned, so this loop could assume presence).
|
|
1497
|
+
// EXECUTION STAYS npm-ONLY. The diff above now also speaks Makefile (16A),
|
|
1498
|
+
// but the runner below is literally `bun run <name>`; on a Makefile project
|
|
1499
|
+
// `present` is empty, so every declared target is skipped rather than run
|
|
1500
|
+
// through the wrong tool. Widening the RUNNER is a separate lever with its
|
|
1501
|
+
// own A/B, not a free rider on an inertness fix.
|
|
1466
1502
|
const present = new Set(Object.keys(packageScripts(cwd)).map(s => s.toLowerCase()));
|
|
1503
|
+
const scripts = packageScripts(cwd);
|
|
1504
|
+
// CONFIG-GAP INPUTS (mx5 run 20), read once: the tracked file list and the
|
|
1505
|
+
// union of every tracked env template's declared variables. Both empty on a
|
|
1506
|
+
// non-git tree or a tree with no template, which makes the whole check inert
|
|
1507
|
+
// — a project with no template gains no excuse. See launch-config-gap.ts.
|
|
1508
|
+
const closure = (() => {
|
|
1509
|
+
try {
|
|
1510
|
+
return scanEnvTemplateClosure(cwd);
|
|
1511
|
+
}
|
|
1512
|
+
catch {
|
|
1513
|
+
return inertClosure();
|
|
1514
|
+
}
|
|
1515
|
+
})();
|
|
1516
|
+
const trackedForGap = closure.templates.length > 0 ? (trackedFiles(cwd) ?? []) : [];
|
|
1517
|
+
const launchTimeout = Math.min(timeoutMs, 180_000);
|
|
1467
1518
|
for (const name of runnableDeclaredScripts(declared, covered)) {
|
|
1468
1519
|
if (!present.has(name.toLowerCase()))
|
|
1469
1520
|
continue;
|
|
@@ -1471,7 +1522,7 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1471
1522
|
const label = `${cmd[0]} ${cmd[1].join(' ')}`;
|
|
1472
1523
|
dynAttempted += 1;
|
|
1473
1524
|
dynBins.add(cmd[0]);
|
|
1474
|
-
const r = runGateCommand(cwd, cmd,
|
|
1525
|
+
const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE);
|
|
1475
1526
|
if (r.outcome === 'skip') {
|
|
1476
1527
|
if (r.spawnFailed)
|
|
1477
1528
|
dynSpawnFailures += 1;
|
|
@@ -1480,6 +1531,37 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1480
1531
|
}
|
|
1481
1532
|
dynObserved += 1;
|
|
1482
1533
|
if (r.outcome === 'fail') {
|
|
1534
|
+
// A CONFIG GAP IS NOT A CODE FAULT (mx5 run 20). The run died on
|
|
1535
|
+
// `bun run seed` exiting 1 because ADMIN_PHONE — which the project's
|
|
1536
|
+
// own `.env.example` DECLARES — is absent from this box, and the only
|
|
1537
|
+
// way to supply it is a gitignored `.env` the commit cannot contain.
|
|
1538
|
+
// Four static conditions (findLaunchConfigGap) plus one dynamic one:
|
|
1539
|
+
// re-run with the variables supplied as synthetic placeholders, and
|
|
1540
|
+
// reclassify ONLY if that exits 0. A script that fails for its own
|
|
1541
|
+
// reasons fails again with the values present and stays a FAIL — an
|
|
1542
|
+
// absent variable is not a licence to ignore an exit code the code
|
|
1543
|
+
// caused. Nothing is parsed from the child's stderr: the wording is
|
|
1544
|
+
// the project's, not the harness's.
|
|
1545
|
+
const gap = findLaunchConfigGap({
|
|
1546
|
+
cwd,
|
|
1547
|
+
script: name,
|
|
1548
|
+
body: scripts[name] ?? null,
|
|
1549
|
+
tracked: trackedForGap,
|
|
1550
|
+
declared: closure.declared,
|
|
1551
|
+
env: process.env
|
|
1552
|
+
});
|
|
1553
|
+
if (gap) {
|
|
1554
|
+
const probe = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap));
|
|
1555
|
+
if (probe.outcome === 'pass') {
|
|
1556
|
+
// Nothing about this script was OBSERVED: the real run could
|
|
1557
|
+
// not reach it and the probe run is a diagnostic, never an
|
|
1558
|
+
// observation. So it un-counts, exactly like a skip.
|
|
1559
|
+
dynObserved -= 1;
|
|
1560
|
+
skippedLaunch.push(name);
|
|
1561
|
+
configGapNotes.push(configGapUnobservedNote(gap));
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1483
1565
|
fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
|
|
1484
1566
|
continue;
|
|
1485
1567
|
}
|
|
@@ -1649,7 +1731,9 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1649
1731
|
// because it names a concrete command and the trail line is sliced at 300 chars.
|
|
1650
1732
|
const unobserved = [
|
|
1651
1733
|
bootUnobserved,
|
|
1652
|
-
unobservedVerdict({ discovered: dynAttempted, observed: dynObserved })
|
|
1734
|
+
unobservedVerdict({ discovered: dynAttempted, observed: dynObserved }),
|
|
1735
|
+
...configGapNotes,
|
|
1736
|
+
...contractNotes
|
|
1653
1737
|
]
|
|
1654
1738
|
.filter(n => n !== null)
|
|
1655
1739
|
.join(' ');
|
|
@@ -43,6 +43,7 @@ import * as fsp from 'node:fs/promises';
|
|
|
43
43
|
import * as os from 'node:os';
|
|
44
44
|
import * as path from 'node:path';
|
|
45
45
|
import { runChildDefault } from '../shared/child-process.js';
|
|
46
|
+
import { isRegenerableArtifact } from './regenerable-artifacts.js';
|
|
46
47
|
/** Keep the gate machinery's own artifacts out of the snapshot and the restore. */
|
|
47
48
|
const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
|
|
48
49
|
/**
|
|
@@ -53,16 +54,12 @@ const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
|
|
|
53
54
|
* `test-results/` and `playwright-report/` above all, the exact churn that discarded
|
|
54
55
|
* verify verdicts across mx5 run 9. Kept deliberately narrow: anything not matched
|
|
55
56
|
* here that a child modifies/deletes is treated as graded state (verdict-tainting).
|
|
57
|
+
*
|
|
58
|
+
* The list itself now lives in `regenerable-artifacts.ts` — the deletion guard and
|
|
59
|
+
* the per-task commit need the same knowledge, and three private copies of it is
|
|
60
|
+
* how mx5 run 20 spent two thirds of its repair budget on three screenshots.
|
|
56
61
|
*/
|
|
57
|
-
const
|
|
58
|
-
/^(?:test-results|playwright-report|coverage|\.nyc_output|dist|build|\.next|\.turbo|\.svelte-kit)\//,
|
|
59
|
-
/(?:^|\/)\.last-run\.json$/,
|
|
60
|
-
/\.tsbuildinfo$/
|
|
61
|
-
];
|
|
62
|
-
function isBenignArtifact(relPath) {
|
|
63
|
-
const p = relPath.replace(/\\/g, '/');
|
|
64
|
-
return ARTIFACT_PATTERNS.some(re => re.test(p));
|
|
65
|
-
}
|
|
62
|
+
const isBenignArtifact = isRegenerableArtifact;
|
|
66
63
|
/**
|
|
67
64
|
* Regenerable machine state that is benign EVEN WHEN TRACKED — a project that
|
|
68
65
|
* mistakenly commits it (mx5 run 10 does exactly this) must not have a gate child's
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type EnvRead } from './env-template-closure.js';
|
|
2
|
+
/** What a launch script needs that this box does not have. */
|
|
3
|
+
export interface LaunchConfigGap {
|
|
4
|
+
/** Declared script name, e.g. `seed`. */
|
|
5
|
+
script: string;
|
|
6
|
+
/** The tracked source file the script body runs. */
|
|
7
|
+
file: string;
|
|
8
|
+
/** Variables it REQUIRES that the template declares and the env lacks. */
|
|
9
|
+
variables: string[];
|
|
10
|
+
/** First read site per variable, for the debt line. */
|
|
11
|
+
reads: EnvRead[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The value the probe run supplies. Obviously synthetic and obviously ours, so a
|
|
15
|
+
* seeded row or a log line carrying it is recognisable as harness residue rather
|
|
16
|
+
* than mistaken for real configuration.
|
|
17
|
+
*/
|
|
18
|
+
export declare const CONFIG_GAP_PROBE_VALUE = "pi-task-config-gap-probe";
|
|
19
|
+
/**
|
|
20
|
+
* The tracked source files a script body runs. Purely lexical and deliberately
|
|
21
|
+
* so: every whitespace/operator-separated token, normalised, that the tracked set
|
|
22
|
+
* contains. A token that is not a tracked file (a flag, the runner, a bare word)
|
|
23
|
+
* cannot match, and a body that names no tracked file yields nothing — which
|
|
24
|
+
* fails condition 1 and ends the check.
|
|
25
|
+
*/
|
|
26
|
+
export declare function bodySourceFiles(body: string, tracked: ReadonlySet<string>): string[];
|
|
27
|
+
/**
|
|
28
|
+
* The static half: conditions 1–4. Returns null when ANY of them fails, which is
|
|
29
|
+
* the common case and means "behave exactly as before".
|
|
30
|
+
*
|
|
31
|
+
* `declared` is the union of every tracked template's variables. When it is empty
|
|
32
|
+
* the tree has no template, condition 3 can never hold, and this always returns
|
|
33
|
+
* null — a project with no template gains no excuse.
|
|
34
|
+
*/
|
|
35
|
+
export declare function findLaunchConfigGap(args: {
|
|
36
|
+
cwd: string;
|
|
37
|
+
script: string;
|
|
38
|
+
/** Resolved script body (`packageScripts(cwd)[name]`), or null. */
|
|
39
|
+
body: string | null;
|
|
40
|
+
/** Tracked files, repo-relative (`trackedFiles(cwd)`). */
|
|
41
|
+
tracked: readonly string[];
|
|
42
|
+
/** Variables the tracked env template(s) declare. */
|
|
43
|
+
declared: ReadonlySet<string>;
|
|
44
|
+
/** The env the gate spawned (or would spawn) the child with. */
|
|
45
|
+
env: Record<string, string | undefined>;
|
|
46
|
+
}): LaunchConfigGap | null;
|
|
47
|
+
/** The env for the probe re-run: the gate's own env plus a synthetic value per
|
|
48
|
+
* gap variable. Nothing else changes, so a second non-zero exit is the script's
|
|
49
|
+
* own fault and not the harness's. */
|
|
50
|
+
export declare function probeEnv(base: Record<string, string | undefined>, gap: LaunchConfigGap): Record<string, string | undefined>;
|
|
51
|
+
/** The UNOBSERVED warning: names the script and every variable, so a human can
|
|
52
|
+
* supply them and re-run by hand. */
|
|
53
|
+
export declare function configGapUnobservedNote(gap: LaunchConfigGap): string;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launch-config-gap — a launch script that cannot run because a variable the
|
|
3
|
+
* project's own tracked template DECLARES is absent from this box is an
|
|
4
|
+
* ENVIRONMENT GAP, not a code fault (mx5 run 20).
|
|
5
|
+
*
|
|
6
|
+
* THE EPISODE. Attempt 3 of the final-gate autofix passed every guard, fixed the
|
|
7
|
+
* failing test, and still lost the run:
|
|
8
|
+
*
|
|
9
|
+
* autofix attempt 3 failed — did not converge: launch script: `bun run seed`
|
|
10
|
+
* exited 1 — $ bun run src/server/seed.ts Missing required environment
|
|
11
|
+
* variable: ADMIN_PHONE error: script "seed" exited with code 1
|
|
12
|
+
*
|
|
13
|
+
* Single-failure phrasing (final-gate.ts uses a numbered list for ≥2), so the
|
|
14
|
+
* suite was green and this was the only remaining failure. The run failed on
|
|
15
|
+
* exactly this, and it is self-inflicted at the last moment: pre-gate
|
|
16
|
+
* `package.json` had no `seed` script at all, so `if (!present.has(name)) continue`
|
|
17
|
+
* skipped it. Attempt 3 added the script to clear the launch-contract diff — and
|
|
18
|
+
* thereby armed the check that killed the run.
|
|
19
|
+
*
|
|
20
|
+
* The loop was closed by construction. The gate runs every declared non-boot
|
|
21
|
+
* script with `runnerEnv(runner)` = `process.env` plus a PATH prefix and nothing
|
|
22
|
+
* else; "Missing required environment variable" matches neither ENV_GAP_OUTPUT_RE
|
|
23
|
+
* nor INFRA_GAP_OUTPUT_RE, so it is a hard FAIL; and the only way to supply the
|
|
24
|
+
* value is a gitignored `.env`, whose writes nexttask 4 correctly refuses to
|
|
25
|
+
* credit. The static half of this already shipped and WORKED — `.env.example`
|
|
26
|
+
* declares ADMIN_PHONE/ADMIN_PASSWORD, so `findMissingEnvDeclarations` was
|
|
27
|
+
* correctly silent. This is the execution half.
|
|
28
|
+
*
|
|
29
|
+
* FOUR STATIC CONDITIONS, ALL REQUIRED. Deliberately over-constrained: the
|
|
30
|
+
* failure mode of getting this wrong is a gate that excuses real breakage.
|
|
31
|
+
*
|
|
32
|
+
* 1. the script's resolved body names a TRACKED SOURCE FILE
|
|
33
|
+
* (`bun run src/server/seed.ts` → `src/server/seed.ts`);
|
|
34
|
+
* 2. that file REQUIRES an env var `X` under `scanSource` — i.e. none of its
|
|
35
|
+
* step-asides (default / compared / assigned / ambient / …) applies;
|
|
36
|
+
* 3. `X` is DECLARED in the tracked template. If it is NOT,
|
|
37
|
+
* `findMissingEnvDeclarations` has already failed the gate statically and
|
|
38
|
+
* this path must not fire — otherwise the two checks would cancel out and a
|
|
39
|
+
* project with no template at all would gain a blanket excuse;
|
|
40
|
+
* 4. `X` is ABSENT from the env the gate spawned the child with.
|
|
41
|
+
*
|
|
42
|
+
* NOTHING IS PARSED FROM THE CHILD'S STDERR. `Missing required environment
|
|
43
|
+
* variable: ADMIN_PHONE` is a string the PROJECT authored; matching on it would
|
|
44
|
+
* be a rule about one project's phrasing, and every other project would phrase it
|
|
45
|
+
* differently or not at all.
|
|
46
|
+
*
|
|
47
|
+
* THE FIFTH CONDITION IS DYNAMIC, AND IT IS WHAT MAKES THE RULE HONEST. The four
|
|
48
|
+
* above cannot tell "exited BECAUSE the variable is absent" from "exited for its
|
|
49
|
+
* own reasons, and also happens to read an absent variable". A script that throws
|
|
50
|
+
* a TypeError on line 1 and also reads ADMIN_PHONE on line 3 satisfies all four.
|
|
51
|
+
* So the script is re-run once with the gap variables supplied as OBVIOUSLY
|
|
52
|
+
* SYNTHETIC placeholders, and the exit code decides:
|
|
53
|
+
*
|
|
54
|
+
* still non-zero → the absence did not cause it → FAIL, as today
|
|
55
|
+
* now zero → the absence did cause it → skip + UNOBSERVED + debt
|
|
56
|
+
*
|
|
57
|
+
* The probe run is a DIAGNOSTIC, never an observation. Its success is not
|
|
58
|
+
* reported, and the verdict it produces is UNOBSERVED with debt — never a PASS
|
|
59
|
+
* (memory/unobserved-gate-verdict-shipped.md). The placeholder is a fixed
|
|
60
|
+
* harness-authored string; `.env.example`'s own values are never injected,
|
|
61
|
+
* because they are placeholders too (`change-me`) and a green seed run against
|
|
62
|
+
* them would be a fabricated observation.
|
|
63
|
+
*/
|
|
64
|
+
import { readFileSync } from 'node:fs';
|
|
65
|
+
import * as path from 'node:path';
|
|
66
|
+
import { scanSource } from './env-template-closure.js';
|
|
67
|
+
/**
|
|
68
|
+
* The value the probe run supplies. Obviously synthetic and obviously ours, so a
|
|
69
|
+
* seeded row or a log line carrying it is recognisable as harness residue rather
|
|
70
|
+
* than mistaken for real configuration.
|
|
71
|
+
*/
|
|
72
|
+
export const CONFIG_GAP_PROBE_VALUE = 'pi-task-config-gap-probe';
|
|
73
|
+
/** Shell operators that separate commands inside a script body. */
|
|
74
|
+
const SEPARATORS = /[\s;|&()<>]+/;
|
|
75
|
+
/**
|
|
76
|
+
* The tracked source files a script body runs. Purely lexical and deliberately
|
|
77
|
+
* so: every whitespace/operator-separated token, normalised, that the tracked set
|
|
78
|
+
* contains. A token that is not a tracked file (a flag, the runner, a bare word)
|
|
79
|
+
* cannot match, and a body that names no tracked file yields nothing — which
|
|
80
|
+
* fails condition 1 and ends the check.
|
|
81
|
+
*/
|
|
82
|
+
export function bodySourceFiles(body, tracked) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const raw of body.split(SEPARATORS)) {
|
|
85
|
+
const tok = raw
|
|
86
|
+
.trim()
|
|
87
|
+
.replace(/^["']|["']$/g, '')
|
|
88
|
+
.replace(/^\.\//, '');
|
|
89
|
+
if (tok.length === 0)
|
|
90
|
+
continue;
|
|
91
|
+
if (tracked.has(tok) && !out.includes(tok))
|
|
92
|
+
out.push(tok);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The static half: conditions 1–4. Returns null when ANY of them fails, which is
|
|
98
|
+
* the common case and means "behave exactly as before".
|
|
99
|
+
*
|
|
100
|
+
* `declared` is the union of every tracked template's variables. When it is empty
|
|
101
|
+
* the tree has no template, condition 3 can never hold, and this always returns
|
|
102
|
+
* null — a project with no template gains no excuse.
|
|
103
|
+
*/
|
|
104
|
+
export function findLaunchConfigGap(args) {
|
|
105
|
+
if (!args.body || args.declared.size === 0)
|
|
106
|
+
return null;
|
|
107
|
+
const trackedSet = new Set(args.tracked);
|
|
108
|
+
const files = bodySourceFiles(args.body, trackedSet);
|
|
109
|
+
if (files.length === 0)
|
|
110
|
+
return null; // condition 1
|
|
111
|
+
const variables = [];
|
|
112
|
+
const reads = [];
|
|
113
|
+
let file = null;
|
|
114
|
+
for (const f of files) {
|
|
115
|
+
let text;
|
|
116
|
+
try {
|
|
117
|
+
text = readFileSync(path.join(args.cwd, f), 'utf8');
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (const r of scanSource(f, text)) {
|
|
123
|
+
if (r.stepAside !== null)
|
|
124
|
+
continue; // condition 2: REQUIRED only
|
|
125
|
+
if (!args.declared.has(r.name))
|
|
126
|
+
continue; // condition 3: template declares it
|
|
127
|
+
if (args.env[r.name] !== undefined)
|
|
128
|
+
continue; // condition 4: absent here
|
|
129
|
+
if (variables.includes(r.name))
|
|
130
|
+
continue;
|
|
131
|
+
variables.push(r.name);
|
|
132
|
+
reads.push(r);
|
|
133
|
+
file ??= f;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (variables.length === 0 || file === null)
|
|
137
|
+
return null;
|
|
138
|
+
return { script: args.script, file, variables, reads };
|
|
139
|
+
}
|
|
140
|
+
/** The env for the probe re-run: the gate's own env plus a synthetic value per
|
|
141
|
+
* gap variable. Nothing else changes, so a second non-zero exit is the script's
|
|
142
|
+
* own fault and not the harness's. */
|
|
143
|
+
export function probeEnv(base, gap) {
|
|
144
|
+
const out = { ...base };
|
|
145
|
+
for (const v of gap.variables)
|
|
146
|
+
out[v] = CONFIG_GAP_PROBE_VALUE;
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
/** The UNOBSERVED warning: names the script and every variable, so a human can
|
|
150
|
+
* supply them and re-run by hand. */
|
|
151
|
+
export function configGapUnobservedNote(gap) {
|
|
152
|
+
const site = gap.reads[0];
|
|
153
|
+
return (`launch script \`${gap.script}\` could not run here — ${gap.file}`
|
|
154
|
+
+ `${site ? `:${site.line}` : ''} requires ${gap.variables.map(v => `\`${v}\``).join(', ')}, `
|
|
155
|
+
+ `which the tracked env template DECLARES but this environment does not supply. `
|
|
156
|
+
+ `Re-running it with the value(s) present exits 0, so the failure is a CONFIG GAP, not a `
|
|
157
|
+
+ `code fault — UNOBSERVED: the launch surface was not exercised here. Supply `
|
|
158
|
+
+ `${gap.variables.join(', ')} and run \`bun run ${gap.script}\` to observe it.`);
|
|
159
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Which manifest kind the diff resolved for a tree. `none` ⇒ the diff is INERT. */
|
|
2
|
+
export type LaunchManifestKind = 'npm' | 'make' | 'none';
|
|
3
|
+
export interface LaunchManifest {
|
|
4
|
+
kind: LaunchManifestKind;
|
|
5
|
+
/** The manifest the diff is taken against, for the failure text. '' when none. */
|
|
6
|
+
file: string;
|
|
7
|
+
/** Entrypoint names the manifest exposes: npm script keys / Makefile targets. */
|
|
8
|
+
names: string[];
|
|
9
|
+
/** Why nothing is checkable (kind === 'none' only). */
|
|
10
|
+
why?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The explicit targets a Makefile declares. Deliberately conservative — this list
|
|
14
|
+
* only ever has to answer "does the project expose `migrate`", so a missed exotic
|
|
15
|
+
* target costs a false FAIL and is the one error worth avoiding:
|
|
16
|
+
* • recipe lines (leading TAB) are skipped — they are shell, not targets;
|
|
17
|
+
* • `:=` / `::=` / `?=` / `+=` assignments are skipped;
|
|
18
|
+
* • pattern rules (`%.o: %.c`) and dot-targets (`.PHONY:`) fail TARGET_NAME_RE,
|
|
19
|
+
* which is also why the names listed AFTER `.PHONY:` are ignored here: they are
|
|
20
|
+
* prerequisites, and every one of them that is real is declared by its own rule.
|
|
21
|
+
* Multiple targets on one line (`build test:`) all count.
|
|
22
|
+
*/
|
|
23
|
+
export declare function makeTargets(src: string): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the manifest the launch contract may be diffed against.
|
|
26
|
+
*
|
|
27
|
+
* A package.json that exists but does not parse resolves to `none`, not to "zero
|
|
28
|
+
* scripts": diffing against a manifest you could not read is exactly the mistake
|
|
29
|
+
* this module exists to stop, and a broken manifest is the static checks' business.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readLaunchManifest(cwd: string): LaunchManifest;
|
|
32
|
+
/**
|
|
33
|
+
* The one line an INERT contract leaves behind. It rides in the gate's UNOBSERVED
|
|
34
|
+
* channel rather than in `warnings` for the reason nexttask 16A gives: a check that
|
|
35
|
+
* silently did nothing must not read later as a check that passed.
|
|
36
|
+
*/
|
|
37
|
+
export declare function inertLaunchContractNote(declared: string[], manifest: LaunchManifest): string;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launch-manifest — WHICH manifest the launch-contract diff is entitled to diff
|
|
3
|
+
* against, and whether there is one at all (nexttask 16A).
|
|
4
|
+
*
|
|
5
|
+
* The defect this closes (unobserved, proven by construction). The extraction end
|
|
6
|
+
* of the launch contract has no ecosystem test anywhere in it:
|
|
7
|
+
* `enumerateScriptCandidates` scrapes backticked, script-name-shaped tokens out of
|
|
8
|
+
* any design paragraph that says "script", and `keepGroundedScripts` can only DROP
|
|
9
|
+
* a candidate. So a CMake / cargo / poetry / Makefile design saying *"the Makefile
|
|
10
|
+
* must expose `build`, `test`, `migrate`"* records exactly the same artifact an npm
|
|
11
|
+
* design does. The diff end hardcoded the ecosystem anyway:
|
|
12
|
+
*
|
|
13
|
+
* missingDeclaredScripts(declared, Object.keys(packageScripts(cwd)))
|
|
14
|
+
* …where packageScripts' catch returns {} — so "this project has no
|
|
15
|
+
* package.json" was indistinguishable from "its package.json declares no
|
|
16
|
+
* scripts", and every declared script was reported missing, at rank 1, in
|
|
17
|
+
* wording that NAMES a file the project was never meant to have.
|
|
18
|
+
*
|
|
19
|
+
* That text seeds the autofix child's prompt (final-gate.ts's FinalGateOutcome.reason),
|
|
20
|
+
* so the most likely repair on a CMake project was to write a package.json.
|
|
21
|
+
*
|
|
22
|
+
* IAR1 is how close this got: `plan-debug.log:10` records "launch-contract
|
|
23
|
+
* extraction: 0 grounded script(s) kept from 3 emitted" — a non-npm project that
|
|
24
|
+
* reached extraction and emitted three candidates, saved only by its design not
|
|
25
|
+
* backticking them in a "script" paragraph.
|
|
26
|
+
*
|
|
27
|
+
* THE RULE, and it is deliberately two ecosystems wide, not three:
|
|
28
|
+
* • package.json present and parseable ⇒ diff against its `scripts` keys, exactly
|
|
29
|
+
* as before (byte-identical failure text on every npm tree — mx5's missing
|
|
30
|
+
* `build`/`seed` is a TRUE positive and must keep failing).
|
|
31
|
+
* • no package.json but a Makefile ⇒ diff against its TARGETS. The capability was
|
|
32
|
+
* already in final-gate.ts (`makeHasTarget`); the diff simply never called it.
|
|
33
|
+
* • neither ⇒ the check is INERT. No failure, and a note saying the contract was
|
|
34
|
+
* recorded but is not checkable here, so the silence is not read later as a pass
|
|
35
|
+
* (the repo-health-check / env-template-closure discipline: ENOENT = pass, and a
|
|
36
|
+
* check must never invent a file the project chose not to have).
|
|
37
|
+
*
|
|
38
|
+
* cargo/poetry/gradle are NOT here on purpose. The corpus on this box contains one
|
|
39
|
+
* project that ever recorded a launch contract (mx5, npm) and one non-npm project
|
|
40
|
+
* that reached extraction (IAR1, CMake). A third ecosystem would be a guess.
|
|
41
|
+
*/
|
|
42
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
43
|
+
import * as path from 'node:path';
|
|
44
|
+
/** GNU make's own lookup order, and nothing beyond it — this is one ecosystem. */
|
|
45
|
+
const MAKEFILE_NAMES = ['GNUmakefile', 'makefile', 'Makefile'];
|
|
46
|
+
/** A target we are willing to name in a failure: an ordinary word-shaped target. */
|
|
47
|
+
const TARGET_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/i;
|
|
48
|
+
/**
|
|
49
|
+
* A variable assignment, in every form make accepts (`=`, `:=`, `::=`, `?=`, `+=`,
|
|
50
|
+
* `!=`, with an optional `override`/`export`). Matched BEFORE the target rule
|
|
51
|
+
* because `FLAGS ::= -O2` otherwise reads as a target named `FLAGS`.
|
|
52
|
+
*/
|
|
53
|
+
const ASSIGN_RE = /^(?:override\s+|export\s+)?[A-Za-z_][A-Za-z0-9_.]*\s*(?:::|:|\?|\+|!)?=/;
|
|
54
|
+
/**
|
|
55
|
+
* The explicit targets a Makefile declares. Deliberately conservative — this list
|
|
56
|
+
* only ever has to answer "does the project expose `migrate`", so a missed exotic
|
|
57
|
+
* target costs a false FAIL and is the one error worth avoiding:
|
|
58
|
+
* • recipe lines (leading TAB) are skipped — they are shell, not targets;
|
|
59
|
+
* • `:=` / `::=` / `?=` / `+=` assignments are skipped;
|
|
60
|
+
* • pattern rules (`%.o: %.c`) and dot-targets (`.PHONY:`) fail TARGET_NAME_RE,
|
|
61
|
+
* which is also why the names listed AFTER `.PHONY:` are ignored here: they are
|
|
62
|
+
* prerequisites, and every one of them that is real is declared by its own rule.
|
|
63
|
+
* Multiple targets on one line (`build test:`) all count.
|
|
64
|
+
*/
|
|
65
|
+
export function makeTargets(src) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const raw of src.replace(/\r\n?/g, '\n').split('\n')) {
|
|
69
|
+
if (raw.startsWith('\t') || raw.trim().length === 0 || raw.trimStart().startsWith('#'))
|
|
70
|
+
continue;
|
|
71
|
+
if (ASSIGN_RE.test(raw))
|
|
72
|
+
continue;
|
|
73
|
+
const m = /^([^:#=]+):(?![=])/.exec(raw);
|
|
74
|
+
if (!m)
|
|
75
|
+
continue;
|
|
76
|
+
for (const tok of m[1].trim().split(/\s+/)) {
|
|
77
|
+
if (!TARGET_NAME_RE.test(tok))
|
|
78
|
+
continue;
|
|
79
|
+
const key = tok.toLowerCase();
|
|
80
|
+
if (seen.has(key))
|
|
81
|
+
continue;
|
|
82
|
+
seen.add(key);
|
|
83
|
+
out.push(tok);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the manifest the launch contract may be diffed against.
|
|
90
|
+
*
|
|
91
|
+
* A package.json that exists but does not parse resolves to `none`, not to "zero
|
|
92
|
+
* scripts": diffing against a manifest you could not read is exactly the mistake
|
|
93
|
+
* this module exists to stop, and a broken manifest is the static checks' business.
|
|
94
|
+
*/
|
|
95
|
+
export function readLaunchManifest(cwd) {
|
|
96
|
+
const pkg = path.join(cwd, 'package.json');
|
|
97
|
+
if (existsSync(pkg)) {
|
|
98
|
+
try {
|
|
99
|
+
const j = JSON.parse(readFileSync(pkg, 'utf8'));
|
|
100
|
+
return { kind: 'npm', file: 'package.json', names: Object.keys(j.scripts ?? {}) };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { kind: 'none', file: '', names: [], why: 'its package.json could not be parsed' };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const name of MAKEFILE_NAMES) {
|
|
107
|
+
const mk = path.join(cwd, name);
|
|
108
|
+
if (!existsSync(mk))
|
|
109
|
+
continue;
|
|
110
|
+
try {
|
|
111
|
+
return { kind: 'make', file: name, names: makeTargets(readFileSync(mk, 'utf8')) };
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { kind: 'none', file: '', names: [], why: 'it has no npm manifest and no Makefile' };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The one line an INERT contract leaves behind. It rides in the gate's UNOBSERVED
|
|
121
|
+
* channel rather than in `warnings` for the reason nexttask 16A gives: a check that
|
|
122
|
+
* silently did nothing must not read later as a check that passed.
|
|
123
|
+
*/
|
|
124
|
+
export function inertLaunchContractNote(declared, manifest) {
|
|
125
|
+
return (`launch contract: ${declared.length} declared script(s) (${declared.join(', ')}) were recorded, `
|
|
126
|
+
+ `but this project could not be diffed against a manifest — ${manifest.why ?? 'no manifest'}. `
|
|
127
|
+
+ 'The contract was NOT checked here.');
|
|
128
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows which repo paths are regenerable machine OUTPUT.
|
|
3
|
+
*
|
|
4
|
+
* This module exists because the knowledge was in exactly one module and two
|
|
5
|
+
* others needed it (mx5 run 20). `git-state-guard.ts` knew `test-results/` was
|
|
6
|
+
* regenerable and used it for VERDICT decisions only; `write-guard.ts`'s deletion
|
|
7
|
+
* guard did not, so it rejected two whole fix attempts over three Playwright
|
|
8
|
+
* failure screenshots — taking each attempt's real `src/client/api.test.tsx`
|
|
9
|
+
* repair with them — and `auto-commit.ts` did not, so its blind `git add -A` is
|
|
10
|
+
* how the screenshots became tracked in the first place. Three modules, one fact,
|
|
11
|
+
* shared with neither of the two that needed it. Import from here; do not copy.
|
|
12
|
+
*
|
|
13
|
+
* TWO LISTS, and the split between them is the whole point.
|
|
14
|
+
*
|
|
15
|
+
* `REGENERABLE_ARTIFACT_PATTERNS` is the VERDICT list: a gate child that rewrote
|
|
16
|
+
* one of these did not mutate the work under judgement, so its verdict stands.
|
|
17
|
+
* Being wrong here costs a discarded verify.
|
|
18
|
+
*
|
|
19
|
+
* `DELETION_EXEMPT_ARTIFACT_PATTERNS` is the strict SUBSET a fix pass may DELETE
|
|
20
|
+
* without its whole attempt being rejected. Being wrong here destroys a
|
|
21
|
+
* deliverable, so it is narrower, and `dist/`, `build/`, `.next/`, `.turbo/` and
|
|
22
|
+
* `.svelte-kit/` are deliberately NOT in it.
|
|
23
|
+
*
|
|
24
|
+
* MEASURED over 269 git work trees under ~/hub, ~/tmp, ~/.cache
|
|
25
|
+
* (`scripts/tracked-artifact-baserate.ts`, 2026-08-07):
|
|
26
|
+
*
|
|
27
|
+
* trees tracking any artifact path 33 (33 of 33 have .pi-tasks)
|
|
28
|
+
* test-results/ 33 trees / 33 files
|
|
29
|
+
* .last-run.json 33 trees / 33 files ← the SAME 33 files
|
|
30
|
+
* dist/, build/, .next/, .turbo/, .svelte-kit/, playwright-report/,
|
|
31
|
+
* coverage/, .nyc_output/, *.tsbuildinfo 0 trees
|
|
32
|
+
*
|
|
33
|
+
* So `test-results/` and `.last-run.json` are exempt on MEASUREMENT — they are the
|
|
34
|
+
* only artifact paths this corpus tracks at all, and the one real episode is
|
|
35
|
+
* exactly a tracked `test-results/` deletion. `playwright-report/`, `coverage/`,
|
|
36
|
+
* `.nyc_output/` and `*.tsbuildinfo` are exempt BY CONSTRUCTION with zero corpus
|
|
37
|
+
* evidence either way: none of the four has a hand-authored form, so there is no
|
|
38
|
+
* version of them that is a deliverable. `dist/` and its family have exactly the
|
|
39
|
+
* hand-authored form the other six lack — a published package's `dist/` IS the
|
|
40
|
+
* shipped artifact — and with 0 tracked instances here the exemption would never
|
|
41
|
+
* fire anyway, so it would buy nothing while carrying that risk.
|
|
42
|
+
*
|
|
43
|
+
* 32 of the 33 trees are one A/B harness's delivery trees built from a single mx5
|
|
44
|
+
* DESIGN/PROJECT.md: 32 independent RUNS of one project shape, which is evidence
|
|
45
|
+
* of reproducibility and not of breadth. The 33rd is mx5 itself.
|
|
46
|
+
*/
|
|
47
|
+
/** Directory prefixes and file names that are regenerable test/build output. */
|
|
48
|
+
export declare const REGENERABLE_ARTIFACT_PATTERNS: readonly RegExp[];
|
|
49
|
+
/**
|
|
50
|
+
* The strict subset a fix pass may delete, and the pipeline must not newly track.
|
|
51
|
+
*
|
|
52
|
+
* Every entry here is output a test runner rewrites on its next run. Absent from
|
|
53
|
+
* this list, and staying absent: `dist/`, `build/`, `.next/`, `.turbo/`,
|
|
54
|
+
* `.svelte-kit/` — see the header.
|
|
55
|
+
*/
|
|
56
|
+
export declare const DELETION_EXEMPT_ARTIFACT_PATTERNS: readonly RegExp[];
|
|
57
|
+
/** Human-readable form of the exempt list, for trail lines and pathspecs. */
|
|
58
|
+
export declare const DELETION_EXEMPT_ARTIFACT_GLOBS: readonly string[];
|
|
59
|
+
/** Is this path regenerable machine output, for VERDICT purposes? */
|
|
60
|
+
export declare function isRegenerableArtifact(relPath: string): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* May a fix pass DELETE this path without its whole attempt being rejected, and
|
|
63
|
+
* may the pipeline leave it untracked? Narrower than `isRegenerableArtifact` on
|
|
64
|
+
* purpose: build output is excluded because a committed one can be the product.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isDeletionExemptArtifact(relPath: string): boolean;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows which repo paths are regenerable machine OUTPUT.
|
|
3
|
+
*
|
|
4
|
+
* This module exists because the knowledge was in exactly one module and two
|
|
5
|
+
* others needed it (mx5 run 20). `git-state-guard.ts` knew `test-results/` was
|
|
6
|
+
* regenerable and used it for VERDICT decisions only; `write-guard.ts`'s deletion
|
|
7
|
+
* guard did not, so it rejected two whole fix attempts over three Playwright
|
|
8
|
+
* failure screenshots — taking each attempt's real `src/client/api.test.tsx`
|
|
9
|
+
* repair with them — and `auto-commit.ts` did not, so its blind `git add -A` is
|
|
10
|
+
* how the screenshots became tracked in the first place. Three modules, one fact,
|
|
11
|
+
* shared with neither of the two that needed it. Import from here; do not copy.
|
|
12
|
+
*
|
|
13
|
+
* TWO LISTS, and the split between them is the whole point.
|
|
14
|
+
*
|
|
15
|
+
* `REGENERABLE_ARTIFACT_PATTERNS` is the VERDICT list: a gate child that rewrote
|
|
16
|
+
* one of these did not mutate the work under judgement, so its verdict stands.
|
|
17
|
+
* Being wrong here costs a discarded verify.
|
|
18
|
+
*
|
|
19
|
+
* `DELETION_EXEMPT_ARTIFACT_PATTERNS` is the strict SUBSET a fix pass may DELETE
|
|
20
|
+
* without its whole attempt being rejected. Being wrong here destroys a
|
|
21
|
+
* deliverable, so it is narrower, and `dist/`, `build/`, `.next/`, `.turbo/` and
|
|
22
|
+
* `.svelte-kit/` are deliberately NOT in it.
|
|
23
|
+
*
|
|
24
|
+
* MEASURED over 269 git work trees under ~/hub, ~/tmp, ~/.cache
|
|
25
|
+
* (`scripts/tracked-artifact-baserate.ts`, 2026-08-07):
|
|
26
|
+
*
|
|
27
|
+
* trees tracking any artifact path 33 (33 of 33 have .pi-tasks)
|
|
28
|
+
* test-results/ 33 trees / 33 files
|
|
29
|
+
* .last-run.json 33 trees / 33 files ← the SAME 33 files
|
|
30
|
+
* dist/, build/, .next/, .turbo/, .svelte-kit/, playwright-report/,
|
|
31
|
+
* coverage/, .nyc_output/, *.tsbuildinfo 0 trees
|
|
32
|
+
*
|
|
33
|
+
* So `test-results/` and `.last-run.json` are exempt on MEASUREMENT — they are the
|
|
34
|
+
* only artifact paths this corpus tracks at all, and the one real episode is
|
|
35
|
+
* exactly a tracked `test-results/` deletion. `playwright-report/`, `coverage/`,
|
|
36
|
+
* `.nyc_output/` and `*.tsbuildinfo` are exempt BY CONSTRUCTION with zero corpus
|
|
37
|
+
* evidence either way: none of the four has a hand-authored form, so there is no
|
|
38
|
+
* version of them that is a deliverable. `dist/` and its family have exactly the
|
|
39
|
+
* hand-authored form the other six lack — a published package's `dist/` IS the
|
|
40
|
+
* shipped artifact — and with 0 tracked instances here the exemption would never
|
|
41
|
+
* fire anyway, so it would buy nothing while carrying that risk.
|
|
42
|
+
*
|
|
43
|
+
* 32 of the 33 trees are one A/B harness's delivery trees built from a single mx5
|
|
44
|
+
* DESIGN/PROJECT.md: 32 independent RUNS of one project shape, which is evidence
|
|
45
|
+
* of reproducibility and not of breadth. The 33rd is mx5 itself.
|
|
46
|
+
*/
|
|
47
|
+
/** Directory prefixes and file names that are regenerable test/build output. */
|
|
48
|
+
export const REGENERABLE_ARTIFACT_PATTERNS = [
|
|
49
|
+
/^(?:test-results|playwright-report|coverage|\.nyc_output|dist|build|\.next|\.turbo|\.svelte-kit)\//,
|
|
50
|
+
/(?:^|\/)\.last-run\.json$/,
|
|
51
|
+
/\.tsbuildinfo$/
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* The strict subset a fix pass may delete, and the pipeline must not newly track.
|
|
55
|
+
*
|
|
56
|
+
* Every entry here is output a test runner rewrites on its next run. Absent from
|
|
57
|
+
* this list, and staying absent: `dist/`, `build/`, `.next/`, `.turbo/`,
|
|
58
|
+
* `.svelte-kit/` — see the header.
|
|
59
|
+
*/
|
|
60
|
+
export const DELETION_EXEMPT_ARTIFACT_PATTERNS = [
|
|
61
|
+
/^(?:test-results|playwright-report|coverage|\.nyc_output)\//,
|
|
62
|
+
/(?:^|\/)\.last-run\.json$/,
|
|
63
|
+
/\.tsbuildinfo$/
|
|
64
|
+
];
|
|
65
|
+
/** Human-readable form of the exempt list, for trail lines and pathspecs. */
|
|
66
|
+
export const DELETION_EXEMPT_ARTIFACT_GLOBS = [
|
|
67
|
+
'test-results/',
|
|
68
|
+
'playwright-report/',
|
|
69
|
+
'coverage/',
|
|
70
|
+
'.nyc_output/',
|
|
71
|
+
'.last-run.json',
|
|
72
|
+
'*.tsbuildinfo'
|
|
73
|
+
];
|
|
74
|
+
const normalize = (relPath) => relPath.replace(/\\/g, '/');
|
|
75
|
+
/** Is this path regenerable machine output, for VERDICT purposes? */
|
|
76
|
+
export function isRegenerableArtifact(relPath) {
|
|
77
|
+
const p = normalize(relPath);
|
|
78
|
+
return REGENERABLE_ARTIFACT_PATTERNS.some(re => re.test(p));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* May a fix pass DELETE this path without its whole attempt being rejected, and
|
|
82
|
+
* may the pipeline leave it untracked? Narrower than `isRegenerableArtifact` on
|
|
83
|
+
* purpose: build output is excluded because a committed one can be the product.
|
|
84
|
+
*/
|
|
85
|
+
export function isDeletionExemptArtifact(relPath) {
|
|
86
|
+
const p = normalize(relPath);
|
|
87
|
+
return DELETION_EXEMPT_ARTIFACT_PATTERNS.some(re => re.test(p));
|
|
88
|
+
}
|
package/dist/task/task-gates.js
CHANGED
|
@@ -391,6 +391,17 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
391
391
|
const commit = await deps.commit(p.cwd, `task: ${p.title} (${p.taskId})`);
|
|
392
392
|
if (commit.committed) {
|
|
393
393
|
await rec(`commit: task snapshot committed${commit.note ? ` (${commit.note})` : ''}`);
|
|
394
|
+
// SAY WHAT WAS LEFT OUT. The stage skips untracked regenerable test-runner
|
|
395
|
+
// output (mx5 run 20: TASK_0027's `git add -A` swept in three Playwright
|
|
396
|
+
// failure screenshots and two later fix attempts were rejected for deleting
|
|
397
|
+
// them). A SILENT exclusion is the same failure class as the silent
|
|
398
|
+
// ignored-path write nexttask 4 closed, so it gets its own trail line.
|
|
399
|
+
if (commit.excluded && commit.excluded.length > 0) {
|
|
400
|
+
await rec(`commit: left ${commit.excluded.length} untracked test-runner artifact(s) out of the `
|
|
401
|
+
+ `snapshot — regenerable output, not deliverables: `
|
|
402
|
+
+ `${commit.excluded.slice(0, 8).join(', ')}`
|
|
403
|
+
+ `${commit.excluded.length > 8 ? `, +${commit.excluded.length - 8} more` : ''}`);
|
|
404
|
+
}
|
|
394
405
|
active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
|
|
395
406
|
}
|
|
396
407
|
else {
|
|
@@ -1,24 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* write-guard — deterministic tree-change accounting for WRITE-CAPABLE gate
|
|
3
|
-
* children (mx5 run 11).
|
|
4
|
-
*
|
|
5
|
-
* The failure class: the final-gate autofix child (read,edit,bash) was added after
|
|
6
|
-
* the run-8 guard generation and inherited NONE of the guards the other
|
|
7
|
-
* write-capable passes carry — no diff capture, no frozen-path deny, no probe
|
|
8
|
-
* scans, free `rm`. Run 11 it deleted `src/client/pages/admin.tsx` (TASK_0008's
|
|
9
|
-
* verified deliverable) to satisfy a recorded debt claim, and the deletion was
|
|
10
|
-
* invisible: nothing even logged what the pass changed.
|
|
11
|
-
*
|
|
12
|
-
* This module is the pure half of the guard stack: parse `git status --porcelain`
|
|
13
|
-
* into a change summary (the diff-capture log line every write-capable child now
|
|
14
|
-
* gets at the gate-deps seam), and classify tracked-file DELETIONS. A fix pass
|
|
15
|
-
* exists to repair the assembled repository, not to shrink it: every tracked file
|
|
16
|
-
* is a committed task's deliverable, so deleting one is rejected outright — with
|
|
17
|
-
* one allowance, a RELOCATION (the same file name reappears as an added file
|
|
18
|
-
* elsewhere, e.g. moving a test the runner was never meant to pick up out of its
|
|
19
|
-
* glob — the legitimate fix shape from run 7). Pure text/path analysis; no git
|
|
20
|
-
* execution, no stack assumptions.
|
|
21
|
-
*/
|
|
22
1
|
/** What a write-capable pass changed, from `git status --porcelain`. */
|
|
23
2
|
export interface TreeChangeSummary {
|
|
24
3
|
/** Tracked files modified in place (includes rename targets). */
|
|
@@ -52,6 +31,21 @@ export declare function parseNameStatusChanges(nameStatus: string): TreeChangeSu
|
|
|
52
31
|
* name, somewhere in the tree). Anything returned here rejects the whole fix
|
|
53
32
|
* attempt — run 11's `rm src/client/pages/admin.tsx` had no corresponding add and
|
|
54
33
|
* destroyed a sibling task's verified deliverable.
|
|
34
|
+
*
|
|
35
|
+
* REGENERABLE TEST-RUNNER OUTPUT IS EXEMPT (mx5 run 20). The guard is pure git: no
|
|
36
|
+
* ecosystem, no exemption list, so three Playwright FAILURE screenshots that
|
|
37
|
+
* TASK_0027's own `git add -A` had swept into a commit read as deliverables. Two
|
|
38
|
+
* consecutive attempts were discarded whole over them — each taking a real
|
|
39
|
+
* `src/client/api.test.tsx` repair with it, which attempt 3 then re-did and kept.
|
|
40
|
+
* 6m14s of an 8m16s gate. And the pipeline committed those same three deletions at
|
|
41
|
+
* the end anyway, in the stranded-fix commit (mx5 5d4147e), so the guard did not
|
|
42
|
+
* even preserve what it rejected two attempts to protect.
|
|
43
|
+
*
|
|
44
|
+
* The exempt list is a strict SUBSET of the verdict-level artifact list and lives
|
|
45
|
+
* in `regenerable-artifacts.ts`. `dist/`, `build/`, `.next/`, `.turbo/` and
|
|
46
|
+
* `.svelte-kit/` are NOT in it: a committed build output can be the shipped
|
|
47
|
+
* artifact, so deleting one is destructive in a way deleting a failure screenshot
|
|
48
|
+
* is not.
|
|
55
49
|
*/
|
|
56
50
|
export declare function findForbiddenDeletions(changes: TreeChangeSummary): string[];
|
|
57
51
|
/** Why an ignored path is (or is not) something the gate may rule on. */
|
package/dist/task/write-guard.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* glob — the legitimate fix shape from run 7). Pure text/path analysis; no git
|
|
20
20
|
* execution, no stack assumptions.
|
|
21
21
|
*/
|
|
22
|
+
import { isDeletionExemptArtifact } from './regenerable-artifacts.js';
|
|
22
23
|
/** Porcelain v1 line: `XY <path>` or `XY <orig> -> <new>` (rename/copy). */
|
|
23
24
|
function splitEntry(raw) {
|
|
24
25
|
if (raw.length < 4)
|
|
@@ -125,12 +126,27 @@ const basename = (p) => {
|
|
|
125
126
|
* name, somewhere in the tree). Anything returned here rejects the whole fix
|
|
126
127
|
* attempt — run 11's `rm src/client/pages/admin.tsx` had no corresponding add and
|
|
127
128
|
* destroyed a sibling task's verified deliverable.
|
|
129
|
+
*
|
|
130
|
+
* REGENERABLE TEST-RUNNER OUTPUT IS EXEMPT (mx5 run 20). The guard is pure git: no
|
|
131
|
+
* ecosystem, no exemption list, so three Playwright FAILURE screenshots that
|
|
132
|
+
* TASK_0027's own `git add -A` had swept into a commit read as deliverables. Two
|
|
133
|
+
* consecutive attempts were discarded whole over them — each taking a real
|
|
134
|
+
* `src/client/api.test.tsx` repair with it, which attempt 3 then re-did and kept.
|
|
135
|
+
* 6m14s of an 8m16s gate. And the pipeline committed those same three deletions at
|
|
136
|
+
* the end anyway, in the stranded-fix commit (mx5 5d4147e), so the guard did not
|
|
137
|
+
* even preserve what it rejected two attempts to protect.
|
|
138
|
+
*
|
|
139
|
+
* The exempt list is a strict SUBSET of the verdict-level artifact list and lives
|
|
140
|
+
* in `regenerable-artifacts.ts`. `dist/`, `build/`, `.next/`, `.turbo/` and
|
|
141
|
+
* `.svelte-kit/` are NOT in it: a committed build output can be the shipped
|
|
142
|
+
* artifact, so deleting one is destructive in a way deleting a failure screenshot
|
|
143
|
+
* is not.
|
|
128
144
|
*/
|
|
129
145
|
export function findForbiddenDeletions(changes) {
|
|
130
146
|
if (changes.deleted.length === 0)
|
|
131
147
|
return [];
|
|
132
148
|
const addedNames = new Set(changes.added.map(basename));
|
|
133
|
-
return changes.deleted.filter(p => !addedNames.has(basename(p)));
|
|
149
|
+
return changes.deleted.filter(p => !addedNames.has(basename(p)) && !isDeletionExemptArtifact(p));
|
|
134
150
|
}
|
|
135
151
|
/**
|
|
136
152
|
* Directory names that are build output or a dependency tree by convention. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.5",
|
|
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",
|