@mjasnikovs/pi-task 0.25.0 → 0.27.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/accept-debt.d.ts +15 -1
- package/dist/task/accept-debt.js +18 -0
- package/dist/task/artifact-closure.d.ts +71 -1
- package/dist/task/artifact-closure.js +274 -21
- package/dist/task/enforce-attribution.d.ts +128 -0
- package/dist/task/enforce-attribution.js +213 -0
- package/dist/task/final-gate.d.ts +3 -1
- package/dist/task/final-gate.js +12 -4
- package/dist/task/gate-deps.js +34 -2
- package/dist/task/repo-health-check.js +7 -5
- package/dist/task/runner-resolve.d.ts +25 -0
- package/dist/task/runner-resolve.js +31 -0
- package/dist/task/task-gates.d.ts +22 -5
- package/dist/task/task-gates.js +73 -6
- package/package.json +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enforce-attribution — decide whether an enforce-pass re-verify FAIL can possibly
|
|
3
|
+
* be the ENFORCE COMMIT's fault (mx5 run 18, nexttask 4).
|
|
4
|
+
*
|
|
5
|
+
* The failure class, measured. Run 18's TASK_0024 enforce commit `ee65661` was, in
|
|
6
|
+
* full, one line:
|
|
7
|
+
*
|
|
8
|
+
* - setApiError((usersData).error ?? 'Failed to load users')
|
|
9
|
+
* + setApiError(usersData.error ?? 'Failed to load users')
|
|
10
|
+
*
|
|
11
|
+
* The differential re-verify then FAILed on `MyListings.spec.tsx:186`, a Playwright
|
|
12
|
+
* CT file `Admin.tsx` cannot reach (CT bundles per story; the import graph never
|
|
13
|
+
* touches it), and the differential reverted the enforce commit anyway. The defect
|
|
14
|
+
* was real and PRE-EXISTING — `getByText('SOLD')` matched two elements because
|
|
15
|
+
* MOCK_LISTINGS already carries a sold row — and the final gate fixed it five
|
|
16
|
+
* minutes later, in the same commit that RE-MADE the identical paren change. Cost:
|
|
17
|
+
* one correct change destroyed, one permanent false verify-FAIL debt written
|
|
18
|
+
* against work that was fine, and the change re-made anyway.
|
|
19
|
+
*
|
|
20
|
+
* The mechanism was not broken; it was asked the wrong question. The root-cause
|
|
21
|
+
* channel (root-cause-repair.ts) attributed against `scope: 'committed'` — the
|
|
22
|
+
* TASK's own commit, which touched `MyListings.tsx` — concluded "this task touched
|
|
23
|
+
* the file", and fell through to the conservative revert. But at THIS seam the
|
|
24
|
+
* differential is deciding whether to discard the ENFORCE COMMIT. The causal
|
|
25
|
+
* question is therefore *"could the enforce diff have caused this?"*, never *"could
|
|
26
|
+
* the task have?"*: reverting the enforce commit can only ever repair damage the
|
|
27
|
+
* enforce commit did.
|
|
28
|
+
*
|
|
29
|
+
* So the filter here is mechanical and one-sided:
|
|
30
|
+
*
|
|
31
|
+
* - the files the FAIL text NAMES are extracted (paths AND bare basenames — run
|
|
32
|
+
* 18's reason named `MyListings.spec.tsx:186`, which carries no separator and
|
|
33
|
+
* is invisible to root-cause-repair.ts's path-token regex, a second reason
|
|
34
|
+
* that incident could not be attributed);
|
|
35
|
+
* - each is compared against the enforce commit's own file set, by path suffix
|
|
36
|
+
* and by RELATED STEM (`Admin.tsx` ~ `Admin.spec.tsx` ~ `Admin.stories.tsx`),
|
|
37
|
+
* so a regression in a touched file's own test still counts as overlap;
|
|
38
|
+
* - DISJOINT ⇒ keep the edits and route the defect. Anything else — an unknown
|
|
39
|
+
* enforce diff, a FAIL text naming no file at all, any overlap — reverts,
|
|
40
|
+
* which is exactly the pre-existing behaviour. Never keep on ignorance.
|
|
41
|
+
*
|
|
42
|
+
* Keeping the edits must not mean losing the finding: the caller still records the
|
|
43
|
+
* debt and still queues the repair. Losing the finding was mx5 run 5's mistake.
|
|
44
|
+
*
|
|
45
|
+
* MEASURED COVERAGE, and it is the honest weakness of this filter. Live, 40 trials
|
|
46
|
+
* against the run-18 tree with a real verify child
|
|
47
|
+
* (`scripts/live-enforce-attribution-ab.ts`):
|
|
48
|
+
*
|
|
49
|
+
* reachable arm (the enforce edit itself breaks `Admin.tsx`, ground truth REVERT)
|
|
50
|
+
* 20/20 FAILs named a resolvable file, 20/20 decided correctly — tsc prints the
|
|
51
|
+
* path, so the regression case is fully covered.
|
|
52
|
+
* disjoint arm (a deterministic failure in `MyListings.spec.tsx`, ground truth KEEP)
|
|
53
|
+
* only 9/20 FAILs named a file at all. The other 11 named the UNIT — "The
|
|
54
|
+
* MyListings component test \"edit link navigates…\" fails … expects 4 Edit links"
|
|
55
|
+
* — with no path and no `.tsx` anywhere. Those 11 fall through to the revert.
|
|
56
|
+
*
|
|
57
|
+
* So this fires on roughly half of live disjoint failures and on the recorded run-18
|
|
58
|
+
* verdict (which did name `MyListings.spec.tsx:186`), and it was NEVER wrong: 29 of
|
|
59
|
+
* 29 extractable decisions matched ground truth across both arms. The arm-level
|
|
60
|
+
* verdict is still FAIL against the pre-registered ≥80%-extractable bar, and the
|
|
61
|
+
* shipped rule is deliberately the conservative half of that trade: a miss costs
|
|
62
|
+
* exactly the behaviour that shipped before, while a false KEEP would ship a real
|
|
63
|
+
* regression. A stem-widened extractor (accept a bare identifier matching a tracked
|
|
64
|
+
* file's stem) reaches 40/40 with 0 errors in the same data and is NOT wired — see
|
|
65
|
+
* VALIDATION-DEBT.md, it resolves prose nouns to files and its FP mode was never
|
|
66
|
+
* exercised.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* The comparable stem of a file name: the base name with its extension and any
|
|
70
|
+
* companion suffix removed, lowercased. `src/client/pages/Admin.tsx`,
|
|
71
|
+
* `Admin.spec.tsx` and `Admin.stories.tsx` all reduce to `admin`, which is what
|
|
72
|
+
* makes "the enforce diff broke that file's own test" count as an overlap rather
|
|
73
|
+
* than a disjoint (and therefore keepable) failure.
|
|
74
|
+
*/
|
|
75
|
+
export declare function fileStem(p: string): string;
|
|
76
|
+
/**
|
|
77
|
+
* Every file a FAIL text names, de-duplicated, in first-seen order. Both shapes
|
|
78
|
+
* count: `src/server/routes/photos.ts` (path) and `MyListings.spec.tsx:186` (bare
|
|
79
|
+
* name plus a line number). An empty result is the signal that NOTHING can be
|
|
80
|
+
* attributed — the caller must then revert, never keep.
|
|
81
|
+
*/
|
|
82
|
+
export declare function extractFailingFiles(text: string): string[];
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a named file to a tracked repo path when the repo file list makes it
|
|
85
|
+
* unambiguous. `MyListings.spec.tsx` → `src/client/pages/MyListings.spec.tsx`, so
|
|
86
|
+
* the KEEP path can ask provenance about it and queue a real repair task. An
|
|
87
|
+
* ambiguous name (two tracked files share it) resolves to null: guessing which one
|
|
88
|
+
* the failure meant would put a wrong file in a repair task's title.
|
|
89
|
+
*/
|
|
90
|
+
export declare function resolveNamedFile(named: string, repoFiles: string[] | null): string | null;
|
|
91
|
+
export interface EnforceAttributionInput {
|
|
92
|
+
/** The differential re-verify's FAIL reason, verbatim. */
|
|
93
|
+
failReason: string;
|
|
94
|
+
/**
|
|
95
|
+
* Repo-relative paths the ENFORCE COMMIT changed. `null` means unknown (git
|
|
96
|
+
* unavailable) and forces the pre-existing revert — inconclusive is never
|
|
97
|
+
* evidence for keeping a possible regression.
|
|
98
|
+
*/
|
|
99
|
+
enforceTouched: string[] | null;
|
|
100
|
+
/** Tracked repo paths, for resolving bare file names. Optional; absent only
|
|
101
|
+
* costs resolution, never changes the keep/revert verdict. */
|
|
102
|
+
repoFiles?: string[] | null;
|
|
103
|
+
}
|
|
104
|
+
/** Why the differential decided as it did — verbatim into the gate trail. */
|
|
105
|
+
export type EnforceAttributionWhy = 'enforce-diff-unknown' | 'no-file-named' | 'named-file-in-enforce-diff' | 'disjoint-from-enforce-diff';
|
|
106
|
+
export interface EnforceAttribution {
|
|
107
|
+
/** `keep` only on positive mechanical evidence that enforce cannot be at fault. */
|
|
108
|
+
verdict: 'keep' | 'revert';
|
|
109
|
+
why: EnforceAttributionWhy;
|
|
110
|
+
/** Files the FAIL text named (resolved to repo paths where unambiguous). */
|
|
111
|
+
named: string[];
|
|
112
|
+
/** The enforce commit's own file set, as compared against (for the trail). */
|
|
113
|
+
enforceDiff: string[];
|
|
114
|
+
/** On `revert`: the named file that overlaps the enforce diff. */
|
|
115
|
+
overlap?: {
|
|
116
|
+
named: string;
|
|
117
|
+
enforce: string;
|
|
118
|
+
};
|
|
119
|
+
/** On `keep`: the file to blame the defect on (the first named file). */
|
|
120
|
+
file?: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Could the ENFORCE COMMIT have caused this FAIL? `keep` iff the failure names at
|
|
124
|
+
* least one file and NO named file is the enforce diff's, or a companion of one.
|
|
125
|
+
* Every unknown returns `revert`, i.e. exactly the behaviour that shipped before
|
|
126
|
+
* this filter existed.
|
|
127
|
+
*/
|
|
128
|
+
export declare function attributeEnforceFailure(input: EnforceAttributionInput): EnforceAttribution;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enforce-attribution — decide whether an enforce-pass re-verify FAIL can possibly
|
|
3
|
+
* be the ENFORCE COMMIT's fault (mx5 run 18, nexttask 4).
|
|
4
|
+
*
|
|
5
|
+
* The failure class, measured. Run 18's TASK_0024 enforce commit `ee65661` was, in
|
|
6
|
+
* full, one line:
|
|
7
|
+
*
|
|
8
|
+
* - setApiError((usersData).error ?? 'Failed to load users')
|
|
9
|
+
* + setApiError(usersData.error ?? 'Failed to load users')
|
|
10
|
+
*
|
|
11
|
+
* The differential re-verify then FAILed on `MyListings.spec.tsx:186`, a Playwright
|
|
12
|
+
* CT file `Admin.tsx` cannot reach (CT bundles per story; the import graph never
|
|
13
|
+
* touches it), and the differential reverted the enforce commit anyway. The defect
|
|
14
|
+
* was real and PRE-EXISTING — `getByText('SOLD')` matched two elements because
|
|
15
|
+
* MOCK_LISTINGS already carries a sold row — and the final gate fixed it five
|
|
16
|
+
* minutes later, in the same commit that RE-MADE the identical paren change. Cost:
|
|
17
|
+
* one correct change destroyed, one permanent false verify-FAIL debt written
|
|
18
|
+
* against work that was fine, and the change re-made anyway.
|
|
19
|
+
*
|
|
20
|
+
* The mechanism was not broken; it was asked the wrong question. The root-cause
|
|
21
|
+
* channel (root-cause-repair.ts) attributed against `scope: 'committed'` — the
|
|
22
|
+
* TASK's own commit, which touched `MyListings.tsx` — concluded "this task touched
|
|
23
|
+
* the file", and fell through to the conservative revert. But at THIS seam the
|
|
24
|
+
* differential is deciding whether to discard the ENFORCE COMMIT. The causal
|
|
25
|
+
* question is therefore *"could the enforce diff have caused this?"*, never *"could
|
|
26
|
+
* the task have?"*: reverting the enforce commit can only ever repair damage the
|
|
27
|
+
* enforce commit did.
|
|
28
|
+
*
|
|
29
|
+
* So the filter here is mechanical and one-sided:
|
|
30
|
+
*
|
|
31
|
+
* - the files the FAIL text NAMES are extracted (paths AND bare basenames — run
|
|
32
|
+
* 18's reason named `MyListings.spec.tsx:186`, which carries no separator and
|
|
33
|
+
* is invisible to root-cause-repair.ts's path-token regex, a second reason
|
|
34
|
+
* that incident could not be attributed);
|
|
35
|
+
* - each is compared against the enforce commit's own file set, by path suffix
|
|
36
|
+
* and by RELATED STEM (`Admin.tsx` ~ `Admin.spec.tsx` ~ `Admin.stories.tsx`),
|
|
37
|
+
* so a regression in a touched file's own test still counts as overlap;
|
|
38
|
+
* - DISJOINT ⇒ keep the edits and route the defect. Anything else — an unknown
|
|
39
|
+
* enforce diff, a FAIL text naming no file at all, any overlap — reverts,
|
|
40
|
+
* which is exactly the pre-existing behaviour. Never keep on ignorance.
|
|
41
|
+
*
|
|
42
|
+
* Keeping the edits must not mean losing the finding: the caller still records the
|
|
43
|
+
* debt and still queues the repair. Losing the finding was mx5 run 5's mistake.
|
|
44
|
+
*
|
|
45
|
+
* MEASURED COVERAGE, and it is the honest weakness of this filter. Live, 40 trials
|
|
46
|
+
* against the run-18 tree with a real verify child
|
|
47
|
+
* (`scripts/live-enforce-attribution-ab.ts`):
|
|
48
|
+
*
|
|
49
|
+
* reachable arm (the enforce edit itself breaks `Admin.tsx`, ground truth REVERT)
|
|
50
|
+
* 20/20 FAILs named a resolvable file, 20/20 decided correctly — tsc prints the
|
|
51
|
+
* path, so the regression case is fully covered.
|
|
52
|
+
* disjoint arm (a deterministic failure in `MyListings.spec.tsx`, ground truth KEEP)
|
|
53
|
+
* only 9/20 FAILs named a file at all. The other 11 named the UNIT — "The
|
|
54
|
+
* MyListings component test \"edit link navigates…\" fails … expects 4 Edit links"
|
|
55
|
+
* — with no path and no `.tsx` anywhere. Those 11 fall through to the revert.
|
|
56
|
+
*
|
|
57
|
+
* So this fires on roughly half of live disjoint failures and on the recorded run-18
|
|
58
|
+
* verdict (which did name `MyListings.spec.tsx:186`), and it was NEVER wrong: 29 of
|
|
59
|
+
* 29 extractable decisions matched ground truth across both arms. The arm-level
|
|
60
|
+
* verdict is still FAIL against the pre-registered ≥80%-extractable bar, and the
|
|
61
|
+
* shipped rule is deliberately the conservative half of that trade: a miss costs
|
|
62
|
+
* exactly the behaviour that shipped before, while a false KEEP would ship a real
|
|
63
|
+
* regression. A stem-widened extractor (accept a bare identifier matching a tracked
|
|
64
|
+
* file's stem) reaches 40/40 with 0 errors in the same data and is NOT wired — see
|
|
65
|
+
* VALIDATION-DEBT.md, it resolves prose nouns to files and its FP mode was never
|
|
66
|
+
* exercised.
|
|
67
|
+
*/
|
|
68
|
+
/** A path-like token: at least one separator, ending in a file name. */
|
|
69
|
+
const PATH_TOKEN_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.\w+/g;
|
|
70
|
+
/**
|
|
71
|
+
* A bare file name with a code-ish extension. Deliberately extension-gated: an
|
|
72
|
+
* ungated `\w+\.\w+` matches prose ("e.g.", "v1.2"), object access (`usersData.error`
|
|
73
|
+
* — which run 18's own enforce diff contains) and assertion chains
|
|
74
|
+
* (`toBeVisible()`), and every one of those would be a phantom "named file".
|
|
75
|
+
*/
|
|
76
|
+
const BARE_FILE_RE = /\b[\w@-]+(?:\.[\w@-]+)*\.(?:tsx?|jsx?|mtsx?|ctsx?|mjs|cjs|vue|svelte|astro|py|go|rs|rb|java|kt|kts|cs|php|swift|scala|c|h|cc|cpp|hpp|css|scss|sass|less|html?|json|jsonc|ya?ml|toml|sql|sh|bash|zsh|md|mdx|prisma|graphql|gql|proto|lock)\b/g;
|
|
77
|
+
/** Line/column suffixes and decoration a reason wraps a file name in. */
|
|
78
|
+
const TRAILING_POSITION_RE = /:(\d+)(?::(\d+))?$/;
|
|
79
|
+
/** Suffixes that make a file a sibling of another (test/story/type companions). */
|
|
80
|
+
const COMPANION_SUFFIXES = ['.spec', '.test', '.stories', '.story', '.d', '.min'];
|
|
81
|
+
/** A pathological reason cannot make this scan unbounded. */
|
|
82
|
+
const MAX_NAMED = 24;
|
|
83
|
+
/** Strip `./`, leading slashes and surrounding whitespace. */
|
|
84
|
+
function normalisePath(p) {
|
|
85
|
+
return p.replace(/^\.\//, '').replace(/^\/+/, '').trim();
|
|
86
|
+
}
|
|
87
|
+
/** The last path segment of a repo-relative path. */
|
|
88
|
+
function basename(p) {
|
|
89
|
+
const i = p.lastIndexOf('/');
|
|
90
|
+
return i < 0 ? p : p.slice(i + 1);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The comparable stem of a file name: the base name with its extension and any
|
|
94
|
+
* companion suffix removed, lowercased. `src/client/pages/Admin.tsx`,
|
|
95
|
+
* `Admin.spec.tsx` and `Admin.stories.tsx` all reduce to `admin`, which is what
|
|
96
|
+
* makes "the enforce diff broke that file's own test" count as an overlap rather
|
|
97
|
+
* than a disjoint (and therefore keepable) failure.
|
|
98
|
+
*/
|
|
99
|
+
export function fileStem(p) {
|
|
100
|
+
let name = basename(normalisePath(p));
|
|
101
|
+
const dot = name.lastIndexOf('.');
|
|
102
|
+
if (dot > 0)
|
|
103
|
+
name = name.slice(0, dot);
|
|
104
|
+
for (const suffix of COMPANION_SUFFIXES) {
|
|
105
|
+
if (name.toLowerCase().endsWith(suffix)) {
|
|
106
|
+
name = name.slice(0, name.length - suffix.length);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return name.toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Every file a FAIL text names, de-duplicated, in first-seen order. Both shapes
|
|
114
|
+
* count: `src/server/routes/photos.ts` (path) and `MyListings.spec.tsx:186` (bare
|
|
115
|
+
* name plus a line number). An empty result is the signal that NOTHING can be
|
|
116
|
+
* attributed — the caller must then revert, never keep.
|
|
117
|
+
*/
|
|
118
|
+
export function extractFailingFiles(text) {
|
|
119
|
+
if (text.trim().length === 0)
|
|
120
|
+
return [];
|
|
121
|
+
const out = [];
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const add = (raw) => {
|
|
124
|
+
const cleaned = normalisePath(raw.replace(TRAILING_POSITION_RE, ''));
|
|
125
|
+
if (cleaned.length === 0)
|
|
126
|
+
return;
|
|
127
|
+
const key = cleaned.toLowerCase();
|
|
128
|
+
if (seen.has(key))
|
|
129
|
+
return;
|
|
130
|
+
seen.add(key);
|
|
131
|
+
if (out.length < MAX_NAMED)
|
|
132
|
+
out.push(cleaned);
|
|
133
|
+
};
|
|
134
|
+
// Paths first so `src/a/B.ts` is recorded as the path, not as the bare `B.ts`
|
|
135
|
+
// its own tail also matches.
|
|
136
|
+
const covered = [];
|
|
137
|
+
for (const m of text.matchAll(PATH_TOKEN_RE)) {
|
|
138
|
+
covered.push([m.index, m.index + m[0].length]);
|
|
139
|
+
add(withPosition(text, m.index + m[0].length, m[0]));
|
|
140
|
+
}
|
|
141
|
+
for (const m of text.matchAll(BARE_FILE_RE)) {
|
|
142
|
+
const start = m.index;
|
|
143
|
+
if (covered.some(([s, e]) => start >= s && start < e))
|
|
144
|
+
continue;
|
|
145
|
+
add(withPosition(text, start + m[0].length, m[0]));
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
/** Re-attach a `:line[:col]` suffix that follows a match, so it can be stripped
|
|
150
|
+
* uniformly (and so `a.ts:1` and `a.ts` collapse to one named file). */
|
|
151
|
+
function withPosition(text, end, token) {
|
|
152
|
+
const m = /^:\d+(?::\d+)?/.exec(text.slice(end));
|
|
153
|
+
return m ? token + m[0] : token;
|
|
154
|
+
}
|
|
155
|
+
/** Two repo-relative paths that denote the same file (suffix-compared). */
|
|
156
|
+
function samePath(a, b) {
|
|
157
|
+
const x = normalisePath(a).toLowerCase();
|
|
158
|
+
const y = normalisePath(b).toLowerCase();
|
|
159
|
+
return x === y || x.endsWith(`/${y}`) || y.endsWith(`/${x}`);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Resolve a named file to a tracked repo path when the repo file list makes it
|
|
163
|
+
* unambiguous. `MyListings.spec.tsx` → `src/client/pages/MyListings.spec.tsx`, so
|
|
164
|
+
* the KEEP path can ask provenance about it and queue a real repair task. An
|
|
165
|
+
* ambiguous name (two tracked files share it) resolves to null: guessing which one
|
|
166
|
+
* the failure meant would put a wrong file in a repair task's title.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveNamedFile(named, repoFiles) {
|
|
169
|
+
if (!repoFiles || repoFiles.length === 0)
|
|
170
|
+
return null;
|
|
171
|
+
const matches = repoFiles.filter(f => samePath(f, named));
|
|
172
|
+
if (matches.length === 1)
|
|
173
|
+
return normalisePath(matches[0]);
|
|
174
|
+
// Exact-path hit wins over the suffix fan-out (a repo may track both
|
|
175
|
+
// `a/x.ts` and `b/a/x.ts`).
|
|
176
|
+
const exact = repoFiles.filter(f => normalisePath(f).toLowerCase() === normalisePath(named).toLowerCase());
|
|
177
|
+
return exact.length === 1 ? normalisePath(exact[0]) : null;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Could the ENFORCE COMMIT have caused this FAIL? `keep` iff the failure names at
|
|
181
|
+
* least one file and NO named file is the enforce diff's, or a companion of one.
|
|
182
|
+
* Every unknown returns `revert`, i.e. exactly the behaviour that shipped before
|
|
183
|
+
* this filter existed.
|
|
184
|
+
*/
|
|
185
|
+
export function attributeEnforceFailure(input) {
|
|
186
|
+
const enforce = (input.enforceTouched ?? []).map(normalisePath).filter(f => f.length > 0);
|
|
187
|
+
if (input.enforceTouched === null || enforce.length === 0) {
|
|
188
|
+
return { verdict: 'revert', why: 'enforce-diff-unknown', named: [], enforceDiff: enforce };
|
|
189
|
+
}
|
|
190
|
+
const raw = extractFailingFiles(input.failReason);
|
|
191
|
+
const named = raw.map(n => resolveNamedFile(n, input.repoFiles ?? null) ?? n);
|
|
192
|
+
if (named.length === 0)
|
|
193
|
+
return { verdict: 'revert', why: 'no-file-named', named, enforceDiff: enforce };
|
|
194
|
+
for (const n of named) {
|
|
195
|
+
const hit = enforce.find(e => samePath(e, n) || fileStem(e) === fileStem(n));
|
|
196
|
+
if (hit) {
|
|
197
|
+
return {
|
|
198
|
+
verdict: 'revert',
|
|
199
|
+
why: 'named-file-in-enforce-diff',
|
|
200
|
+
named,
|
|
201
|
+
enforceDiff: enforce,
|
|
202
|
+
overlap: { named: n, enforce: hit }
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
verdict: 'keep',
|
|
208
|
+
why: 'disjoint-from-enforce-diff',
|
|
209
|
+
named,
|
|
210
|
+
enforceDiff: enforce,
|
|
211
|
+
file: named[0]
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -305,7 +305,9 @@ export declare function preferredDeclaredPort(cwd: string): Promise<number | nul
|
|
|
305
305
|
* needs no socket probe, so run 14's original true positive (a `--hot` runtime
|
|
306
306
|
* pinning a crashed app) stays reportable wherever the tooling exists.
|
|
307
307
|
*
|
|
308
|
-
* Env-gap contract as everywhere: spawn error (ENOENT) or
|
|
308
|
+
* Env-gap contract as everywhere: spawn error (ENOENT) or a command-not-found
|
|
309
|
+
* inside the chain (exit 127, or the runner's own wording where the platform
|
|
310
|
+
* reports it that way — see isCommandNotFound) → skip.
|
|
309
311
|
*/
|
|
310
312
|
export declare function runBootCheck(cwd: string, [bin, args]: HealthCommand, graceMs?: number, opts?: {
|
|
311
313
|
expectServer?: boolean;
|
package/dist/task/final-gate.js
CHANGED
|
@@ -55,7 +55,7 @@ import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts }
|
|
|
55
55
|
import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
|
|
56
56
|
import { runRenderCheck } from './render-check.js';
|
|
57
57
|
import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
|
|
58
|
-
import { resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
58
|
+
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';
|
|
@@ -693,7 +693,9 @@ function holderIsOurs(command, boot) {
|
|
|
693
693
|
* needs no socket probe, so run 14's original true positive (a `--hot` runtime
|
|
694
694
|
* pinning a crashed app) stays reportable wherever the tooling exists.
|
|
695
695
|
*
|
|
696
|
-
* Env-gap contract as everywhere: spawn error (ENOENT) or
|
|
696
|
+
* Env-gap contract as everywhere: spawn error (ENOENT) or a command-not-found
|
|
697
|
+
* inside the chain (exit 127, or the runner's own wording where the platform
|
|
698
|
+
* reports it that way — see isCommandNotFound) → skip.
|
|
697
699
|
*/
|
|
698
700
|
export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
|
|
699
701
|
const expectServer = (opts.expectServer ?? false) && process.platform !== 'win32';
|
|
@@ -881,7 +883,11 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
881
883
|
}
|
|
882
884
|
return settle({ outcome: 'pass' });
|
|
883
885
|
}
|
|
884
|
-
|
|
886
|
+
// Command-not-found inside the boot chain — 127 on a posix shell, or
|
|
887
|
+
// the runner's own wording where it isn't (Windows bun exits 1). Either
|
|
888
|
+
// way the boot never RAN, so it is an environment gap, not an app fault.
|
|
889
|
+
if (isCommandNotFound(status, `${out}\n${err}`)
|
|
890
|
+
|| (status === null && signal === null)) {
|
|
885
891
|
return settle({ outcome: 'skip' });
|
|
886
892
|
}
|
|
887
893
|
const what = status !== null ? `exited ${status}` : `was killed by ${signal}`;
|
|
@@ -967,10 +973,12 @@ function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe) {
|
|
|
967
973
|
});
|
|
968
974
|
if (r.error)
|
|
969
975
|
return { outcome: 'skip', spawnFailed: true };
|
|
970
|
-
if (r.status === null
|
|
976
|
+
if (r.status === null)
|
|
971
977
|
return { outcome: 'skip', spawnFailed: false };
|
|
972
978
|
if (r.status !== 0) {
|
|
973
979
|
const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
|
|
980
|
+
if (isCommandNotFound(r.status, output))
|
|
981
|
+
return { outcome: 'skip', spawnFailed: false };
|
|
974
982
|
if (ENV_GAP_OUTPUT_RE.test(output))
|
|
975
983
|
return { outcome: 'skip', spawnFailed: false };
|
|
976
984
|
if (extraGapRe?.test(output))
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -22,7 +22,7 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
|
|
|
22
22
|
import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
|
|
23
23
|
import { readEnvNotes, appendEnvNotes } from './env-notes.js';
|
|
24
24
|
import { readContracts } from './contracts.js';
|
|
25
|
-
import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt, recordRootCauseDebt } from './accept-debt.js';
|
|
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
28
|
import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
|
|
@@ -469,6 +469,10 @@ export function buildGateDeps(params) {
|
|
|
469
469
|
recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
|
|
470
470
|
recordYoloAcceptDebt: (cwd2, taskId, reason) => recordYoloAcceptDebt(cwd2, taskId, reason),
|
|
471
471
|
recordEnforceRevertDebt: (cwd2, taskId, reason) => recordEnforceRevertDebt(cwd2, taskId, reason),
|
|
472
|
+
// Same ledger, the KEPT disposition (mx5 run 18 / nexttask 4): the enforce
|
|
473
|
+
// re-verify FAILed on a check the enforce diff cannot reach, so the edits
|
|
474
|
+
// stayed and only the defect was recorded.
|
|
475
|
+
recordEnforceKeptDebt: (cwd2, taskId, reason) => recordEnforceKeptDebt(cwd2, taskId, reason),
|
|
472
476
|
// Durable cross-task-contradiction ledger (PROMPT 1 layer B): a repo-health
|
|
473
477
|
// FAIL whose only fix is an edit to a path this task's spec froze — recorded
|
|
474
478
|
// when the gate loop routes it to the picker, re-checked by the final gate.
|
|
@@ -485,6 +489,23 @@ export function buildGateDeps(params) {
|
|
|
485
489
|
recordRepairCandidate: (cwd2, candidate) => recordRepairCandidate(cwd2, candidate),
|
|
486
490
|
// file → introducing task, the provenance half of the discriminator.
|
|
487
491
|
introducedBy: (cwd2, rel) => Promise.resolve(taskThatIntroduced(cwd2, rel)),
|
|
492
|
+
// Tracked paths, used only to resolve a bare file name a FAIL text names
|
|
493
|
+
// (`MyListings.spec.tsx:186`) to its repo path. A git fault returns null,
|
|
494
|
+
// which costs resolution and nothing else.
|
|
495
|
+
repoFiles: async (cwd2) => {
|
|
496
|
+
try {
|
|
497
|
+
const r = await git(cwd2, ['ls-files'], signal);
|
|
498
|
+
if (r.exitCode !== 0)
|
|
499
|
+
return null;
|
|
500
|
+
return r.stdout
|
|
501
|
+
.split('\n')
|
|
502
|
+
.map(l => l.trim())
|
|
503
|
+
.filter(l => l.length > 0);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
},
|
|
488
509
|
// The authorship half: which files THIS task's work touched. `worktree` is
|
|
489
510
|
// the pre-commit verify site (uncommitted changes); `committed` is the
|
|
490
511
|
// post-commit enforce site, where the task snapshot and the ENFORCE commit
|
|
@@ -499,7 +520,18 @@ export function buildGateDeps(params) {
|
|
|
499
520
|
const c = parseTreeChanges(r.stdout);
|
|
500
521
|
return [...c.modified, ...c.added, ...c.deleted];
|
|
501
522
|
}
|
|
502
|
-
|
|
523
|
+
// `enforce-commit` is HEAD ALONE — at the enforce differential HEAD is
|
|
524
|
+
// the ENFORCE commit, and that commit's own diff is the only file set
|
|
525
|
+
// that can answer "could reverting this repair the failure?" (mx5 run
|
|
526
|
+
// 18 / nexttask 4).
|
|
527
|
+
const r = await git(cwd2, [
|
|
528
|
+
'log',
|
|
529
|
+
'-n',
|
|
530
|
+
scope === 'enforce-commit' ? '1' : '2',
|
|
531
|
+
'--name-only',
|
|
532
|
+
'--format=',
|
|
533
|
+
'HEAD'
|
|
534
|
+
], signal);
|
|
503
535
|
if (r.exitCode !== 0)
|
|
504
536
|
return null;
|
|
505
537
|
return r.stdout
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import { existsSync, readFileSync } from 'node:fs';
|
|
34
34
|
import * as path from 'node:path';
|
|
35
|
-
import { resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
35
|
+
import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
|
|
36
36
|
/** How much of a failing command's output to keep — bounded so a wedged tool that
|
|
37
37
|
* spews megabytes cannot bloat the trail. stderr leads (a crash trace lives there). */
|
|
38
38
|
const HEALTH_OUTPUT_MAX_LINES = 40;
|
|
@@ -142,10 +142,12 @@ export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
|
|
|
142
142
|
// Tool missing (ENOENT) or killed by timeout → cannot conclude; skip it.
|
|
143
143
|
if (r.error || r.status === null)
|
|
144
144
|
continue;
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
145
|
+
// "Command not found" INSIDE the script chain (e.g. `bun run lint` before
|
|
146
|
+
// node_modules exists — seen live failing TASK_0001's first verify). Same
|
|
147
|
+
// environment gap as ENOENT, just surfaced through the runner's shell —
|
|
148
|
+
// as exit 127 where a posix shell ran it, else by the runner's own wording
|
|
149
|
+
// (Windows bun reports the miss itself and exits 1).
|
|
150
|
+
if (isCommandNotFound(r.status, `${r.stdout ?? ''}\n${r.stderr ?? ''}`))
|
|
149
151
|
continue;
|
|
150
152
|
if (r.status !== 0) {
|
|
151
153
|
return {
|
|
@@ -20,6 +20,31 @@ export declare function resolveRunner(bin: string, opts?: {
|
|
|
20
20
|
probe?: (bin: string) => boolean;
|
|
21
21
|
env?: NodeJS.ProcessEnv;
|
|
22
22
|
}): ResolvedRunner;
|
|
23
|
+
/**
|
|
24
|
+
* Output shapes a RUNNER emits when the command inside a script chain does not
|
|
25
|
+
* exist, on platforms where that is not reported as exit 127.
|
|
26
|
+
*
|
|
27
|
+
* 127 is a POSIX-SHELL convention: on Linux/macOS bun hands the script to
|
|
28
|
+
* /bin/sh, the shell prints `…: command not found` and exits 127, and the whole
|
|
29
|
+
* env-gap contract keys off that number. On Windows there is no such shell —
|
|
30
|
+
* bun runs the script in its own built-in shell, which reports the miss itself
|
|
31
|
+
* (`bun: command not found: X`) and exits **1**, indistinguishable by status
|
|
32
|
+
* alone from a real code fault. cmd.exe (9009) and PowerShell have their own
|
|
33
|
+
* wording. Recognising the shape restores one env-gap contract on all three.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately narrow: only wordings a RUNNER/SHELL produces, never the bare
|
|
36
|
+
* phrase. A suite that prints "command not found" inside a failing assertion is
|
|
37
|
+
* a real FAIL and must stay one — the posix shape already travels as 127.
|
|
38
|
+
*/
|
|
39
|
+
export declare const COMMAND_NOT_FOUND_OUTPUT_RE: RegExp;
|
|
40
|
+
/**
|
|
41
|
+
* Did this command fail because the thing it tried to run does not exist here,
|
|
42
|
+
* rather than because the code is wrong? Exit 127 (POSIX shell) or 9009
|
|
43
|
+
* (cmd.exe) say so outright; anything else needs the runner's own wording (see
|
|
44
|
+
* COMMAND_NOT_FOUND_OUTPUT_RE) — a Windows `bun run dev` on a missing binary
|
|
45
|
+
* exits 1. Callers treat a true here as an environment gap → skip, never FAIL.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isCommandNotFound(status: number | null, output?: string): boolean;
|
|
23
48
|
/**
|
|
24
49
|
* The env a spawn site should pass so the resolved runner's script chain can
|
|
25
50
|
* re-invoke it: base env with the runner's directory prepended to PATH. With no
|
|
@@ -83,6 +83,37 @@ export function resolveRunner(bin, opts = {}) {
|
|
|
83
83
|
cache.set(bin, resolved);
|
|
84
84
|
return resolved;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Output shapes a RUNNER emits when the command inside a script chain does not
|
|
88
|
+
* exist, on platforms where that is not reported as exit 127.
|
|
89
|
+
*
|
|
90
|
+
* 127 is a POSIX-SHELL convention: on Linux/macOS bun hands the script to
|
|
91
|
+
* /bin/sh, the shell prints `…: command not found` and exits 127, and the whole
|
|
92
|
+
* env-gap contract keys off that number. On Windows there is no such shell —
|
|
93
|
+
* bun runs the script in its own built-in shell, which reports the miss itself
|
|
94
|
+
* (`bun: command not found: X`) and exits **1**, indistinguishable by status
|
|
95
|
+
* alone from a real code fault. cmd.exe (9009) and PowerShell have their own
|
|
96
|
+
* wording. Recognising the shape restores one env-gap contract on all three.
|
|
97
|
+
*
|
|
98
|
+
* Deliberately narrow: only wordings a RUNNER/SHELL produces, never the bare
|
|
99
|
+
* phrase. A suite that prints "command not found" inside a failing assertion is
|
|
100
|
+
* a real FAIL and must stay one — the posix shape already travels as 127.
|
|
101
|
+
*/
|
|
102
|
+
export const COMMAND_NOT_FOUND_OUTPUT_RE = /\b(?:bun|npm|pnpm|yarn|node|deno): command not found:|is not recognized as an internal or external command|is not recognized as the name of a cmdlet/i;
|
|
103
|
+
/**
|
|
104
|
+
* Did this command fail because the thing it tried to run does not exist here,
|
|
105
|
+
* rather than because the code is wrong? Exit 127 (POSIX shell) or 9009
|
|
106
|
+
* (cmd.exe) say so outright; anything else needs the runner's own wording (see
|
|
107
|
+
* COMMAND_NOT_FOUND_OUTPUT_RE) — a Windows `bun run dev` on a missing binary
|
|
108
|
+
* exits 1. Callers treat a true here as an environment gap → skip, never FAIL.
|
|
109
|
+
*/
|
|
110
|
+
export function isCommandNotFound(status, output = '') {
|
|
111
|
+
if (status === 127 || status === 9009)
|
|
112
|
+
return true;
|
|
113
|
+
if (status === null || status === 0)
|
|
114
|
+
return false;
|
|
115
|
+
return COMMAND_NOT_FOUND_OUTPUT_RE.test(output);
|
|
116
|
+
}
|
|
86
117
|
/**
|
|
87
118
|
* The env a spawn site should pass so the resolved runner's script chain can
|
|
88
119
|
* re-invoke it: base env with the runner's directory prepended to PATH. With no
|
|
@@ -170,6 +170,13 @@ export interface GateDeps {
|
|
|
170
170
|
* so the final gate must re-check and surface it. Best-effort; absent in tests.
|
|
171
171
|
*/
|
|
172
172
|
recordRootCauseDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
173
|
+
/**
|
|
174
|
+
* Record a durable ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): the enforce
|
|
175
|
+
* re-verify FAILED but the failing check names only files the ENFORCE COMMIT
|
|
176
|
+
* does not touch, so the edits were KEPT. The defect is still real and still in
|
|
177
|
+
* the shipped tree — keeping the work must not lose the finding.
|
|
178
|
+
*/
|
|
179
|
+
recordEnforceKeptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
173
180
|
/**
|
|
174
181
|
* Queue a scoped repair task for a root-caused defect. The gate DETECTS the
|
|
175
182
|
* cause; only the /task-auto loop may mutate the plan, so the two are decoupled
|
|
@@ -184,12 +191,22 @@ export interface GateDeps {
|
|
|
184
191
|
* this task's own fault, and only a file it never touched can be somebody
|
|
185
192
|
* else's pre-existing bug. `worktree` = uncommitted changes (the pre-commit
|
|
186
193
|
* verify site); `committed` = the files the task snapshot + the ENFORCE commit
|
|
187
|
-
* changed (the post-commit enforce site)
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
194
|
+
* changed (the post-commit enforce site); `enforce-commit` = the ENFORCE COMMIT
|
|
195
|
+
* ALONE, which is the only correct authorship question at the enforce
|
|
196
|
+
* differential (mx5 run 18 / nexttask 4 — that differential decides whether to
|
|
197
|
+
* discard the enforce commit, so what the TASK touched is irrelevant to it).
|
|
198
|
+
* `null` means UNKNOWN (git unavailable) and stands the whole channel down —
|
|
199
|
+
* inconclusive is never evidence, so an unreadable tree can only cost a repair
|
|
200
|
+
* task, never spawn a wrong one or wrongly keep a regression.
|
|
201
|
+
*/
|
|
202
|
+
touchedFiles?: (cwd: string, scope: 'worktree' | 'committed' | 'enforce-commit') => Promise<string[] | null>;
|
|
203
|
+
/**
|
|
204
|
+
* Every path git tracks in the repo — used ONLY to resolve a bare file name a
|
|
205
|
+
* FAIL text names (`MyListings.spec.tsx:186`) to its repo path, so the defect
|
|
206
|
+
* can be attributed and a repair queued for it. Absent/null costs resolution,
|
|
207
|
+
* never changes a keep/revert verdict.
|
|
191
208
|
*/
|
|
192
|
-
|
|
209
|
+
repoFiles?: (cwd: string) => Promise<string[] | null>;
|
|
193
210
|
/** The task whose commit INTRODUCED a file (task-provenance.ts). Null for a
|
|
194
211
|
* file predating the run or any git error → unknown provenance. */
|
|
195
212
|
introducedBy?: (cwd: string, rel: string) => Promise<string | null>;
|