@mjasnikovs/pi-task 0.29.1 → 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.
@@ -45,7 +45,8 @@ import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, app
45
45
  import { reconcileTitleSources } from './decompose-fidelity.js';
46
46
  import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER } from './decompose-granularity.js';
47
47
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
48
- import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
48
+ import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger, readOwnedRequirements } from './requirements.js';
49
+ import { unclaimedPendingRequirements } from './owned-freeze-reassign.js';
49
50
  import { decideAdoption, groundedCoverage } from './coverage-loop.js';
50
51
  import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
51
52
  import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
@@ -1239,6 +1240,22 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1239
1240
  // compared against it rather than blindly re-printed.
1240
1241
  let reportedDebts = fin.openDebts ?? [];
1241
1242
  await surfaceOpenDebts(reportedDebts);
1243
+ // An owned obligation a task DETACHED (its own spec froze the
1244
+ // only file that could satisfy it, nexttask 2) and no later
1245
+ // task claimed. Detach never deletes the quote, so the run
1246
+ // ends holding it — say so, or the resolution would be a
1247
+ // quieter version of the deletion it exists to prevent.
1248
+ const unclaimed = unclaimedPendingRequirements(await readOwnedRequirements(cwd).catch(() => []));
1249
+ for (const o of unclaimed) {
1250
+ await recGate(`owned requirement UNCLAIMED — "${o.quote.slice(0, 200)}"`
1251
+ + ` [frozen in "${o.title.slice(0, 60)}"; no task claimed`
1252
+ + ` ${(o.pending ?? []).join(', ')}]`);
1253
+ }
1254
+ if (unclaimed.length > 0) {
1255
+ active.ui.notify(`${id}: ${unclaimed.length} authoritative design requirement(s) ended the run`
1256
+ + ' owned by NO task — the task they were mapped to could not touch the'
1257
+ + ' file, and nothing else claimed it. See the gate trail.', 'warning');
1258
+ }
1242
1259
  /**
1243
1260
  * nexttask 6 (mx5 run 18). The lines above are emitted from the
1244
1261
  * FIRST gate result; the converged-autofix paths below used to
@@ -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
- if (deps.treeChanges) {
250
- const gone = findForbiddenDeletions(await deps.treeChanges());
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