@mjasnikovs/pi-task 0.29.2 → 0.29.3
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/command-shrink.d.ts +105 -0
- package/dist/task/command-shrink.js +410 -0
- package/dist/task/final-gate-fix.d.ts +4 -0
- package/dist/task/final-gate-fix.js +29 -2
- package/dist/task/final-gate.d.ts +15 -0
- package/dist/task/final-gate.js +51 -0
- package/dist/task/gate-deps.js +2 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -136,6 +136,10 @@ export interface FinalFixDeps {
|
|
|
136
136
|
/** Labels of every currently-discoverable gate command (static + integration),
|
|
137
137
|
* for the shrink guard. Pure discovery — nothing is executed. */
|
|
138
138
|
discoverLabels: (cwd: string) => string[];
|
|
139
|
+
/** The same commands' RESOLVED BODIES (`label → scripts[name]` / Makefile
|
|
140
|
+
* recipe), for the scope-shrink half of the guard. Absent → only the label
|
|
141
|
+
* comparison runs, i.e. the pre-run-19 behaviour. */
|
|
142
|
+
discoverBodies?: (cwd: string) => Record<string, string>;
|
|
139
143
|
/** Discard the fix child's working-tree edits (guard trips only). Absent
|
|
140
144
|
* → the violation is still rejected, edits are left for inspection. */
|
|
141
145
|
discard?: (cwd: string) => Promise<void>;
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
*/
|
|
46
46
|
import { USER_CANCELLED } from './child-runner.js';
|
|
47
47
|
import { findForbiddenDeletions } 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,7 @@ 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) ?? {};
|
|
216
218
|
let text;
|
|
217
219
|
try {
|
|
218
220
|
text = await deps.runChild(FINAL_FIX_TOOLS, buildFinalFixPrompt(deps.failReason), deps.signal);
|
|
@@ -246,8 +248,9 @@ export async function runFinalGateAutofix(deps) {
|
|
|
246
248
|
}
|
|
247
249
|
// DELETION GUARD (post-revert state): a tracked file the pass deleted without
|
|
248
250
|
// relocating it is a committed deliverable destroyed — reject the attempt.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
const changes = deps.treeChanges ? await deps.treeChanges() : null;
|
|
252
|
+
if (changes) {
|
|
253
|
+
const gone = findForbiddenDeletions(changes);
|
|
251
254
|
if (gone.length > 0) {
|
|
252
255
|
if (deps.discard)
|
|
253
256
|
await deps.discard(deps.cwd);
|
|
@@ -267,6 +270,30 @@ export async function runFinalGateAutofix(deps) {
|
|
|
267
270
|
await deps.discard(deps.cwd);
|
|
268
271
|
return rejected(`fix pass removed the gate's own command(s) (${vanished.join(', ')})`);
|
|
269
272
|
}
|
|
273
|
+
// SCOPE-SHRINK GUARD (mx5 run 19): the label surviving is not enough. The
|
|
274
|
+
// autofix kept `bun run test` and rewrote its BODY from `AGENT=1 bun test`
|
|
275
|
+
// to `AGENT=1 bun test ./test`, so the set difference above was empty while
|
|
276
|
+
// the suite stopped covering the repository — and the gate re-ran, went
|
|
277
|
+
// green, and reported "converged". Compare the resolved bodies and reject a
|
|
278
|
+
// fix that shrinks what the gate measures (command-shrink.ts: four
|
|
279
|
+
// mechanical shapes, measured at 3 hits in 274 manifest-touching corpus
|
|
280
|
+
// commits, all three read by hand as real narrowings).
|
|
281
|
+
if (deps.discoverBodies) {
|
|
282
|
+
const addedByFix = new Set((changes?.added ?? []).map(p => p.replace(/^\.\//, '')));
|
|
283
|
+
const narrowed = findNarrowedCommands(bodiesBefore, deps.discoverBodies(deps.cwd), {
|
|
284
|
+
createdByFix: p => {
|
|
285
|
+
const n = p.replace(/^\.\//, '');
|
|
286
|
+
return addedByFix.has(n) || [...addedByFix].some(a => a.endsWith(`/${n}`));
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
if (narrowed.length > 0) {
|
|
290
|
+
if (deps.discard)
|
|
291
|
+
await deps.discard(deps.cwd);
|
|
292
|
+
const r = rejected(narrowingRejectionText(narrowed));
|
|
293
|
+
deps.log?.(`final-fix SCOPE-SHRINK GUARD — ${r.reason}`);
|
|
294
|
+
return r;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
270
297
|
// PROBE SCAN (F6): added lines whose stated purpose is to make a check pass
|
|
271
298
|
// rather than meet the requirement reject the attempt — there is no verify
|
|
272
299
|
// child downstream of this pass to judge the finding, and the probe is
|
|
@@ -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.js
CHANGED
|
@@ -25,7 +25,7 @@ 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';
|
|
@@ -815,6 +815,7 @@ export function buildGateDeps(params) {
|
|
|
815
815
|
// shrink guard's discovery is the gate's own (see final-gate.ts).
|
|
816
816
|
gate: c => runFinalIntegrationGate(c),
|
|
817
817
|
discoverLabels: discoverGateCommandLabels,
|
|
818
|
+
discoverBodies: discoverGateCommandBodies,
|
|
818
819
|
discard: discardTreeEdits,
|
|
819
820
|
// WRITE-GUARD STACK (mx5 run 11: this child ran with free bash and
|
|
820
821
|
// none of the run-8 guards — it rm'd a sibling task's verified
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.3",
|
|
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",
|