@mjasnikovs/pi-task 0.29.2 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/task/auto-orchestrator.js +30 -1
- package/dist/task/command-shrink.d.ts +105 -0
- package/dist/task/command-shrink.js +410 -0
- package/dist/task/final-gate-fix.d.ts +26 -1
- package/dist/task/final-gate-fix.js +82 -8
- package/dist/task/final-gate.d.ts +15 -0
- package/dist/task/final-gate.js +51 -0
- package/dist/task/gate-deps.d.ts +41 -2
- package/dist/task/gate-deps.js +143 -4
- package/dist/task/write-guard.d.ts +42 -0
- package/dist/task/write-guard.js +108 -0
- package/package.json +1 -1
|
@@ -35,6 +35,7 @@ import { runGatesForTask } from './task-gates.js';
|
|
|
35
35
|
import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
|
|
36
36
|
import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
|
|
37
37
|
import { describeDebt, recordFinalGateUnobservedDebt } from './accept-debt.js';
|
|
38
|
+
import { ignoredWriteTrailLine, ignoredWriteDebtReason } from './write-guard.js';
|
|
38
39
|
import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
|
|
39
40
|
import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
|
|
40
41
|
import { getConfig } from '../config/config.js';
|
|
@@ -1322,6 +1323,11 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1322
1323
|
// after MAX_FINAL_GATE_AUTOFIX attempts that still FAIL the
|
|
1323
1324
|
// autofix card is withdrawn so the loop cannot run unbounded.
|
|
1324
1325
|
let fixAttempts = 0;
|
|
1326
|
+
// Gitignored paths the fix passes have written so far in this
|
|
1327
|
+
// resolution loop (mx5 run 19). Accumulated across attempts: a
|
|
1328
|
+
// `.env` written by a failed attempt is still on disk for the next
|
|
1329
|
+
// one, and that attempt's own before/after diff cannot see it.
|
|
1330
|
+
let ignoredWritten = [];
|
|
1325
1331
|
// Sub-fixes a non-converging autofix attempt left uncommitted.
|
|
1326
1332
|
// Refreshed after every attempt; drives the picker note and the
|
|
1327
1333
|
// terminal commit (mx5 run 13 PROMPT 4 item 3, run 14 item 2b).
|
|
@@ -1446,7 +1452,30 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1446
1452
|
const seed = choice.guidance ?
|
|
1447
1453
|
`${fin.reason}\n\nUser guidance: ${choice.guidance}`
|
|
1448
1454
|
: fin.reason;
|
|
1449
|
-
const fix = await deps.finalGateFix(active, cwd, seed);
|
|
1455
|
+
const fix = await deps.finalGateFix(active, cwd, seed, ignoredWritten);
|
|
1456
|
+
// IGNORED-PATH WRITES (mx5 run 19). The pass wrote file(s)
|
|
1457
|
+
// git ignores, so they are not in the commit and a fresh
|
|
1458
|
+
// clone does not have them. Trailed on EVERY outcome — a
|
|
1459
|
+
// rejected attempt's tracked edits are discarded while its
|
|
1460
|
+
// ignored writes survive on disk — and carried forward, so a
|
|
1461
|
+
// later attempt's PASS is judged against everything this loop
|
|
1462
|
+
// wrote, not just its own attempt. PATH NAMES ONLY: an ignored
|
|
1463
|
+
// file's contents (`.env` is the canonical case) never enter a
|
|
1464
|
+
// log, a debt or a child prompt.
|
|
1465
|
+
if (fix.ignoredWrites && fix.ignoredWrites.length > 0) {
|
|
1466
|
+
ignoredWritten = [
|
|
1467
|
+
...new Set([...ignoredWritten, ...fix.ignoredWrites])
|
|
1468
|
+
].sort();
|
|
1469
|
+
await recGate(ignoredWriteTrailLine(fix.ignoredWrites));
|
|
1470
|
+
// Debt only where a verdict can rest on the file: the
|
|
1471
|
+
// probe proved the gate needs it, or the question stayed
|
|
1472
|
+
// open. A write the gate demonstrably does NOT need is
|
|
1473
|
+
// trailed and nothing more — a ledger full of scratch
|
|
1474
|
+
// files is a ledger nobody reads.
|
|
1475
|
+
if (fix.ignoredDependent !== false) {
|
|
1476
|
+
await recordFinalGateUnobservedDebt(cwd, id, ignoredWriteDebtReason(fix.ignoredWrites, fix.ignoredDependent));
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1450
1479
|
if (fix.ok) {
|
|
1451
1480
|
await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
|
|
1452
1481
|
// A converged re-run that observed nothing dynamic is
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCOPE-SHRINK detection for the final-gate fix pass.
|
|
3
|
+
*
|
|
4
|
+
* The label guard (final-gate-fix.ts) compares the SET of discoverable gate
|
|
5
|
+
* commands before and after the fix child. mx5 run 19 walked straight through
|
|
6
|
+
* it: the autofix rewrote `"test": "AGENT=1 bun test"` to
|
|
7
|
+
* `"AGENT=1 bun test ./test"`, the label `bun run test` existed before and
|
|
8
|
+
* after, the set difference was empty, and the gate re-ran a suite that no
|
|
9
|
+
* longer covered the repository — then reported "autofix converged".
|
|
10
|
+
*
|
|
11
|
+
* What changed was not the command's NAME but its SCOPE. This module compares
|
|
12
|
+
* the RESOLVED BODIES (`scripts[name]` for package.json, the recipe lines for a
|
|
13
|
+
* Makefile target) and classifies each change as narrow / widen / neutral.
|
|
14
|
+
*
|
|
15
|
+
* A narrowing is defined MECHANICALLY, four shapes only:
|
|
16
|
+
* 1. a path/glob argument is ADDED to a command member that had none
|
|
17
|
+
* (`bun test` → `bun test ./test`);
|
|
18
|
+
* 2. an existing path/glob argument is replaced by a strict SUBPATH of itself
|
|
19
|
+
* (`eslint src` → `eslint src/server`);
|
|
20
|
+
* 3. an exclusion flag/pattern is ADDED (`--ignore`, `--exclude`,
|
|
21
|
+
* `--testPathIgnorePatterns`, `-x`, …);
|
|
22
|
+
* 4. a config-file argument is swapped for one the fix pass itself created.
|
|
23
|
+
*
|
|
24
|
+
* With ONE excuse, measured on mx5@a9c6145 and documented at
|
|
25
|
+
* `excusedByRelocation`: rule 3 does not fire when the same pass hands the
|
|
26
|
+
* excluded input to another command the gate already runs.
|
|
27
|
+
*
|
|
28
|
+
* Everything else passes: new env vars, added flags that do not restrict the
|
|
29
|
+
* input set, reordering, added chain members, whole new scripts. The guard's
|
|
30
|
+
* job is to stop the fix pass from silently redefining the gate's own success
|
|
31
|
+
* criterion — not to review the fix.
|
|
32
|
+
*
|
|
33
|
+
* PURE by construction (no fs, no git): the live guard feeds it two body maps
|
|
34
|
+
* from `discoverGateCommandBodies`, the replay harness feeds it two maps built
|
|
35
|
+
* from git blobs. Same code decides both.
|
|
36
|
+
*/
|
|
37
|
+
/** Which of the four mechanical shapes fired. */
|
|
38
|
+
export type ShrinkKind = 'path-added' | 'path-narrowed' | 'exclusion-added' | 'config-swapped';
|
|
39
|
+
export type BodyChangeClass = 'narrow' | 'widen' | 'neutral';
|
|
40
|
+
export interface BodyChange {
|
|
41
|
+
cls: BodyChangeClass;
|
|
42
|
+
/** Every narrowing shape found (empty unless cls === 'narrow'). */
|
|
43
|
+
kinds: ShrinkKind[];
|
|
44
|
+
/** Human-readable one-liners, most specific first. */
|
|
45
|
+
reasons: string[];
|
|
46
|
+
/** Non-deciding observations (e.g. a chain member disappeared) — reported by
|
|
47
|
+
* the base-rate harness, never a rejection on their own. */
|
|
48
|
+
notes: string[];
|
|
49
|
+
}
|
|
50
|
+
export interface ShrinkEnv {
|
|
51
|
+
/** Did the fix pass itself CREATE this path? Powers rule 4 only. Absent →
|
|
52
|
+
* rule 4 is inert (a config swap with no provenance is not evidence). */
|
|
53
|
+
createdByFix?: (p: string) => boolean;
|
|
54
|
+
/** The resolved bodies of the OTHER gate commands after the pass. Powers the
|
|
55
|
+
* RELOCATION excuse on rule 3 (see excusedByRelocation). Absent → no
|
|
56
|
+
* exclusion is ever excused. */
|
|
57
|
+
siblingBodies?: string[];
|
|
58
|
+
}
|
|
59
|
+
export interface Narrowing {
|
|
60
|
+
/** The gate command label whose body narrowed (`bun run test`). */
|
|
61
|
+
label: string;
|
|
62
|
+
before: string;
|
|
63
|
+
after: string;
|
|
64
|
+
kinds: ShrinkKind[];
|
|
65
|
+
reasons: string[];
|
|
66
|
+
}
|
|
67
|
+
/** Split a shell body into chain members, quote-aware: `&&`, `||`, `;`, `|`,
|
|
68
|
+
* and newlines (Makefile recipes arrive as multiple lines). */
|
|
69
|
+
export declare function splitChainMembers(body: string): string[];
|
|
70
|
+
/** Is this token SYNTACTICALLY a path or glob? Deliberately lexical: no fs. */
|
|
71
|
+
export declare function looksLikePath(tok: string): boolean;
|
|
72
|
+
interface MemberShape {
|
|
73
|
+
/** Alignment key: `bin verb` (`bun test`, `bun run lint`, `eslint`). */
|
|
74
|
+
key: string;
|
|
75
|
+
/** Path/glob arguments, excluding flag values. */
|
|
76
|
+
paths: string[];
|
|
77
|
+
/** Every flag seen, normalized to its name (`--ignore=x` → `--ignore`). */
|
|
78
|
+
flags: Set<string>;
|
|
79
|
+
/** Value-flag name → its value (last wins). */
|
|
80
|
+
flagValues: Map<string, string>;
|
|
81
|
+
text: string;
|
|
82
|
+
}
|
|
83
|
+
/** Decompose one chain member into the shape the rules compare. */
|
|
84
|
+
export declare function shapeMember(member: string): MemberShape;
|
|
85
|
+
/** Is `child` strictly INSIDE `parent`? (`''` is the repo root.) */
|
|
86
|
+
export declare function isStrictSubpath(parent: string, child: string): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* The recipe lines of `target:` in a Makefile, joined by newlines (which
|
|
89
|
+
* splitChainMembers treats as member separators), or null when the target has
|
|
90
|
+
* no rule. Continuations (`\` at end of line) are folded into one line.
|
|
91
|
+
*/
|
|
92
|
+
export declare function makefileRecipe(makefileText: string, target: string): string | null;
|
|
93
|
+
/**
|
|
94
|
+
* Classify one command body change. Deterministic, no side conditions beyond
|
|
95
|
+
* the optional `createdByFix` provenance used by rule 4.
|
|
96
|
+
*/
|
|
97
|
+
export declare function classifyBodyChange(before: string, after: string, env?: ShrinkEnv): BodyChange;
|
|
98
|
+
/**
|
|
99
|
+
* Every gate command whose resolved body NARROWED across the fix pass. Labels
|
|
100
|
+
* present on only one side are the label guard's business, not this one's.
|
|
101
|
+
*/
|
|
102
|
+
export declare function findNarrowedCommands(before: Record<string, string>, after: Record<string, string>, env?: ShrinkEnv): Narrowing[];
|
|
103
|
+
/** One-line rejection text for the guard and the log. */
|
|
104
|
+
export declare function narrowingRejectionText(narrowed: Narrowing[]): string;
|
|
105
|
+
export {};
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCOPE-SHRINK detection for the final-gate fix pass.
|
|
3
|
+
*
|
|
4
|
+
* The label guard (final-gate-fix.ts) compares the SET of discoverable gate
|
|
5
|
+
* commands before and after the fix child. mx5 run 19 walked straight through
|
|
6
|
+
* it: the autofix rewrote `"test": "AGENT=1 bun test"` to
|
|
7
|
+
* `"AGENT=1 bun test ./test"`, the label `bun run test` existed before and
|
|
8
|
+
* after, the set difference was empty, and the gate re-ran a suite that no
|
|
9
|
+
* longer covered the repository — then reported "autofix converged".
|
|
10
|
+
*
|
|
11
|
+
* What changed was not the command's NAME but its SCOPE. This module compares
|
|
12
|
+
* the RESOLVED BODIES (`scripts[name]` for package.json, the recipe lines for a
|
|
13
|
+
* Makefile target) and classifies each change as narrow / widen / neutral.
|
|
14
|
+
*
|
|
15
|
+
* A narrowing is defined MECHANICALLY, four shapes only:
|
|
16
|
+
* 1. a path/glob argument is ADDED to a command member that had none
|
|
17
|
+
* (`bun test` → `bun test ./test`);
|
|
18
|
+
* 2. an existing path/glob argument is replaced by a strict SUBPATH of itself
|
|
19
|
+
* (`eslint src` → `eslint src/server`);
|
|
20
|
+
* 3. an exclusion flag/pattern is ADDED (`--ignore`, `--exclude`,
|
|
21
|
+
* `--testPathIgnorePatterns`, `-x`, …);
|
|
22
|
+
* 4. a config-file argument is swapped for one the fix pass itself created.
|
|
23
|
+
*
|
|
24
|
+
* With ONE excuse, measured on mx5@a9c6145 and documented at
|
|
25
|
+
* `excusedByRelocation`: rule 3 does not fire when the same pass hands the
|
|
26
|
+
* excluded input to another command the gate already runs.
|
|
27
|
+
*
|
|
28
|
+
* Everything else passes: new env vars, added flags that do not restrict the
|
|
29
|
+
* input set, reordering, added chain members, whole new scripts. The guard's
|
|
30
|
+
* job is to stop the fix pass from silently redefining the gate's own success
|
|
31
|
+
* criterion — not to review the fix.
|
|
32
|
+
*
|
|
33
|
+
* PURE by construction (no fs, no git): the live guard feeds it two body maps
|
|
34
|
+
* from `discoverGateCommandBodies`, the replay harness feeds it two maps built
|
|
35
|
+
* from git blobs. Same code decides both.
|
|
36
|
+
*/
|
|
37
|
+
// ── tokenizing ──────────────────────────────────────────────────────────────
|
|
38
|
+
/** Split a shell body into chain members, quote-aware: `&&`, `||`, `;`, `|`,
|
|
39
|
+
* and newlines (Makefile recipes arrive as multiple lines). */
|
|
40
|
+
export function splitChainMembers(body) {
|
|
41
|
+
const out = [];
|
|
42
|
+
let cur = '';
|
|
43
|
+
let quote = null;
|
|
44
|
+
for (let i = 0; i < body.length; i++) {
|
|
45
|
+
const c = body[i];
|
|
46
|
+
if (quote) {
|
|
47
|
+
cur += c;
|
|
48
|
+
if (c === quote && body[i - 1] !== '\\')
|
|
49
|
+
quote = null;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === '"' || c === "'") {
|
|
53
|
+
quote = c;
|
|
54
|
+
cur += c;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === '\n' || c === ';') {
|
|
58
|
+
out.push(cur);
|
|
59
|
+
cur = '';
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if ((c === '&' || c === '|') && body[i + 1] === c) {
|
|
63
|
+
out.push(cur);
|
|
64
|
+
cur = '';
|
|
65
|
+
i++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (c === '|') {
|
|
69
|
+
out.push(cur);
|
|
70
|
+
cur = '';
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
cur += c;
|
|
74
|
+
}
|
|
75
|
+
out.push(cur);
|
|
76
|
+
return out.map(s => s.trim()).filter(s => s.length > 0);
|
|
77
|
+
}
|
|
78
|
+
/** Quote-aware word split; surrounding quotes are stripped from each token. */
|
|
79
|
+
function tokenize(member) {
|
|
80
|
+
const out = [];
|
|
81
|
+
let cur = '';
|
|
82
|
+
let quote = null;
|
|
83
|
+
let started = false;
|
|
84
|
+
for (let i = 0; i < member.length; i++) {
|
|
85
|
+
const c = member[i];
|
|
86
|
+
if (quote) {
|
|
87
|
+
if (c === quote && member[i - 1] !== '\\')
|
|
88
|
+
quote = null;
|
|
89
|
+
else
|
|
90
|
+
cur += c;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (c === '"' || c === "'") {
|
|
94
|
+
quote = c;
|
|
95
|
+
started = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (/\s/.test(c)) {
|
|
99
|
+
if (started || cur.length > 0)
|
|
100
|
+
out.push(cur);
|
|
101
|
+
cur = '';
|
|
102
|
+
started = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
cur += c;
|
|
106
|
+
}
|
|
107
|
+
if (started || cur.length > 0)
|
|
108
|
+
out.push(cur);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/** Leading `FOO=bar` env assignments and `sudo`/`exec`/`env`/`time` wrappers,
|
|
112
|
+
* and a Makefile recipe's `@`/`-`/`+` prefix, carry no verb. */
|
|
113
|
+
function stripPrefixes(tokens) {
|
|
114
|
+
const t = [...tokens];
|
|
115
|
+
if (t.length > 0)
|
|
116
|
+
t[0] = t[0].replace(/^[@\-+]+(?=[A-Za-z_./])/, '');
|
|
117
|
+
while (t.length > 0
|
|
118
|
+
&& (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0]) || /^(?:sudo|exec|env|time|nice)$/.test(t[0]))) {
|
|
119
|
+
t.shift();
|
|
120
|
+
}
|
|
121
|
+
return t;
|
|
122
|
+
}
|
|
123
|
+
// ── argument classification ─────────────────────────────────────────────────
|
|
124
|
+
const GLOB_RE = /[*?[\]{}]/;
|
|
125
|
+
/** A conservative source/config extension set — enough to catch a bare file
|
|
126
|
+
* argument (`jest.config.js`) without calling `--log-level 1.2` a path. */
|
|
127
|
+
const EXT_RE = /\.(?:[cm]?[jt]sx?|json|jsonc|toml|ya?ml|md|html?|css|scss|py|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|sh|sql|lock|cfg|ini|conf|xml|txt)$/i;
|
|
128
|
+
/** Is this token SYNTACTICALLY a path or glob? Deliberately lexical: no fs. */
|
|
129
|
+
export function looksLikePath(tok) {
|
|
130
|
+
if (tok.length === 0)
|
|
131
|
+
return false;
|
|
132
|
+
if (tok === '.' || tok === '..')
|
|
133
|
+
return true;
|
|
134
|
+
if (tok.startsWith('-'))
|
|
135
|
+
return false;
|
|
136
|
+
return tok.includes('/') || tok.includes('\\') || GLOB_RE.test(tok) || EXT_RE.test(tok);
|
|
137
|
+
}
|
|
138
|
+
const RUNNER_RE = /^(?:bun|bunx|npm|npx|pnpm|pnpx|yarn|deno|node|make|cargo|go|uv|poetry|pipenv)$/;
|
|
139
|
+
const RUN_VERB_RE = /^(?:run|run-script|exec|dlx|x|task)$/;
|
|
140
|
+
/** Flags whose next token is their VALUE (so it is not itself a path argument).
|
|
141
|
+
* Every exclusion flag that takes a PATTERN belongs here too — otherwise
|
|
142
|
+
* `mocha -x slow` reads as a path argument as well as an exclusion. The
|
|
143
|
+
* boolean-ish exclusions (`--skip`, `--no-tests`) are deliberately absent: they
|
|
144
|
+
* take no value, and consuming the next token would swallow a real path. */
|
|
145
|
+
const VALUE_FLAG_RE = /^--?(?:config|configfile|config-file|c|project|tsconfig|ignore|ignores|ignore-pattern|ignore-patterns|ignore-path|ignorePath|exclude|excludes|excludePattern|testPathIgnorePatterns|path-ignore-patterns|deselect|x|reporter|log-level|loglevel|outdir|outfile|out|target|env-file)$/;
|
|
146
|
+
/** Flags that REMOVE input from the command's set. Adding one narrows scope. */
|
|
147
|
+
const EXCLUSION_FLAG_RE = /^--?(?:ignore|ignores|ignore-pattern|ignore-patterns|ignore-path|ignorePath|exclude|excludes|excludePattern|testPathIgnorePatterns|path-ignore-patterns|deselect|skip|skip-tests|no-tests|x)$/;
|
|
148
|
+
/** Flags that point the command at a configuration file. */
|
|
149
|
+
const CONFIG_FLAG_RE = /^--?(?:config|configfile|config-file|c|project|tsconfig)$/;
|
|
150
|
+
/** Decompose one chain member into the shape the rules compare. */
|
|
151
|
+
export function shapeMember(member) {
|
|
152
|
+
const tokens = stripPrefixes(tokenize(member));
|
|
153
|
+
const shape = {
|
|
154
|
+
key: '',
|
|
155
|
+
paths: [],
|
|
156
|
+
flags: new Set(),
|
|
157
|
+
flagValues: new Map(),
|
|
158
|
+
text: member.trim()
|
|
159
|
+
};
|
|
160
|
+
if (tokens.length === 0)
|
|
161
|
+
return shape;
|
|
162
|
+
const bin = basename(tokens[0]);
|
|
163
|
+
shape.key = bin;
|
|
164
|
+
let verb = '';
|
|
165
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
166
|
+
const tok = tokens[i];
|
|
167
|
+
if (tok.startsWith('-') && tok.length > 1) {
|
|
168
|
+
const eq = tok.indexOf('=');
|
|
169
|
+
const name = eq >= 0 ? tok.slice(0, eq) : tok;
|
|
170
|
+
shape.flags.add(name);
|
|
171
|
+
if (eq >= 0) {
|
|
172
|
+
shape.flagValues.set(name, tok.slice(eq + 1));
|
|
173
|
+
}
|
|
174
|
+
else if (VALUE_FLAG_RE.test(name) && i + 1 < tokens.length) {
|
|
175
|
+
shape.flagValues.set(name, tokens[i + 1]);
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// The verb slot — the token IMMEDIATELY after the bin (`bun TEST …`,
|
|
181
|
+
// `ruff CHECK .`) — and, for a runner, the script-name slot after it
|
|
182
|
+
// (`bun run TEST`) name a target, not a path, unless written as one
|
|
183
|
+
// (`bun run src/x.ts`). Both ride in the alignment key instead. The slot
|
|
184
|
+
// is POSITIONAL on purpose: making it "the first non-flag token" would
|
|
185
|
+
// swallow the first path of `eslint --fix src`, and then reading a
|
|
186
|
+
// second one (`eslint --fix src tests`) as a scope shrink — measured as
|
|
187
|
+
// a false positive on dace-pro@45b6fa0.
|
|
188
|
+
if (!looksLikePath(tok)) {
|
|
189
|
+
if (i === 1) {
|
|
190
|
+
verb = tok;
|
|
191
|
+
shape.key = `${bin} ${tok}`;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (i === 2 && verb !== '' && RUNNER_RE.test(bin) && RUN_VERB_RE.test(verb)) {
|
|
195
|
+
shape.key = `${bin} ${verb} ${tok}`;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// A BARE word elsewhere is a filter/path argument (`bun test test`,
|
|
199
|
+
// `pytest -q tests`) — without this the guard's own lead is one
|
|
200
|
+
// keystroke from a dodge, since `bun test ./test` and `bun test
|
|
201
|
+
// test` do the same thing. Numbers and booleans are flag values, not
|
|
202
|
+
// paths (`jest --maxWorkers 2`).
|
|
203
|
+
if (!/^(?:[\d.]+|true|false)$/.test(tok))
|
|
204
|
+
shape.paths.push(tok);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
shape.paths.push(tok);
|
|
208
|
+
}
|
|
209
|
+
return shape;
|
|
210
|
+
}
|
|
211
|
+
function basename(p) {
|
|
212
|
+
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
|
213
|
+
return i >= 0 ? p.slice(i + 1) : p;
|
|
214
|
+
}
|
|
215
|
+
/** Normalized comparable form: drop `./`, trailing `/`, and everything from the
|
|
216
|
+
* first glob metacharacter on (so `src/**\/*.ts` compares as `src/`). */
|
|
217
|
+
function pathPrefix(p) {
|
|
218
|
+
let s = p.replace(/\\/g, '/');
|
|
219
|
+
const g = s.search(GLOB_RE);
|
|
220
|
+
if (g >= 0)
|
|
221
|
+
s = s.slice(0, g);
|
|
222
|
+
s = s.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
223
|
+
if (s === '.' || s === '')
|
|
224
|
+
return '';
|
|
225
|
+
return s;
|
|
226
|
+
}
|
|
227
|
+
/** Is `child` strictly INSIDE `parent`? (`''` is the repo root.) */
|
|
228
|
+
export function isStrictSubpath(parent, child) {
|
|
229
|
+
const a = pathPrefix(parent);
|
|
230
|
+
const b = pathPrefix(child);
|
|
231
|
+
if (a === b)
|
|
232
|
+
return false;
|
|
233
|
+
if (a === '')
|
|
234
|
+
return b.length > 0;
|
|
235
|
+
return b.startsWith(`${a}/`);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The recipe lines of `target:` in a Makefile, joined by newlines (which
|
|
239
|
+
* splitChainMembers treats as member separators), or null when the target has
|
|
240
|
+
* no rule. Continuations (`\` at end of line) are folded into one line.
|
|
241
|
+
*/
|
|
242
|
+
export function makefileRecipe(makefileText, target) {
|
|
243
|
+
const lines = makefileText.split(/\r?\n/);
|
|
244
|
+
const head = new RegExp(`^${target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:(?!=)`);
|
|
245
|
+
let i = lines.findIndex(l => head.test(l));
|
|
246
|
+
if (i < 0)
|
|
247
|
+
return null;
|
|
248
|
+
const recipe = [];
|
|
249
|
+
for (i++; i < lines.length; i++) {
|
|
250
|
+
const l = lines[i];
|
|
251
|
+
if (l.trim() === '' || l.startsWith('#'))
|
|
252
|
+
continue;
|
|
253
|
+
if (!/^\t/.test(l))
|
|
254
|
+
break;
|
|
255
|
+
let cur = l.replace(/^\t+/, '');
|
|
256
|
+
while (/\\$/.test(cur) && i + 1 < lines.length) {
|
|
257
|
+
cur = `${cur.replace(/\\$/, '')} ${lines[++i].replace(/^\t+/, '')}`;
|
|
258
|
+
}
|
|
259
|
+
recipe.push(cur);
|
|
260
|
+
}
|
|
261
|
+
return recipe.length > 0 ? recipe.join('\n') : null;
|
|
262
|
+
}
|
|
263
|
+
// ── the rules ───────────────────────────────────────────────────────────────
|
|
264
|
+
/**
|
|
265
|
+
* RELOCATION EXCUSE (rule 3 only, measured on mx5@a9c6145).
|
|
266
|
+
*
|
|
267
|
+
* That autofix rewrote `test` from `AGENT=1 bun test` to `AGENT=1 bun test
|
|
268
|
+
* --path-ignore-patterns '**\/*.spec.tsx' && playwright test -c
|
|
269
|
+
* playwright-ct.config.ts`. Read by hand: `test:ct` was ALREADY
|
|
270
|
+
* `playwright test -c playwright-ct.config.ts` and already a discovered gate
|
|
271
|
+
* command, so the excluded specs never left the gate's coverage — the pass
|
|
272
|
+
* separated two runners that collide, it did not shrink what is measured.
|
|
273
|
+
* Rejecting it would be a false positive, and the rule as first written did.
|
|
274
|
+
*
|
|
275
|
+
* The excuse is deliberately narrow and hard to game: the pass must have added
|
|
276
|
+
* a chain member that is VERBATIM (whitespace-normalized) a member of another
|
|
277
|
+
* discovered gate command's body. `&& echo ok` does not qualify; neither does a
|
|
278
|
+
* near-copy pointed at a different config. Nothing excuses rules 1, 2 or 4 —
|
|
279
|
+
* a path narrowing stands on its own.
|
|
280
|
+
*
|
|
281
|
+
* Returns the matching sibling member, or null.
|
|
282
|
+
*/
|
|
283
|
+
function excusedByRelocation(addedMembers, siblingBodies) {
|
|
284
|
+
if (addedMembers.length === 0 || siblingBodies.length === 0)
|
|
285
|
+
return null;
|
|
286
|
+
const norm = (s) => s.trim().replace(/\s+/g, ' ');
|
|
287
|
+
const sibling = new Set(siblingBodies.flatMap(b => splitChainMembers(b)).map(m => norm(m)));
|
|
288
|
+
for (const m of addedMembers) {
|
|
289
|
+
if (sibling.has(norm(m.text)))
|
|
290
|
+
return m.text.trim();
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
/** Align members of two bodies by key; duplicates align in declaration order. */
|
|
295
|
+
function alignMembers(before, after) {
|
|
296
|
+
const pairs = [];
|
|
297
|
+
const pool = [...after];
|
|
298
|
+
const onlyBefore = [];
|
|
299
|
+
for (const b of before) {
|
|
300
|
+
const i = pool.findIndex(a => a.key === b.key);
|
|
301
|
+
if (i < 0)
|
|
302
|
+
onlyBefore.push(b);
|
|
303
|
+
else
|
|
304
|
+
pairs.push([b, pool.splice(i, 1)[0]]);
|
|
305
|
+
}
|
|
306
|
+
return { pairs, onlyBefore, onlyAfter: pool };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Classify one command body change. Deterministic, no side conditions beyond
|
|
310
|
+
* the optional `createdByFix` provenance used by rule 4.
|
|
311
|
+
*/
|
|
312
|
+
export function classifyBodyChange(before, after, env = {}) {
|
|
313
|
+
const out = { cls: 'neutral', kinds: [], reasons: [], notes: [] };
|
|
314
|
+
if (before.trim() === after.trim())
|
|
315
|
+
return out;
|
|
316
|
+
const { pairs, onlyBefore, onlyAfter } = alignMembers(splitChainMembers(before).map(shapeMember), splitChainMembers(after).map(shapeMember));
|
|
317
|
+
let widened = false;
|
|
318
|
+
const narrow = (kind, reason) => {
|
|
319
|
+
out.cls = 'narrow';
|
|
320
|
+
if (!out.kinds.includes(kind))
|
|
321
|
+
out.kinds.push(kind);
|
|
322
|
+
out.reasons.push(reason);
|
|
323
|
+
};
|
|
324
|
+
for (const [b, a] of pairs) {
|
|
325
|
+
// RULE 1 — a path/glob argument added to a member that had none.
|
|
326
|
+
if (b.paths.length === 0 && a.paths.length > 0) {
|
|
327
|
+
narrow('path-added', `\`${b.key}\` ran over the whole project and now runs over ${a.paths.map(p => `\`${p}\``).join(', ')}`);
|
|
328
|
+
}
|
|
329
|
+
else if (b.paths.length > 0 && a.paths.length === 0) {
|
|
330
|
+
widened = true;
|
|
331
|
+
}
|
|
332
|
+
// RULE 2 — an existing path replaced by a strict subpath of itself.
|
|
333
|
+
const goneFromA = b.paths.filter(p => !a.paths.includes(p));
|
|
334
|
+
const newInA = a.paths.filter(p => !b.paths.includes(p));
|
|
335
|
+
for (const p of goneFromA) {
|
|
336
|
+
const sub = newInA.find(q => isStrictSubpath(p, q));
|
|
337
|
+
if (sub) {
|
|
338
|
+
narrow('path-narrowed', `\`${b.key}\` argument \`${p}\` was replaced by its subpath \`${sub}\``);
|
|
339
|
+
}
|
|
340
|
+
else if (newInA.some(q => isStrictSubpath(q, p))) {
|
|
341
|
+
widened = true;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// RULE 3 — an exclusion flag/pattern added, unless the same pass handed
|
|
345
|
+
// the excluded input to another command the gate already runs.
|
|
346
|
+
for (const f of a.flags) {
|
|
347
|
+
if (!EXCLUSION_FLAG_RE.test(f) || b.flags.has(f))
|
|
348
|
+
continue;
|
|
349
|
+
const v = a.flagValues.get(f);
|
|
350
|
+
const relocation = excusedByRelocation(onlyAfter, env.siblingBodies ?? []);
|
|
351
|
+
if (relocation) {
|
|
352
|
+
out.notes.push(`exclusion \`${f}${v ? ` ${v}` : ''}\` excused — relocated to \`${relocation}\``);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
narrow('exclusion-added', `\`${b.key}\` gained the exclusion \`${f}${v ? ` ${v}` : ''}\`, removing input from what the gate measures`);
|
|
356
|
+
}
|
|
357
|
+
for (const f of b.flags) {
|
|
358
|
+
if (EXCLUSION_FLAG_RE.test(f) && !a.flags.has(f))
|
|
359
|
+
widened = true;
|
|
360
|
+
}
|
|
361
|
+
// RULE 4 — a config argument swapped for a file the fix pass created.
|
|
362
|
+
for (const f of a.flags) {
|
|
363
|
+
if (!CONFIG_FLAG_RE.test(f))
|
|
364
|
+
continue;
|
|
365
|
+
const av = a.flagValues.get(f);
|
|
366
|
+
const bv = b.flagValues.get(f);
|
|
367
|
+
if (!av || av === bv)
|
|
368
|
+
continue;
|
|
369
|
+
if (env.createdByFix?.(av)) {
|
|
370
|
+
narrow('config-swapped', `\`${b.key}\` was pointed at \`${av}\`, a config file this fix pass created`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Observations only — a vanished chain member is a scope change the four
|
|
375
|
+
// pre-registered rules do not cover. Reported, never rejected on.
|
|
376
|
+
for (const b of onlyBefore)
|
|
377
|
+
out.notes.push(`member-removed: \`${b.text}\``);
|
|
378
|
+
for (const a of onlyAfter)
|
|
379
|
+
out.notes.push(`member-added: \`${a.text}\``);
|
|
380
|
+
if (out.cls === 'neutral' && widened)
|
|
381
|
+
out.cls = 'widen';
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Every gate command whose resolved body NARROWED across the fix pass. Labels
|
|
386
|
+
* present on only one side are the label guard's business, not this one's.
|
|
387
|
+
*/
|
|
388
|
+
export function findNarrowedCommands(before, after, env = {}) {
|
|
389
|
+
const out = [];
|
|
390
|
+
for (const [label, body] of Object.entries(before)) {
|
|
391
|
+
const now = after[label];
|
|
392
|
+
if (now === undefined)
|
|
393
|
+
continue;
|
|
394
|
+
const siblingBodies = Object.entries(after)
|
|
395
|
+
.filter(([l]) => l !== label)
|
|
396
|
+
.map(([, b]) => b);
|
|
397
|
+
const c = classifyBodyChange(body, now, { siblingBodies, ...env });
|
|
398
|
+
if (c.cls !== 'narrow')
|
|
399
|
+
continue;
|
|
400
|
+
out.push({ label, before: body, after: now, kinds: c.kinds, reasons: c.reasons });
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
/** One-line rejection text for the guard and the log. */
|
|
405
|
+
export function narrowingRejectionText(narrowed) {
|
|
406
|
+
const first = narrowed[0];
|
|
407
|
+
const more = narrowed.length > 1 ? ` (+${narrowed.length - 1} more)` : '';
|
|
408
|
+
return (`fix pass NARROWED the gate's own command \`${first.label}\`${more}: ${first.reasons[0]}`
|
|
409
|
+
+ ` — \`${first.before}\` → \`${first.after}\``);
|
|
410
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type TreeChangeSummary } from './write-guard.js';
|
|
1
|
+
import { type TreeChangeSummary, type IgnoredSnapshot } from './write-guard.js';
|
|
2
2
|
/** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
|
|
3
3
|
* failing command (and the project's own tooling), not to mutate git state. */
|
|
4
4
|
export declare const FINAL_FIX_TOOLS = "read,edit,bash";
|
|
@@ -108,6 +108,14 @@ export interface FinalFixResult {
|
|
|
108
108
|
* labels a converge-on-statics-alone the same way it labels a first-pass one —
|
|
109
109
|
* "converged" must never quietly mean "we stopped being able to check". */
|
|
110
110
|
unobserved?: string;
|
|
111
|
+
/** Gitignored path(s) this fix pass wrote, exempt classes already removed (see
|
|
112
|
+
* write-guard.ts). Present whether or not the gate converged — the caller
|
|
113
|
+
* trails them either way; path names only, never contents. */
|
|
114
|
+
ignoredWrites?: string[];
|
|
115
|
+
/** …and the mechanical dependency probe found the converged gate does NOT pass
|
|
116
|
+
* without them, so `unobserved` above carries the downgrade. Absent when the
|
|
117
|
+
* probe could not answer (no probe wired, restore risk, too many paths). */
|
|
118
|
+
ignoredDependent?: boolean;
|
|
111
119
|
/** A write-guard rejected this attempt (deletion / shrink / probe-gaming). */
|
|
112
120
|
guardTripped?: boolean;
|
|
113
121
|
/** …and its edits were discarded. When a guard tripped and this is false, the
|
|
@@ -136,6 +144,10 @@ export interface FinalFixDeps {
|
|
|
136
144
|
/** Labels of every currently-discoverable gate command (static + integration),
|
|
137
145
|
* for the shrink guard. Pure discovery — nothing is executed. */
|
|
138
146
|
discoverLabels: (cwd: string) => string[];
|
|
147
|
+
/** The same commands' RESOLVED BODIES (`label → scripts[name]` / Makefile
|
|
148
|
+
* recipe), for the scope-shrink half of the guard. Absent → only the label
|
|
149
|
+
* comparison runs, i.e. the pre-run-19 behaviour. */
|
|
150
|
+
discoverBodies?: (cwd: string) => Record<string, string>;
|
|
139
151
|
/** Discard the fix child's working-tree edits (guard trips only). Absent
|
|
140
152
|
* → the violation is still rejected, edits are left for inspection. */
|
|
141
153
|
discard?: (cwd: string) => Promise<void>;
|
|
@@ -154,6 +166,19 @@ export interface FinalFixDeps {
|
|
|
154
166
|
* run 11's autofix replaced the typed client with a hand-written contract
|
|
155
167
|
* copy to green the lint. Findings are verbatim offending lines. */
|
|
156
168
|
probeScan?: () => Promise<string[]>;
|
|
169
|
+
/** Fingerprint of the ACTIONABLE ignored paths (build output and node_modules
|
|
170
|
+
* already exempt). Called before and after the child; the difference is what
|
|
171
|
+
* this pass wrote. Absent → the channel is off and behaviour is unchanged. */
|
|
172
|
+
ignoredSnapshot?: () => Promise<IgnoredSnapshot>;
|
|
173
|
+
/** Ignored paths EARLIER attempts in this resolution loop already wrote. An
|
|
174
|
+
* attempt that fails still leaves its ignored writes on disk (discard reverts
|
|
175
|
+
* tracked files only), so without this a `.env` written by attempt 1 would be
|
|
176
|
+
* invisible to attempt 2's before/after diff — and attempt 2's converged PASS
|
|
177
|
+
* would rest on it unrecorded. The caller accumulates. */
|
|
178
|
+
ignoredKnown?: string[];
|
|
179
|
+
/** The mechanical dependency test: does the gate still pass with these paths
|
|
180
|
+
* moved aside? `null` ⇒ unanswerable, which never downgrades a verdict. */
|
|
181
|
+
gateWithoutIgnored?: (paths: string[]) => Promise<boolean | null>;
|
|
157
182
|
/** Write a timestamped line to the gate debug log (guard events). */
|
|
158
183
|
log?: (msg: string) => void;
|
|
159
184
|
}
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
* producing task). It activates only when a run-GLOBAL freeze source exists.
|
|
45
45
|
*/
|
|
46
46
|
import { USER_CANCELLED } from './child-runner.js';
|
|
47
|
-
import { findForbiddenDeletions } from './write-guard.js';
|
|
47
|
+
import { findForbiddenDeletions, diffIgnoredSnapshots, ignoredWriteTrailLine, ignoredWriteUnobservedNote } from './write-guard.js';
|
|
48
|
+
import { findNarrowedCommands, narrowingRejectionText } from './command-shrink.js';
|
|
48
49
|
/** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
|
|
49
50
|
* failing command (and the project's own tooling), not to mutate git state. */
|
|
50
51
|
export const FINAL_FIX_TOOLS = 'read,edit,bash';
|
|
@@ -213,6 +214,11 @@ export function strandedFixNote(paths) {
|
|
|
213
214
|
*/
|
|
214
215
|
export async function runFinalGateAutofix(deps) {
|
|
215
216
|
const before = deps.discoverLabels(deps.cwd);
|
|
217
|
+
const bodiesBefore = deps.discoverBodies?.(deps.cwd) ?? {};
|
|
218
|
+
// Ignored paths as they stood BEFORE the child. Attribution needs both ends:
|
|
219
|
+
// ignored files are untracked, so git alone cannot tell a file this pass wrote
|
|
220
|
+
// from one that was already sitting in the worktree.
|
|
221
|
+
const ignoredBefore = deps.ignoredSnapshot ? await deps.ignoredSnapshot() : null;
|
|
216
222
|
let text;
|
|
217
223
|
try {
|
|
218
224
|
text = await deps.runChild(FINAL_FIX_TOOLS, buildFinalFixPrompt(deps.failReason), deps.signal);
|
|
@@ -223,7 +229,22 @@ export async function runFinalGateAutofix(deps) {
|
|
|
223
229
|
throw err;
|
|
224
230
|
return { ok: false, reason: `fix child failed: ${msg}` };
|
|
225
231
|
}
|
|
226
|
-
|
|
232
|
+
// What the child wrote to gitignored paths. Recorded on the trail IMMEDIATELY —
|
|
233
|
+
// before any guard can reject the attempt — because `discard` reverts tracked
|
|
234
|
+
// edits only: an ignored file the pass wrote survives a rejection, and the trail
|
|
235
|
+
// is the only place that fact can ever be read back.
|
|
236
|
+
const ignoredWrites = ignoredBefore === null || !deps.ignoredSnapshot ?
|
|
237
|
+
[]
|
|
238
|
+
: [
|
|
239
|
+
...new Set([
|
|
240
|
+
...diffIgnoredSnapshots(ignoredBefore, await deps.ignoredSnapshot()),
|
|
241
|
+
...(deps.ignoredKnown ?? [])
|
|
242
|
+
])
|
|
243
|
+
].sort();
|
|
244
|
+
if (ignoredWrites.length > 0)
|
|
245
|
+
deps.log?.(ignoredWriteTrailLine(ignoredWrites));
|
|
246
|
+
const withIgnored = (r) => ignoredWrites.length > 0 ? { ...r, ignoredWrites } : r;
|
|
247
|
+
const rejected = (what) => withIgnored({
|
|
227
248
|
ok: false,
|
|
228
249
|
reason: `${what} — edits ${deps.discard ? 'discarded' : 'REJECTED but left in the tree (no discard available)'}`,
|
|
229
250
|
guardTripped: true,
|
|
@@ -246,8 +267,9 @@ export async function runFinalGateAutofix(deps) {
|
|
|
246
267
|
}
|
|
247
268
|
// DELETION GUARD (post-revert state): a tracked file the pass deleted without
|
|
248
269
|
// relocating it is a committed deliverable destroyed — reject the attempt.
|
|
249
|
-
|
|
250
|
-
|
|
270
|
+
const changes = deps.treeChanges ? await deps.treeChanges() : null;
|
|
271
|
+
if (changes) {
|
|
272
|
+
const gone = findForbiddenDeletions(changes);
|
|
251
273
|
if (gone.length > 0) {
|
|
252
274
|
if (deps.discard)
|
|
253
275
|
await deps.discard(deps.cwd);
|
|
@@ -267,6 +289,30 @@ export async function runFinalGateAutofix(deps) {
|
|
|
267
289
|
await deps.discard(deps.cwd);
|
|
268
290
|
return rejected(`fix pass removed the gate's own command(s) (${vanished.join(', ')})`);
|
|
269
291
|
}
|
|
292
|
+
// SCOPE-SHRINK GUARD (mx5 run 19): the label surviving is not enough. The
|
|
293
|
+
// autofix kept `bun run test` and rewrote its BODY from `AGENT=1 bun test`
|
|
294
|
+
// to `AGENT=1 bun test ./test`, so the set difference above was empty while
|
|
295
|
+
// the suite stopped covering the repository — and the gate re-ran, went
|
|
296
|
+
// green, and reported "converged". Compare the resolved bodies and reject a
|
|
297
|
+
// fix that shrinks what the gate measures (command-shrink.ts: four
|
|
298
|
+
// mechanical shapes, measured at 3 hits in 274 manifest-touching corpus
|
|
299
|
+
// commits, all three read by hand as real narrowings).
|
|
300
|
+
if (deps.discoverBodies) {
|
|
301
|
+
const addedByFix = new Set((changes?.added ?? []).map(p => p.replace(/^\.\//, '')));
|
|
302
|
+
const narrowed = findNarrowedCommands(bodiesBefore, deps.discoverBodies(deps.cwd), {
|
|
303
|
+
createdByFix: p => {
|
|
304
|
+
const n = p.replace(/^\.\//, '');
|
|
305
|
+
return addedByFix.has(n) || [...addedByFix].some(a => a.endsWith(`/${n}`));
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
if (narrowed.length > 0) {
|
|
309
|
+
if (deps.discard)
|
|
310
|
+
await deps.discard(deps.cwd);
|
|
311
|
+
const r = rejected(narrowingRejectionText(narrowed));
|
|
312
|
+
deps.log?.(`final-fix SCOPE-SHRINK GUARD — ${r.reason}`);
|
|
313
|
+
return r;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
270
316
|
// PROBE SCAN (F6): added lines whose stated purpose is to make a check pass
|
|
271
317
|
// rather than meet the requirement reject the attempt — there is no verify
|
|
272
318
|
// child downstream of this pass to judge the finding, and the probe is
|
|
@@ -284,16 +330,44 @@ export async function runFinalGateAutofix(deps) {
|
|
|
284
330
|
const marker = parseFinalFixMarker(text);
|
|
285
331
|
if (marker.blocked) {
|
|
286
332
|
// Self-declared blocked: skip the (expensive) gate re-run; nothing converged.
|
|
287
|
-
return { ok: false, reason: `fix child blocked: ${marker.note}` };
|
|
333
|
+
return withIgnored({ ok: false, reason: `fix child blocked: ${marker.note}` });
|
|
288
334
|
}
|
|
289
335
|
const fin = await deps.gate(deps.cwd);
|
|
290
336
|
if (!fin.ok) {
|
|
291
|
-
return {
|
|
337
|
+
return withIgnored({
|
|
292
338
|
ok: false,
|
|
293
339
|
reason: `did not converge: ${fin.reason}`,
|
|
294
340
|
gateReason: fin.reason,
|
|
295
341
|
gateFailures: fin.failures
|
|
296
|
-
};
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// IGNORED-DEPENDENCY DOWNGRADE (mx5 run 19). The gate says PASS; the question
|
|
345
|
+
// this answers is whether that PASS belongs to the REPOSITORY or only to this
|
|
346
|
+
// worktree. Decided mechanically, never by judgement: move the ignored files
|
|
347
|
+
// the pass wrote aside, re-run the gate once, put them back. Still passing ⇒
|
|
348
|
+
// they were incidental and the PASS stands. Failing ⇒ the checks were passing
|
|
349
|
+
// on state no fresh clone has, which is the definition of UNOBSERVED (see
|
|
350
|
+
// final-gate.ts unobservedVerdict) — not a FAIL: the fix is real, it just did
|
|
351
|
+
// not ship. The probe runs only here, so a run with no ignored writes (the
|
|
352
|
+
// overwhelming majority — 1 of 68 recorded child logs) pays nothing.
|
|
353
|
+
let ignoredDependent;
|
|
354
|
+
if (ignoredWrites.length > 0 && deps.gateWithoutIgnored) {
|
|
355
|
+
const passesWithout = await deps.gateWithoutIgnored(ignoredWrites);
|
|
356
|
+
if (passesWithout !== null)
|
|
357
|
+
ignoredDependent = !passesWithout;
|
|
297
358
|
}
|
|
298
|
-
|
|
359
|
+
const notes = [
|
|
360
|
+
...(fin.unobserved ? [fin.unobserved] : []),
|
|
361
|
+
...(ignoredDependent === true ? [ignoredWriteUnobservedNote(ignoredWrites)] : [])
|
|
362
|
+
];
|
|
363
|
+
if (ignoredDependent === true) {
|
|
364
|
+
deps.log?.(`final-gate: converged PASS DOWNGRADED to UNOBSERVED — the gate does not pass with `
|
|
365
|
+
+ `${ignoredWrites.join(', ')} moved aside, and those path(s) are gitignored`);
|
|
366
|
+
}
|
|
367
|
+
return withIgnored({
|
|
368
|
+
ok: true,
|
|
369
|
+
reason: fin.reason,
|
|
370
|
+
...(notes.length > 0 ? { unobserved: notes.join(' ') } : {}),
|
|
371
|
+
...(ignoredDependent !== undefined ? { ignoredDependent } : {})
|
|
372
|
+
});
|
|
299
373
|
}
|
|
@@ -321,6 +321,21 @@ export declare function runBootCheck(cwd: string, [bin, args]: HealthCommand, gr
|
|
|
321
321
|
* gaming the gate, not fixing the defect.
|
|
322
322
|
*/
|
|
323
323
|
export declare function discoverGateCommandLabels(cwd: string): string[];
|
|
324
|
+
/**
|
|
325
|
+
* The RESOLVED BODY of every discoverable gate command, keyed by the same label
|
|
326
|
+
* `discoverGateCommandLabels` produces.
|
|
327
|
+
*
|
|
328
|
+
* The label is what the gate CALLS; the body is what actually runs. mx5 run 19's
|
|
329
|
+
* autofix changed `scripts.test` from `AGENT=1 bun test` to `AGENT=1 bun test
|
|
330
|
+
* ./test` — the label `bun run test` was identical before and after, so the
|
|
331
|
+
* label guard saw nothing while the suite stopped covering the repository. The
|
|
332
|
+
* shrink guard compares these bodies (see command-shrink.ts).
|
|
333
|
+
*
|
|
334
|
+
* A command with no indirection (`cargo test --quiet`, `pytest -q`) resolves to
|
|
335
|
+
* itself: it cannot be narrowed without changing the label, which the label
|
|
336
|
+
* guard already owns.
|
|
337
|
+
*/
|
|
338
|
+
export declare function discoverGateCommandBodies(cwd: string): Record<string, string>;
|
|
324
339
|
/**
|
|
325
340
|
* A non-zero exit whose output shows the EXTERNAL INFRASTRUCTURE a launch script
|
|
326
341
|
* talks to is absent HERE — a database/daemon that is not running or not
|
package/dist/task/final-gate.js
CHANGED
|
@@ -59,6 +59,7 @@ import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js
|
|
|
59
59
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
60
60
|
import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
|
|
61
61
|
import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
|
|
62
|
+
import { makefileRecipe } from './command-shrink.js';
|
|
62
63
|
function packageScripts(cwd) {
|
|
63
64
|
try {
|
|
64
65
|
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
@@ -924,6 +925,56 @@ export function discoverGateCommandLabels(cwd) {
|
|
|
924
925
|
].map(([bin, args]) => `${bin} ${args.join(' ')}`);
|
|
925
926
|
return [...new Set(labels)];
|
|
926
927
|
}
|
|
928
|
+
/**
|
|
929
|
+
* The RESOLVED BODY of every discoverable gate command, keyed by the same label
|
|
930
|
+
* `discoverGateCommandLabels` produces.
|
|
931
|
+
*
|
|
932
|
+
* The label is what the gate CALLS; the body is what actually runs. mx5 run 19's
|
|
933
|
+
* autofix changed `scripts.test` from `AGENT=1 bun test` to `AGENT=1 bun test
|
|
934
|
+
* ./test` — the label `bun run test` was identical before and after, so the
|
|
935
|
+
* label guard saw nothing while the suite stopped covering the repository. The
|
|
936
|
+
* shrink guard compares these bodies (see command-shrink.ts).
|
|
937
|
+
*
|
|
938
|
+
* A command with no indirection (`cargo test --quiet`, `pytest -q`) resolves to
|
|
939
|
+
* itself: it cannot be narrowed without changing the label, which the label
|
|
940
|
+
* guard already owns.
|
|
941
|
+
*/
|
|
942
|
+
export function discoverGateCommandBodies(cwd) {
|
|
943
|
+
const boot = discoverBootCommand(cwd);
|
|
944
|
+
const cmds = [
|
|
945
|
+
...discoverHealthCommands(cwd).cmds,
|
|
946
|
+
...discoverLockfileChecks(cwd),
|
|
947
|
+
...discoverIntegrationCommands(cwd).cmds,
|
|
948
|
+
...(boot ? [boot] : [])
|
|
949
|
+
];
|
|
950
|
+
const scripts = existsSync(path.join(cwd, 'package.json')) ? packageScripts(cwd) : {};
|
|
951
|
+
let makefile = null;
|
|
952
|
+
if (existsSync(path.join(cwd, 'Makefile'))) {
|
|
953
|
+
try {
|
|
954
|
+
makefile = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
makefile = null;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const out = {};
|
|
961
|
+
for (const [bin, args] of cmds) {
|
|
962
|
+
const label = `${bin} ${args.join(' ')}`;
|
|
963
|
+
if (out[label] !== undefined)
|
|
964
|
+
continue;
|
|
965
|
+
out[label] = resolveCommandBody(bin, args, scripts, makefile) ?? label;
|
|
966
|
+
}
|
|
967
|
+
return out;
|
|
968
|
+
}
|
|
969
|
+
/** `bun run test` → `scripts.test`; `make test` → the target's recipe lines. */
|
|
970
|
+
function resolveCommandBody(bin, args, scripts, makefile) {
|
|
971
|
+
if (args[0] === 'run' && args[1] !== undefined)
|
|
972
|
+
return scripts[args[1]] ?? null;
|
|
973
|
+
if (bin === 'make' && args[0] !== undefined && makefile !== null) {
|
|
974
|
+
return makefileRecipe(makefile, args[0]);
|
|
975
|
+
}
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
927
978
|
/** Last ~`limit` chars of the command's combined output, one line, for the reason. */
|
|
928
979
|
function outputTail(stdout, stderr, limit = 400) {
|
|
929
980
|
const combined = `${stdout}\n${stderr}`.trim();
|
package/dist/task/gate-deps.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { GateDeps } from './task-gates.js';
|
|
|
3
3
|
import { type FinalFixResult } from './final-gate-fix.js';
|
|
4
4
|
import { type AddedLine } from './probe-gaming.js';
|
|
5
5
|
import { type ChangedFile } from './substitution-probe.js';
|
|
6
|
-
import { type TreeChangeSummary } from './write-guard.js';
|
|
6
|
+
import { type TreeChangeSummary, type IgnoredSnapshot } from './write-guard.js';
|
|
7
7
|
/** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
|
|
8
8
|
* command so this module stays free of the orchestrators (avoids an import cycle). */
|
|
9
9
|
export type RunTaskFn = GateDeps['runTask'];
|
|
@@ -15,7 +15,11 @@ export type RunTaskFn = GateDeps['runTask'];
|
|
|
15
15
|
export declare function truncateToolResult(text: string, limit?: number): string;
|
|
16
16
|
/** One bounded final-gate fix attempt (see final-gate-fix.ts): fix child →
|
|
17
17
|
* shrink guard → gate re-run. Consumed by /task-auto's run-end gate branch. */
|
|
18
|
-
export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failReason: string
|
|
18
|
+
export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failReason: string,
|
|
19
|
+
/** Ignored paths earlier attempts in this resolution loop already wrote (see
|
|
20
|
+
* FinalFixDeps.ignoredKnown) — a failed attempt's ignored writes survive its
|
|
21
|
+
* discard and can green a later attempt. */
|
|
22
|
+
ignoredKnown?: string[]) => Promise<FinalFixResult>;
|
|
19
23
|
/**
|
|
20
24
|
* Collect the task's changed files as pure GIT SHAPE — path + added-line count,
|
|
21
25
|
* no content, no language parsing — for the self-verification probe. Before the
|
|
@@ -40,6 +44,41 @@ export declare function collectAddedLines(cwd: string, signal?: AbortSignal): Pr
|
|
|
40
44
|
* Failures degrade to an empty summary — the guard then has nothing to reject.
|
|
41
45
|
*/
|
|
42
46
|
export declare function collectTreeChanges(cwd: string, signal?: AbortSignal): Promise<TreeChangeSummary>;
|
|
47
|
+
/**
|
|
48
|
+
* Build output directories declared by the project's OWN build commands
|
|
49
|
+
* (`--outdir=X`, `--out-dir X`), so the ignored-write exemption follows the real
|
|
50
|
+
* tooling instead of a name list. Best-effort: an unreadable or non-JSON manifest
|
|
51
|
+
* contributes nothing and the name-list fallback in classifyIgnoredPath applies.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseBuildOutdirs(cwd: string): string[];
|
|
54
|
+
/**
|
|
55
|
+
* IGNORED-PATH CHANNEL (mx5 run 19 — see write-guard.ts). A fingerprint of every
|
|
56
|
+
* ACTIONABLE ignored path (`git status --porcelain --ignored=matching`, minus
|
|
57
|
+
* build output / node_modules / .pi-tasks / .git), taken before and after a
|
|
58
|
+
* write-capable gate child so its writes to files git never reports are
|
|
59
|
+
* attributable to it.
|
|
60
|
+
*
|
|
61
|
+
* `--ignored=matching` collapses a wholly-ignored directory into ONE entry, which
|
|
62
|
+
* is what keeps this cheap: `node_modules/` is one exempt line, never 40,000
|
|
63
|
+
* stats. Every failure mode degrades to `{}` — no git, an older git that rejects
|
|
64
|
+
* `--ignored=matching`, an unreadable path — so the gate behaves exactly as it did
|
|
65
|
+
* before this channel existed.
|
|
66
|
+
*/
|
|
67
|
+
export declare function collectIgnoredSnapshot(cwd: string, signal?: AbortSignal): Promise<IgnoredSnapshot>;
|
|
68
|
+
/**
|
|
69
|
+
* The dependency test, decided mechanically rather than by judgement: move the
|
|
70
|
+
* ignored paths aside, re-run the gate once, put them back. A gate that no longer
|
|
71
|
+
* passes without them was passing on state the repository does not contain.
|
|
72
|
+
*
|
|
73
|
+
* Returns null when the question could not be answered (nothing movable, a move or
|
|
74
|
+
* a restore fault, too many paths) — an unanswered probe never downgrades a
|
|
75
|
+
* verdict. Restoration runs in a finally and is best-effort per path: leaving a
|
|
76
|
+
* developer's `.env` renamed on disk would be a far worse failure than a missed
|
|
77
|
+
* downgrade.
|
|
78
|
+
*/
|
|
79
|
+
export declare function gatePassesWithoutIgnored(cwd: string, paths: string[], runGate: (cwd: string) => Promise<{
|
|
80
|
+
ok: boolean;
|
|
81
|
+
}>, log?: (msg: string) => void): Promise<boolean | null>;
|
|
43
82
|
/**
|
|
44
83
|
* The task's changes for the cross-task deletion probe: the working tree's status
|
|
45
84
|
* when the work is uncommitted (pre-commit verify), else the LAST COMMIT's
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -13,7 +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
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import * as fsp from 'node:fs/promises';
|
|
18
18
|
import * as path from 'node:path';
|
|
19
19
|
import { tasksDir, readTaskFile, appendGateRecord } from './task-io.js';
|
|
@@ -25,14 +25,14 @@ import { readContracts } from './contracts.js';
|
|
|
25
25
|
import { recordAcceptDebt, recordEnforceKeptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt, recordRootCauseDebt } from './accept-debt.js';
|
|
26
26
|
import { recordRepairCandidate } from './root-cause-repair.js';
|
|
27
27
|
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
28
|
-
import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
|
|
28
|
+
import { runFinalIntegrationGate, discoverGateCommandLabels, discoverGateCommandBodies } from './final-gate.js';
|
|
29
29
|
import { runFinalGateAutofix } from './final-gate-fix.js';
|
|
30
30
|
import { researchResolution } from './verify-resolution.js';
|
|
31
31
|
import { extractProhibitions, findProhibitionViolations } from './prohibition-probe.js';
|
|
32
32
|
import { frozenPathsFromSpec, revertFrozenPaths } from './frozen-path-guard.js';
|
|
33
33
|
import { findProbeGaming, parseAddedLines } from './probe-gaming.js';
|
|
34
34
|
import { findSubstitutionSuspects, isTestFile } from './substitution-probe.js';
|
|
35
|
-
import { parseTreeChanges, parseNameStatusChanges, formatTreeChanges } from './write-guard.js';
|
|
35
|
+
import { parseTreeChanges, parseNameStatusChanges, formatTreeChanges, findActionableIgnoredWrites } from './write-guard.js';
|
|
36
36
|
import { taskThatIntroduced, findCrossTaskDeletions } from './task-provenance.js';
|
|
37
37
|
import { findTestRebuiltAssemblies, testAssemblyVerifyFindings } from './test-assembly.js';
|
|
38
38
|
import { runBoundedLintFix } from './lint-fix.js';
|
|
@@ -253,6 +253,133 @@ export async function collectTreeChanges(cwd, signal) {
|
|
|
253
253
|
const r = await git(cwd, ['status', '--porcelain', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
254
254
|
return r.exitCode === 0 ? parseTreeChanges(r.stdout) : { modified: [], deleted: [], added: [] };
|
|
255
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Build output directories declared by the project's OWN build commands
|
|
258
|
+
* (`--outdir=X`, `--out-dir X`), so the ignored-write exemption follows the real
|
|
259
|
+
* tooling instead of a name list. Best-effort: an unreadable or non-JSON manifest
|
|
260
|
+
* contributes nothing and the name-list fallback in classifyIgnoredPath applies.
|
|
261
|
+
*/
|
|
262
|
+
export function parseBuildOutdirs(cwd) {
|
|
263
|
+
let raw;
|
|
264
|
+
try {
|
|
265
|
+
raw = readFileSync(path.join(cwd, 'package.json'), 'utf8');
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
let scripts;
|
|
271
|
+
try {
|
|
272
|
+
scripts = JSON.parse(raw).scripts ?? {};
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
const out = new Set();
|
|
278
|
+
for (const body of Object.values(scripts)) {
|
|
279
|
+
if (typeof body !== 'string')
|
|
280
|
+
continue;
|
|
281
|
+
for (const m of body.matchAll(/--out-?dir[= ]([^\s'"]+)/g)) {
|
|
282
|
+
const p = (m[1] ?? '').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
283
|
+
if (p.length > 0 && !p.startsWith('-'))
|
|
284
|
+
out.add(p);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return [...out];
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* IGNORED-PATH CHANNEL (mx5 run 19 — see write-guard.ts). A fingerprint of every
|
|
291
|
+
* ACTIONABLE ignored path (`git status --porcelain --ignored=matching`, minus
|
|
292
|
+
* build output / node_modules / .pi-tasks / .git), taken before and after a
|
|
293
|
+
* write-capable gate child so its writes to files git never reports are
|
|
294
|
+
* attributable to it.
|
|
295
|
+
*
|
|
296
|
+
* `--ignored=matching` collapses a wholly-ignored directory into ONE entry, which
|
|
297
|
+
* is what keeps this cheap: `node_modules/` is one exempt line, never 40,000
|
|
298
|
+
* stats. Every failure mode degrades to `{}` — no git, an older git that rejects
|
|
299
|
+
* `--ignored=matching`, an unreadable path — so the gate behaves exactly as it did
|
|
300
|
+
* before this channel existed.
|
|
301
|
+
*/
|
|
302
|
+
export async function collectIgnoredSnapshot(cwd, signal) {
|
|
303
|
+
const r = await git(cwd, ['status', '--porcelain', '--ignored=matching', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
304
|
+
if (r.exitCode !== 0)
|
|
305
|
+
return {};
|
|
306
|
+
const outdirs = parseBuildOutdirs(cwd);
|
|
307
|
+
const paths = r.stdout
|
|
308
|
+
.split('\n')
|
|
309
|
+
.filter(l => l.startsWith('!! '))
|
|
310
|
+
.map(l => l.slice(3).trim())
|
|
311
|
+
.map(p => (p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p))
|
|
312
|
+
.filter(p => p.length > 0);
|
|
313
|
+
const snap = {};
|
|
314
|
+
for (const rel of findActionableIgnoredWrites(paths, outdirs)) {
|
|
315
|
+
try {
|
|
316
|
+
const st = await fsp.stat(path.join(cwd, rel));
|
|
317
|
+
// A directory's own mtime moves when entries are added or removed; that
|
|
318
|
+
// is the whole fingerprint available for one without walking it, and a
|
|
319
|
+
// walk is exactly the cost this channel refuses to pay.
|
|
320
|
+
snap[rel] = st.isDirectory() ? `dir:${st.mtimeMs}` : `${st.mtimeMs}:${st.size}`;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Vanished between status and stat — nothing to fingerprint.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return snap;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The dependency test, decided mechanically rather than by judgement: move the
|
|
330
|
+
* ignored paths aside, re-run the gate once, put them back. A gate that no longer
|
|
331
|
+
* passes without them was passing on state the repository does not contain.
|
|
332
|
+
*
|
|
333
|
+
* Returns null when the question could not be answered (nothing movable, a move or
|
|
334
|
+
* a restore fault, too many paths) — an unanswered probe never downgrades a
|
|
335
|
+
* verdict. Restoration runs in a finally and is best-effort per path: leaving a
|
|
336
|
+
* developer's `.env` renamed on disk would be a far worse failure than a missed
|
|
337
|
+
* downgrade.
|
|
338
|
+
*/
|
|
339
|
+
export async function gatePassesWithoutIgnored(cwd, paths, runGate, log) {
|
|
340
|
+
if (paths.length === 0 || paths.length > MAX_IGNORED_PROBE_PATHS)
|
|
341
|
+
return null;
|
|
342
|
+
const moved = [];
|
|
343
|
+
try {
|
|
344
|
+
for (const rel of paths) {
|
|
345
|
+
// `--ignored=matching` reports a wholly-ignored DIRECTORY with a trailing
|
|
346
|
+
// slash (`logs/`). Left on, `${path.join(cwd, 'logs/')}.pi-gate-probe`
|
|
347
|
+
// names a path INSIDE the directory, so the rename is a move-into-itself
|
|
348
|
+
// and the probe silently answers null for every directory entry.
|
|
349
|
+
const from = path.join(cwd, rel.replace(/\/+$/, ''));
|
|
350
|
+
const to = `${from}.pi-gate-probe`;
|
|
351
|
+
try {
|
|
352
|
+
await fsp.rename(from, to);
|
|
353
|
+
moved.push({ from, to });
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Could not move one → the probe cannot answer the question at all.
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (moved.length === 0)
|
|
361
|
+
return null;
|
|
362
|
+
const again = await runGate(cwd);
|
|
363
|
+
return again.ok;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
for (const m of moved) {
|
|
370
|
+
try {
|
|
371
|
+
await fsp.rename(m.to, m.from);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
log?.(`final-gate: WARNING — could not restore ${path.relative(cwd, m.from)} after `
|
|
375
|
+
+ `the ignored-dependency probe; it is on disk as ${path.basename(m.to)}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** Bound on the ignored-dependency probe: past this the set is not a fix child's
|
|
381
|
+
* handful of files and moving them is not a safe thing to do to a worktree. */
|
|
382
|
+
const MAX_IGNORED_PROBE_PATHS = 20;
|
|
256
383
|
/**
|
|
257
384
|
* The task's changes for the cross-task deletion probe: the working tree's status
|
|
258
385
|
* when the work is uncommitted (pre-commit verify), else the LAST COMMIT's
|
|
@@ -806,7 +933,7 @@ export function buildGateDeps(params) {
|
|
|
806
933
|
return r.exitCode === 0 && r.stdout.trim().length > 0;
|
|
807
934
|
},
|
|
808
935
|
discardEdits: discardTreeEdits,
|
|
809
|
-
finalGateFix: (fixCtx, cwd2, failReason) => runFinalGateAutofix({
|
|
936
|
+
finalGateFix: (fixCtx, cwd2, failReason, ignoredKnown) => runFinalGateAutofix({
|
|
810
937
|
cwd: cwd2,
|
|
811
938
|
signal,
|
|
812
939
|
failReason,
|
|
@@ -815,6 +942,7 @@ export function buildGateDeps(params) {
|
|
|
815
942
|
// shrink guard's discovery is the gate's own (see final-gate.ts).
|
|
816
943
|
gate: c => runFinalIntegrationGate(c),
|
|
817
944
|
discoverLabels: discoverGateCommandLabels,
|
|
945
|
+
discoverBodies: discoverGateCommandBodies,
|
|
818
946
|
discard: discardTreeEdits,
|
|
819
947
|
// WRITE-GUARD STACK (mx5 run 11: this child ran with free bash and
|
|
820
948
|
// none of the run-8 guards — it rm'd a sibling task's verified
|
|
@@ -830,6 +958,17 @@ export function buildGateDeps(params) {
|
|
|
830
958
|
// preserve registry), never a per-task union.
|
|
831
959
|
treeChanges: () => collectTreeChanges(cwd2, signal),
|
|
832
960
|
probeScan: () => collectAddedLines(cwd2, signal).then(findProbeGaming),
|
|
961
|
+
// IGNORED-PATH CHANNEL (mx5 run 19): the write guards above read
|
|
962
|
+
// `git status --porcelain`, which never reports ignored paths, so
|
|
963
|
+
// the pass that greened `bun run seed` by writing credentials into
|
|
964
|
+
// a gitignored `.env` was structurally invisible to all of them —
|
|
965
|
+
// and the gate certified a PASS no fresh clone can reproduce. This
|
|
966
|
+
// does not reject the write (a local `.env` is often the only way to
|
|
967
|
+
// make a check run); it records it, and downgrades a PASS proven to
|
|
968
|
+
// depend on it.
|
|
969
|
+
ignoredSnapshot: () => collectIgnoredSnapshot(cwd2, signal),
|
|
970
|
+
...(ignoredKnown && ignoredKnown.length > 0 ? { ignoredKnown } : {}),
|
|
971
|
+
gateWithoutIgnored: paths => gatePassesWithoutIgnored(cwd2, paths, c => runFinalIntegrationGate(c), makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))),
|
|
833
972
|
log: makeDebugAppender(path.join(tasksDir(cwd2), 'final-gate-debug.log'))
|
|
834
973
|
}),
|
|
835
974
|
recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
|
|
@@ -54,6 +54,48 @@ export declare function parseNameStatusChanges(nameStatus: string): TreeChangeSu
|
|
|
54
54
|
* destroyed a sibling task's verified deliverable.
|
|
55
55
|
*/
|
|
56
56
|
export declare function findForbiddenDeletions(changes: TreeChangeSummary): string[];
|
|
57
|
+
/** Why an ignored path is (or is not) something the gate may rule on. */
|
|
58
|
+
export type IgnoredClass = 'build-output' | 'dep-dir' | 'task-dir' | 'vcs-meta' | 'actionable';
|
|
59
|
+
/**
|
|
60
|
+
* Classify one repo-relative ignored path. `outdirs` are build output directories
|
|
61
|
+
* parsed from the project's own build commands. Only `actionable` may produce a
|
|
62
|
+
* finding — everything else is reproducible from the repository by running the
|
|
63
|
+
* project's own tooling, which is exactly what makes it not a gate concern.
|
|
64
|
+
*/
|
|
65
|
+
export declare function classifyIgnoredPath(rel: string, outdirs: string[]): IgnoredClass;
|
|
66
|
+
/** The ignored paths a gate may rule on: everything not exempt by mechanism. */
|
|
67
|
+
export declare function findActionableIgnoredWrites(paths: string[], outdirs: string[]): string[];
|
|
68
|
+
/**
|
|
69
|
+
* A fingerprint per ignored path (`mtimeMs:size`, or a marker for a directory),
|
|
70
|
+
* taken before and after a write-capable child so a write is ATTRIBUTED to that
|
|
71
|
+
* child rather than to the tree's pre-existing state. Ignored files are untracked,
|
|
72
|
+
* so git cannot tell "changed" from "was always there" — only the snapshot can.
|
|
73
|
+
*/
|
|
74
|
+
export type IgnoredSnapshot = Record<string, string>;
|
|
75
|
+
/**
|
|
76
|
+
* Paths whose fingerprint appeared or changed across the child's window. Pure, so
|
|
77
|
+
* the attribution rule is unit-testable without a repo.
|
|
78
|
+
*/
|
|
79
|
+
export declare function diffIgnoredSnapshots(before: IgnoredSnapshot, after: IgnoredSnapshot): string[];
|
|
80
|
+
/**
|
|
81
|
+
* The gate-trail line. PATH NAMES ONLY: the contents of an ignored file are never
|
|
82
|
+
* read into a log, a debt reason or a child prompt (`.env` is the canonical case —
|
|
83
|
+
* this channel exists because of a file full of credentials).
|
|
84
|
+
*/
|
|
85
|
+
export declare function ignoredWriteTrailLine(paths: string[]): string;
|
|
86
|
+
/**
|
|
87
|
+
* The durable debt reason for the same event. Path names only, same rule.
|
|
88
|
+
*
|
|
89
|
+
* The wording tracks what was actually PROVEN. `dependent === true` is the probe's
|
|
90
|
+
* answer that the gate does not pass without these files; `undefined` is an
|
|
91
|
+
* unanswered probe (the attempt never converged, or the probe could not run), which
|
|
92
|
+
* is still worth carrying because the file is on disk and can green a LATER attempt.
|
|
93
|
+
* A debt that overstates its evidence is the same defect this whole channel exists
|
|
94
|
+
* to fix, one level up.
|
|
95
|
+
*/
|
|
96
|
+
export declare function ignoredWriteDebtReason(paths: string[], dependent?: boolean): string;
|
|
97
|
+
/** The UNOBSERVED note that replaces such a PASS. Path names only, same rule. */
|
|
98
|
+
export declare function ignoredWriteUnobservedNote(paths: string[]): string;
|
|
57
99
|
/**
|
|
58
100
|
* One-line summary for the gate debug log — the diff capture every write-capable
|
|
59
101
|
* child gets so "what did this pass change" is answerable from artifacts (the
|
package/dist/task/write-guard.js
CHANGED
|
@@ -132,6 +132,114 @@ export function findForbiddenDeletions(changes) {
|
|
|
132
132
|
const addedNames = new Set(changes.added.map(basename));
|
|
133
133
|
return changes.deleted.filter(p => !addedNames.has(basename(p)));
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Directory names that are build output or a dependency tree by convention. The
|
|
137
|
+
* parsed outdirs (see classifyIgnoredPath's `outdirs`) come from the project's
|
|
138
|
+
* own tooling and are the primary mechanism; this list is the fallback for
|
|
139
|
+
* projects whose build is not declared in a package.json (CMake, cargo, gradle).
|
|
140
|
+
*/
|
|
141
|
+
const BUILD_DIR_NAMES = new Set([
|
|
142
|
+
'node_modules',
|
|
143
|
+
'dist',
|
|
144
|
+
'build',
|
|
145
|
+
'target',
|
|
146
|
+
'out',
|
|
147
|
+
'coverage',
|
|
148
|
+
'.next',
|
|
149
|
+
'.nuxt',
|
|
150
|
+
'.svelte-kit',
|
|
151
|
+
'.turbo',
|
|
152
|
+
'.cache',
|
|
153
|
+
'.parcel-cache',
|
|
154
|
+
'.vite',
|
|
155
|
+
'.gradle',
|
|
156
|
+
'__pycache__',
|
|
157
|
+
'.pytest_cache',
|
|
158
|
+
'.venv',
|
|
159
|
+
'venv'
|
|
160
|
+
]);
|
|
161
|
+
/**
|
|
162
|
+
* Classify one repo-relative ignored path. `outdirs` are build output directories
|
|
163
|
+
* parsed from the project's own build commands. Only `actionable` may produce a
|
|
164
|
+
* finding — everything else is reproducible from the repository by running the
|
|
165
|
+
* project's own tooling, which is exactly what makes it not a gate concern.
|
|
166
|
+
*/
|
|
167
|
+
export function classifyIgnoredPath(rel, outdirs) {
|
|
168
|
+
const segs = rel
|
|
169
|
+
.replace(/^\.\//, '')
|
|
170
|
+
.split('/')
|
|
171
|
+
.filter(s => s.length > 0);
|
|
172
|
+
if (segs.length === 0)
|
|
173
|
+
return 'actionable';
|
|
174
|
+
if (segs.includes('.pi-tasks'))
|
|
175
|
+
return 'task-dir';
|
|
176
|
+
if (segs.includes('.git'))
|
|
177
|
+
return 'vcs-meta';
|
|
178
|
+
if (segs.includes('node_modules'))
|
|
179
|
+
return 'dep-dir';
|
|
180
|
+
for (const o of outdirs) {
|
|
181
|
+
const oSegs = o
|
|
182
|
+
.replace(/^\.\//, '')
|
|
183
|
+
.split('/')
|
|
184
|
+
.filter(s => s.length > 0);
|
|
185
|
+
if (oSegs.length > 0 && oSegs.every((s, i) => segs[i] === s))
|
|
186
|
+
return 'build-output';
|
|
187
|
+
}
|
|
188
|
+
if (segs.some(s => BUILD_DIR_NAMES.has(s)))
|
|
189
|
+
return 'build-output';
|
|
190
|
+
return 'actionable';
|
|
191
|
+
}
|
|
192
|
+
/** The ignored paths a gate may rule on: everything not exempt by mechanism. */
|
|
193
|
+
export function findActionableIgnoredWrites(paths, outdirs) {
|
|
194
|
+
return paths.filter(p => classifyIgnoredPath(p, outdirs) === 'actionable');
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Paths whose fingerprint appeared or changed across the child's window. Pure, so
|
|
198
|
+
* the attribution rule is unit-testable without a repo.
|
|
199
|
+
*/
|
|
200
|
+
export function diffIgnoredSnapshots(before, after) {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const [p, fp] of Object.entries(after)) {
|
|
203
|
+
if (before[p] !== fp)
|
|
204
|
+
out.push(p);
|
|
205
|
+
}
|
|
206
|
+
return out.sort();
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The gate-trail line. PATH NAMES ONLY: the contents of an ignored file are never
|
|
210
|
+
* read into a log, a debt reason or a child prompt (`.env` is the canonical case —
|
|
211
|
+
* this channel exists because of a file full of credentials).
|
|
212
|
+
*/
|
|
213
|
+
export function ignoredWriteTrailLine(paths) {
|
|
214
|
+
return (`final-gate: fix pass modified IGNORED path(s) — ${paths.join(', ')}; `
|
|
215
|
+
+ 'these are NOT committed and NOT reproducible from the repository');
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The durable debt reason for the same event. Path names only, same rule.
|
|
219
|
+
*
|
|
220
|
+
* The wording tracks what was actually PROVEN. `dependent === true` is the probe's
|
|
221
|
+
* answer that the gate does not pass without these files; `undefined` is an
|
|
222
|
+
* unanswered probe (the attempt never converged, or the probe could not run), which
|
|
223
|
+
* is still worth carrying because the file is on disk and can green a LATER attempt.
|
|
224
|
+
* A debt that overstates its evidence is the same defect this whole channel exists
|
|
225
|
+
* to fix, one level up.
|
|
226
|
+
*/
|
|
227
|
+
export function ignoredWriteDebtReason(paths, dependent) {
|
|
228
|
+
const head = dependent === true ?
|
|
229
|
+
`final-gate PASS depended on gitignored file(s) the run wrote and cannot ship: ${paths.join(', ')}. `
|
|
230
|
+
+ 'A fresh clone does NOT have them, so the checks that passed here cannot be reproduced. '
|
|
231
|
+
: `the final-gate fix pass wrote gitignored file(s) that are NOT in the commit: ${paths.join(', ')}. `
|
|
232
|
+
+ 'Whether the gate needs them was not established, so a fresh clone may not reproduce this run. ';
|
|
233
|
+
return (head
|
|
234
|
+
+ 'The durable fix is a TRACKED counterpart (e.g. .env.example) or a check that '
|
|
235
|
+
+ 'does not need the file — never committing the ignored file itself.');
|
|
236
|
+
}
|
|
237
|
+
/** The UNOBSERVED note that replaces such a PASS. Path names only, same rule. */
|
|
238
|
+
export function ignoredWriteUnobservedNote(paths) {
|
|
239
|
+
return (`UNOBSERVED — NOT a pass: the gate's checks passed only with gitignored file(s) `
|
|
240
|
+
+ `this run wrote (${paths.join(', ')}), which are not in the commit; re-running `
|
|
241
|
+
+ 'them without those files FAILS, so no reproducible evidence was produced.');
|
|
242
|
+
}
|
|
135
243
|
/**
|
|
136
244
|
* One-line summary for the gate debug log — the diff capture every write-capable
|
|
137
245
|
* child gets so "what did this pass change" is answerable from artifacts (the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
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",
|