@mjasnikovs/pi-task 0.18.39 → 0.18.41
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 +18 -1
- package/dist/task/accept-debt.js +20 -1
- package/dist/task/auto-io.d.ts +16 -0
- package/dist/task/auto-io.js +54 -0
- package/dist/task/auto-orchestrator.js +93 -5
- package/dist/task/auto-prompts.d.ts +5 -1
- package/dist/task/auto-prompts.js +7 -2
- package/dist/task/batch-test-task.d.ts +71 -0
- package/dist/task/batch-test-task.js +320 -0
- package/dist/task/gate-deps.js +36 -1
- package/dist/task/root-cause-repair.d.ts +122 -0
- package/dist/task/root-cause-repair.js +378 -0
- package/dist/task/task-gates.d.ts +31 -0
- package/dist/task/task-gates.js +58 -1
- package/package.json +1 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* batch-test-task — ban the whole-project "write all the tests" task when the
|
|
3
|
+
* DECISIONS channel mandates tests-in-the-same-change (mx5 run 14, PROMPT item 6).
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes: run 14's plan contained
|
|
6
|
+
*
|
|
7
|
+
* TASK_0037 "Write component and page tests — Playwright CT tests with
|
|
8
|
+
* screenshot baselines for all components and pages"
|
|
9
|
+
*
|
|
10
|
+
* — ~4.7h, the worst active-time task of the run, ended in a yolo-accepted verify
|
|
11
|
+
* FAIL with a frozen-config violation — while the very decision carried ON THAT
|
|
12
|
+
* TITLE said the opposite:
|
|
13
|
+
*
|
|
14
|
+
* "a test lands *as fast as possible* — in the same change — as each new route
|
|
15
|
+
* or React component/page. No route or component is considered done until its
|
|
16
|
+
* test exists and passes. Don't batch testing to the end of a milestone."
|
|
17
|
+
*
|
|
18
|
+
* Decompose did not invent the shape: the spec's own §10/§12 structure induced it,
|
|
19
|
+
* and decompose mirrors the spec. So the conflict is SPEC-INTERNAL (the spec's
|
|
20
|
+
* milestone shape vs the spec's own cadence rule), and the decisions channel
|
|
21
|
+
* OVERRIDES the spec doc by definition — the same precedence the task titles
|
|
22
|
+
* already state. Resolve toward the decision; never ask the user.
|
|
23
|
+
*
|
|
24
|
+
* The mechanism is a deterministic post-decompose rewrite (applied on EVERY
|
|
25
|
+
* decompose output, like fidelity reconciliation), plus a conditional prompt rule
|
|
26
|
+
* as the belt. Only the batch-EVERYTHING shape is banned — coverage is never
|
|
27
|
+
* nuked (run 12's lesson). Which of the two outcomes fires is decided by
|
|
28
|
+
* `groundedCoverage`, not by wording:
|
|
29
|
+
*
|
|
30
|
+
* • the batch title grounds NO requirement that survives its removal ⇒ every
|
|
31
|
+
* requirement it touched is owned by some other task, the per-change cadence
|
|
32
|
+
* already covers the work, and the task is DROPPED;
|
|
33
|
+
* • it is the ONLY title grounding some requirement(s) ⇒ dropping it would
|
|
34
|
+
* reduce planned coverage, so it is REPLACED by a scoped sweep that names
|
|
35
|
+
* exactly those orphaned requirements and points at the spec's own coverage
|
|
36
|
+
* source ("fill the gaps <that command> reports"), never "test everything".
|
|
37
|
+
*
|
|
38
|
+
* Because the replacement's owned-set is computed from the same groundedCoverage
|
|
39
|
+
* the monotonic adoption guard uses, total planned coverage cannot fall.
|
|
40
|
+
*/
|
|
41
|
+
import { groundedCoverage } from './coverage-loop.js';
|
|
42
|
+
/**
|
|
43
|
+
* Sentences that MANDATE tests landing with the change they cover. Deliberately
|
|
44
|
+
* narrow: a spec that merely REQUIRES tests ("every route has tests") does not
|
|
45
|
+
* ban a batch task — only an explicit cadence/anti-batch directive does.
|
|
46
|
+
*/
|
|
47
|
+
const SAME_CHANGE_RE = /\bin the same (?:change|commit|pr|patch|diff|task|step)\b|\b(?:do ?n['’]?t|do not|never|no)\s+(?:batch|defer|postpone|save|leave)\b|\bnot\b[^.]{0,60}\bdone until\b[^.]{0,60}\btest/i;
|
|
48
|
+
/** Any mention of testing — the mandate must be ABOUT tests, not about docs. */
|
|
49
|
+
const TEST_WORD_RE = /\btests?\b|\btesting\b|\btest-first\b/i;
|
|
50
|
+
/**
|
|
51
|
+
* Does the decisions/spec text mandate tests-in-the-same-change?
|
|
52
|
+
*
|
|
53
|
+
* Scans sentence by sentence and requires BOTH signals in the SAME sentence, so
|
|
54
|
+
* a testing section that happens to sit near an unrelated "don't defer" line
|
|
55
|
+
* cannot trigger the ban. `decisions` is checked first and alone is sufficient;
|
|
56
|
+
* the spec is scanned too because run 14's cadence rule is stated in §10 of the
|
|
57
|
+
* doc and only echoed into the decisions channel.
|
|
58
|
+
*/
|
|
59
|
+
export function mandatesTestsInSameChange(decisions, spec = '') {
|
|
60
|
+
for (const text of [decisions, spec]) {
|
|
61
|
+
for (const sentence of text.split(/(?<=[.!?])\s+|\n{2,}/)) {
|
|
62
|
+
if (TEST_WORD_RE.test(sentence) && SAME_CHANGE_RE.test(sentence))
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
/** Trailing `[decisions: …]` clauses decompose appends — carried onto the
|
|
69
|
+
* replacement title verbatim so the rewrite never loses a directive. */
|
|
70
|
+
const DECISIONS_CLAUSE_RE = /\s*\[decisions:/i;
|
|
71
|
+
/**
|
|
72
|
+
* Where decompose's trailing metadata starts. `[source: …]` is included because
|
|
73
|
+
* reconcileTitleSources only strips a WELL-FORMED trailing citation: measured
|
|
74
|
+
* live, the model also emits `[source: "…" [10. Testing]]`, which fails that
|
|
75
|
+
* regex and leaks the QUOTED SPEC LINE into the title. Judging scope on such a
|
|
76
|
+
* title reads the citation's words as the task's own — the cadence quote "…as
|
|
77
|
+
* each new route or React component/page" made a properly scoped
|
|
78
|
+
* "Write route/API tests for auth" look like a batch task (live false positive,
|
|
79
|
+
* rep 1). A citation is provenance, never scope.
|
|
80
|
+
*/
|
|
81
|
+
const CLAUSE_START_RE = /\s*\[(?:source|decisions)\s*:/i;
|
|
82
|
+
/** Split a title into the part decompose authored, and the `[decisions: …]` tail
|
|
83
|
+
* that must survive onto a replacement title. A leaked `[source: …]` remnant is
|
|
84
|
+
* dropped from both — the sweep does not derive from that line. */
|
|
85
|
+
function splitDecisions(title) {
|
|
86
|
+
const clause = CLAUSE_START_RE.exec(title);
|
|
87
|
+
const body = (clause ? title.slice(0, clause.index) : title).trim();
|
|
88
|
+
const decisions = DECISIONS_CLAUSE_RE.exec(title);
|
|
89
|
+
return { body, tail: decisions ? title.slice(decisions.index) : '' };
|
|
90
|
+
}
|
|
91
|
+
/** The head of a title: everything before the " — <detail>" separator. */
|
|
92
|
+
function head(body) {
|
|
93
|
+
return body.split(/\s+[—–-]\s+|\s*\|\s*/)[0];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Remove double-quoted spans — they are QUOTED SPEC TEXT, never the task's own
|
|
97
|
+
* scope. Measured live: the model frequently appends its citation as a bare
|
|
98
|
+
* quote with no `[source: …]` wrapper ("Write auth route tests — … — "…a test
|
|
99
|
+
* lands as fast as possible — in the same change — as each new route or React
|
|
100
|
+
* component/page.""), and that borrowed "each" made five correctly scoped
|
|
101
|
+
* per-area test tasks read as batch tasks (rep 5). Trading a possible miss (a
|
|
102
|
+
* batch title whose only quantifier sits inside a quote) for never deleting a
|
|
103
|
+
* properly scoped test task: the false positive is far the more expensive error.
|
|
104
|
+
*/
|
|
105
|
+
function stripQuotedSpans(s) {
|
|
106
|
+
return s.replace(/"[^"]*"/g, ' ').replace(/[“][^”]*[”]/g, ' ');
|
|
107
|
+
}
|
|
108
|
+
/** A title whose DELIVERABLE is tests: an authoring verb whose object is tests,
|
|
109
|
+
* with nothing else claimed in between ("Write component and page tests" ✓,
|
|
110
|
+
* "Add listings CRUD + tests" ✗ — the `+` marks tests as an additive constraint
|
|
111
|
+
* on a feature task, which is exactly the cadence the decision asks for). */
|
|
112
|
+
const TEST_AUTHORING_RE = /^\s*(?:(?:write|add|create|implement|author|build|develop|produce|backfill)\b[\w\s,/-]*\b(?:tests?|test suite|test coverage|testing)\b|(?:tests?|testing)\b)/i;
|
|
113
|
+
/** Enabling/infrastructure work — a runner, a config, fixtures, CI. Those are
|
|
114
|
+
* NOT batched test authoring; banning them would remove the very thing the
|
|
115
|
+
* per-change tests need to exist first. */
|
|
116
|
+
const TEST_INFRA_RE = /\b(?:harness|infrastructure|infra|runner|config|configuration|scaffold|scaffolding|set ?up|fixtures?|helpers?|utilities|utility|ci|pipeline|database|seed)\b/i;
|
|
117
|
+
/**
|
|
118
|
+
* WHOLE-PROJECT scope: a universal quantifier that governs a PROJECT-LEVEL target
|
|
119
|
+
* ("all components and pages", "every route"), not merely any noun.
|
|
120
|
+
*
|
|
121
|
+
* Both halves are load-bearing, each learned from a live false positive:
|
|
122
|
+
* - "full"/"complete" are excluded — they routinely scope a single area
|
|
123
|
+
* ("full login flow");
|
|
124
|
+
* - the quantifier must reach a project-level plural within ~20 chars, because
|
|
125
|
+
* "Test PartCard component — ALL badge combinations…" is a one-component task
|
|
126
|
+
* that a bare-quantifier rule flagged and DROPPED (live rep 3).
|
|
127
|
+
*/
|
|
128
|
+
const WHOLE_SCOPE_RE = /\b(?:all|every|each|entire|whole|comprehensive)\b[\w\s,/-]{0,20}?\b(?:components?|pages?|routes?|endpoints?|screens?|views?|modules?|layers?|files?|features?|app|application|project|codebase|repo|repository|system|surface)\b|\bacross the (?:app|application|project|codebase|repo|repository)\b|\bend[- ]to[- ]end coverage\b/i;
|
|
129
|
+
/**
|
|
130
|
+
* Indices of titles that are whole-project BATCH test tasks.
|
|
131
|
+
*
|
|
132
|
+
* Three conditions must all hold — the title's deliverable is tests, its scope is
|
|
133
|
+
* the whole project, and it is not test INFRASTRUCTURE. A per-feature task that
|
|
134
|
+
* carries "+ tests" never matches (its head names the feature), which is the
|
|
135
|
+
* point: those are the cadence the decision asks for.
|
|
136
|
+
*/
|
|
137
|
+
export function findBatchTestTitles(titles) {
|
|
138
|
+
const out = [];
|
|
139
|
+
for (let i = 0; i < titles.length; i++) {
|
|
140
|
+
const { body } = splitDecisions(titles[i]);
|
|
141
|
+
const scope = stripQuotedSpans(body);
|
|
142
|
+
const h = head(scope);
|
|
143
|
+
if (!TEST_AUTHORING_RE.test(h))
|
|
144
|
+
continue;
|
|
145
|
+
if (TEST_INFRA_RE.test(h))
|
|
146
|
+
continue;
|
|
147
|
+
if (!WHOLE_SCOPE_RE.test(scope))
|
|
148
|
+
continue;
|
|
149
|
+
out.push(i);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/** Test-runner commands a spec may name as the thing that REPORTS coverage. */
|
|
154
|
+
const TEST_COMMAND_RE = /\b(?:bun test|npm (?:run )?test|yarn test|pnpm (?:run )?test|npx (?:vitest|jest|playwright test)|playwright test|vitest|jest|pytest|go test|cargo test|mix test|rspec)\b[^`\n]*/i;
|
|
155
|
+
/**
|
|
156
|
+
* The spec's own coverage source — the command whose report scopes the sweep.
|
|
157
|
+
*
|
|
158
|
+
* Only backticked spans are considered, so the result is a command the spec
|
|
159
|
+
* actually writes down rather than a phrase inferred from prose; a span carrying
|
|
160
|
+
* a coverage flag wins over a plain test run. Falls back to a generic phrase when
|
|
161
|
+
* the spec names no command, which keeps the sweep title well-formed either way.
|
|
162
|
+
*/
|
|
163
|
+
export function coverageSourceFromSpec(spec) {
|
|
164
|
+
const spans = [...spec.matchAll(/`([^`\n]+)`/g)].map(m => m[1].trim());
|
|
165
|
+
const commands = spans.filter(s => TEST_COMMAND_RE.test(s));
|
|
166
|
+
const withCoverage = commands.find(s => /--coverage|\bcoverage\b/i.test(s));
|
|
167
|
+
return withCoverage ?? commands[0] ?? "the project's own test suite";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Vocabulary that every testing task and every testing requirement shares, so it
|
|
171
|
+
* carries NO ownership signal here. Without this scrub the bare token "test"
|
|
172
|
+
* connects a screenshot-baseline requirement to "Write auth route tests", and the
|
|
173
|
+
* orphan check goes blind. (Scrubbed locally rather than added to
|
|
174
|
+
* COVERAGE_STOPWORDS: those govern the A/B-validated monotonic adoption guard,
|
|
175
|
+
* where "test" IS a discriminating noun for a plan that has no testing task at
|
|
176
|
+
* all.) Token-level, not regex — `mx5_test` must scrub to `mx5`.
|
|
177
|
+
*/
|
|
178
|
+
const GENERIC_TEST_TOKENS = new Set([
|
|
179
|
+
'test',
|
|
180
|
+
'tests',
|
|
181
|
+
'testing',
|
|
182
|
+
'tested',
|
|
183
|
+
'spec',
|
|
184
|
+
'specs',
|
|
185
|
+
'suite',
|
|
186
|
+
'suites',
|
|
187
|
+
'coverage',
|
|
188
|
+
'assertion',
|
|
189
|
+
'assertions',
|
|
190
|
+
'case',
|
|
191
|
+
'cases',
|
|
192
|
+
'unit',
|
|
193
|
+
'e2e'
|
|
194
|
+
]);
|
|
195
|
+
/** Drop the generic testing vocabulary, keeping the tokenization coverage-loop
|
|
196
|
+
* itself uses so the scrubbed text grounds exactly the same way. */
|
|
197
|
+
function scrubTestVocabulary(s) {
|
|
198
|
+
return s
|
|
199
|
+
.toLowerCase()
|
|
200
|
+
.split(/[^a-z0-9]+/)
|
|
201
|
+
.filter(w => w.length > 0 && !GENERIC_TEST_TOKENS.has(w))
|
|
202
|
+
.join(' ');
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Requirement indices that lose their owner when the batch titles are removed.
|
|
206
|
+
*
|
|
207
|
+
* Two independent measures, unioned:
|
|
208
|
+
*
|
|
209
|
+
* 1. STRICT (the one that actually fires): generic testing vocabulary scrubbed
|
|
210
|
+
* from both sides, and a requirement that is ABOUT testing may only be owned
|
|
211
|
+
* by a title that is about testing — "Implement login page" does not
|
|
212
|
+
* discharge "every component/page test captures a screenshot".
|
|
213
|
+
* 2. PLAIN groundedCoverage, exactly as the monotonic adoption guard measures
|
|
214
|
+
* it. This is the belt: whatever that guard would call a drop is an orphan
|
|
215
|
+
* here too, so the rewrite can never trip the guard it feeds.
|
|
216
|
+
*/
|
|
217
|
+
function orphanedRequirements(quotes, titles, kept, isCrossCutting) {
|
|
218
|
+
const scrubbed = quotes.map(scrubTestVocabulary);
|
|
219
|
+
// Index-aligned cross-cutting lookup: the scrubbed text is not the quote the
|
|
220
|
+
// classifier expects, so map back to the original.
|
|
221
|
+
const crossByScrub = new Map();
|
|
222
|
+
quotes.forEach((q, i) => crossByScrub.set(scrubbed[i], isCrossCutting(q)));
|
|
223
|
+
const isCrossScrubbed = (s) => crossByScrub.get(s) ?? false;
|
|
224
|
+
const testish = (t) => TEST_WORD_RE.test(t);
|
|
225
|
+
const scrub = (list) => list.map(scrubTestVocabulary);
|
|
226
|
+
const strictBefore = groundedCoverage(scrubbed, scrub(titles), isCrossScrubbed);
|
|
227
|
+
const strictAfterAll = groundedCoverage(scrubbed, scrub(kept), isCrossScrubbed);
|
|
228
|
+
const strictAfterTest = groundedCoverage(scrubbed, scrub(kept.filter(testish)), isCrossScrubbed);
|
|
229
|
+
const plainBefore = groundedCoverage(quotes, titles, isCrossCutting);
|
|
230
|
+
const plainAfter = groundedCoverage(quotes, kept, isCrossCutting);
|
|
231
|
+
const out = [];
|
|
232
|
+
for (let i = 0; i < quotes.length; i++) {
|
|
233
|
+
const owner = testish(quotes[i]) ? strictAfterTest : strictAfterAll;
|
|
234
|
+
const strictLost = strictBefore.has(i) && !owner.has(i);
|
|
235
|
+
const plainLost = plainBefore.has(i) && !plainAfter.has(i);
|
|
236
|
+
if (strictLost || plainLost)
|
|
237
|
+
out.push(i);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
/** How many orphaned requirement quotes the sweep title names, and how long each
|
|
242
|
+
* may be — a title is a one-line handoff, not a ledger. */
|
|
243
|
+
const MAX_SWEEP_QUOTES = 6;
|
|
244
|
+
const MAX_QUOTE_CHARS = 120;
|
|
245
|
+
function shorten(q) {
|
|
246
|
+
const s = q.replace(/\s+/g, ' ').trim();
|
|
247
|
+
return s.length <= MAX_QUOTE_CHARS ? s : s.slice(0, MAX_QUOTE_CHARS - 1).trimEnd() + '…';
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The scoped replacement: a gap-filling sweep, bounded by what the coverage
|
|
251
|
+
* source reports and by the requirements that lost their only owner. It states
|
|
252
|
+
* the ban explicitly, because the child that receives this title sees nothing
|
|
253
|
+
* else.
|
|
254
|
+
*/
|
|
255
|
+
export function buildSweepTitle(orphaned, coverageSource) {
|
|
256
|
+
const named = orphaned.slice(0, MAX_SWEEP_QUOTES).map(q => `"${shorten(q)}"`);
|
|
257
|
+
const more = orphaned.length > named.length ? ` (+${orphaned.length - named.length} more)` : '';
|
|
258
|
+
return (`Fill the test-coverage gaps left by the per-change tests — run \`${coverageSource}\`,`
|
|
259
|
+
+ ' and add tests ONLY for what it reports uncovered, specifically:'
|
|
260
|
+
+ ` ${named.join('; ')}${more}.`
|
|
261
|
+
+ ' Do NOT re-test what earlier tasks already covered, and do NOT modify'
|
|
262
|
+
+ ' existing test config or existing tests.');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Rewrite a decomposed plan so it carries no whole-project batch test task when
|
|
266
|
+
* the decisions mandate tests-in-the-same-change.
|
|
267
|
+
*
|
|
268
|
+
* No mandate, or no batch title ⇒ the plan is returned untouched (identity), so
|
|
269
|
+
* every non-cadence run behaves exactly as before.
|
|
270
|
+
*
|
|
271
|
+
* With a mandate, ALL batch titles are removed at once before coverage is
|
|
272
|
+
* re-measured — otherwise two batch tasks would each mask the other's orphans and
|
|
273
|
+
* both would look droppable. The orphaned set is then whatever grounded coverage
|
|
274
|
+
* the removal costs; it is non-empty only when no other task's title claims that
|
|
275
|
+
* requirement, and it becomes the sweep's scope. At most ONE sweep is emitted (in
|
|
276
|
+
* the first batch title's position, so plan order is preserved).
|
|
277
|
+
*/
|
|
278
|
+
export function rewriteBatchTestPlan(titles, decisions, spec, requirementQuotes, isCrossCutting) {
|
|
279
|
+
if (!mandatesTestsInSameChange(decisions, spec))
|
|
280
|
+
return { titles, actions: [] };
|
|
281
|
+
const batch = findBatchTestTitles(titles);
|
|
282
|
+
if (batch.length === 0)
|
|
283
|
+
return { titles, actions: [] };
|
|
284
|
+
const batchSet = new Set(batch);
|
|
285
|
+
const kept = titles.filter((_, i) => !batchSet.has(i));
|
|
286
|
+
const orphaned = orphanedRequirements(requirementQuotes, titles, kept, isCrossCutting).map(i => requirementQuotes[i]);
|
|
287
|
+
const actions = [];
|
|
288
|
+
const out = [];
|
|
289
|
+
let sweepEmitted = false;
|
|
290
|
+
for (let i = 0; i < titles.length; i++) {
|
|
291
|
+
if (!batchSet.has(i)) {
|
|
292
|
+
out.push(titles[i]);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (orphaned.length === 0 || sweepEmitted) {
|
|
296
|
+
actions.push({ index: i, title: titles[i], kind: 'dropped', orphaned: [] });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// Carry the offending title's own [decisions: …] clauses onto the sweep —
|
|
300
|
+
// they are user directives and survive the reshaping of the task.
|
|
301
|
+
const { tail } = splitDecisions(titles[i]);
|
|
302
|
+
const replacement = buildSweepTitle(orphaned, coverageSourceFromSpec(spec)) + tail;
|
|
303
|
+
actions.push({ index: i, title: titles[i], kind: 'scoped', replacement, orphaned });
|
|
304
|
+
out.push(replacement);
|
|
305
|
+
sweepEmitted = true;
|
|
306
|
+
}
|
|
307
|
+
return { titles: out, actions };
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The decompose-prompt rule (the belt; the host rewrite above is the lever).
|
|
311
|
+
* Emitted ONLY when the decisions mandate the cadence, so ordinary runs see the
|
|
312
|
+
* prompt they always saw. Kept next to the detector so the two cannot drift.
|
|
313
|
+
*/
|
|
314
|
+
export const DECOMPOSE_NO_BATCH_TESTS_RULE = '- The CLARIFICATIONS mandate that tests land in the SAME change as the code they'
|
|
315
|
+
+ ' cover. So do NOT emit a task whose job is writing tests for all components /'
|
|
316
|
+
+ ' all routes / the whole project — that shape is rejected host-side. Fold each'
|
|
317
|
+
+ " test into the task that builds the thing it tests (name it in that task's"
|
|
318
|
+
+ ' title), and emit a separate testing task ONLY as a narrowly scoped sweep of'
|
|
319
|
+
+ ' gaps a coverage run reports. Test INFRASTRUCTURE (runner, config, fixtures)'
|
|
320
|
+
+ ' may still be its own early task.';
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -22,7 +22,8 @@ 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 } from './accept-debt.js';
|
|
25
|
+
import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt, recordRootCauseDebt } from './accept-debt.js';
|
|
26
|
+
import { recordRepairCandidate } from './root-cause-repair.js';
|
|
26
27
|
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
27
28
|
import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
|
|
28
29
|
import { runFinalGateAutofix } from './final-gate-fix.js';
|
|
@@ -471,6 +472,40 @@ export function buildGateDeps(params) {
|
|
|
471
472
|
// deliverable this task's diff deletes, ACCEPTed into a commit anyway —
|
|
472
473
|
// the final gate re-checks it (resolved iff the file is back in the tree).
|
|
473
474
|
recordCrossTaskDeletionDebt: (cwd2, taskId, deletion) => recordCrossTaskDeletionDebt(cwd2, taskId, deletion),
|
|
475
|
+
// ROOT-CAUSE channel (mx5 run 14 item 5): a FAIL another task's untouched
|
|
476
|
+
// file caused is recorded as its own debt class and queued as a scoped
|
|
477
|
+
// repair task, instead of being blamed on — and reverted out of — the task
|
|
478
|
+
// that merely tripped over it.
|
|
479
|
+
recordRootCauseDebt: (cwd2, taskId, reason) => recordRootCauseDebt(cwd2, taskId, reason),
|
|
480
|
+
recordRepairCandidate: (cwd2, candidate) => recordRepairCandidate(cwd2, candidate),
|
|
481
|
+
// file → introducing task, the provenance half of the discriminator.
|
|
482
|
+
introducedBy: (cwd2, rel) => Promise.resolve(taskThatIntroduced(cwd2, rel)),
|
|
483
|
+
// The authorship half: which files THIS task's work touched. `worktree` is
|
|
484
|
+
// the pre-commit verify site (uncommitted changes); `committed` is the
|
|
485
|
+
// post-commit enforce site, where the task snapshot and the ENFORCE commit
|
|
486
|
+
// are the last two commits. Any git fault returns null, which stands the
|
|
487
|
+
// channel down entirely rather than guessing.
|
|
488
|
+
touchedFiles: async (cwd2, scope) => {
|
|
489
|
+
try {
|
|
490
|
+
if (scope === 'worktree') {
|
|
491
|
+
const r = await git(cwd2, ['status', '--porcelain'], signal);
|
|
492
|
+
if (r.exitCode !== 0)
|
|
493
|
+
return null;
|
|
494
|
+
const c = parseTreeChanges(r.stdout);
|
|
495
|
+
return [...c.modified, ...c.added, ...c.deleted];
|
|
496
|
+
}
|
|
497
|
+
const r = await git(cwd2, ['log', '-n', '2', '--name-only', '--format=', 'HEAD'], 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
|
+
},
|
|
474
509
|
// Frozen-path write-deny (see frozen-path-guard.ts): the concrete paths this
|
|
475
510
|
// task's spec forbids modifying, so the gate sequence can UNDO any edit the
|
|
476
511
|
// enforce EDIT pass makes to them before those edits are committed. Reads the
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** One file accused of causing another task's verify FAIL. */
|
|
2
|
+
export interface RepairCandidate {
|
|
3
|
+
/** Repo-relative path of the accused file. */
|
|
4
|
+
file: string;
|
|
5
|
+
/** The task whose commit introduced `file` (provenance). */
|
|
6
|
+
owner: string;
|
|
7
|
+
/** One-line summary of the defect, lifted from the FAIL text. */
|
|
8
|
+
defect: string;
|
|
9
|
+
/** The task whose verify FAILed because of it. */
|
|
10
|
+
blamedTask: string;
|
|
11
|
+
/** The failing command from the debt — becomes the repair task's VERIFY. */
|
|
12
|
+
verifyCommand?: string;
|
|
13
|
+
}
|
|
14
|
+
/** True when the FAIL text blames the ENVIRONMENT rather than a file. */
|
|
15
|
+
export declare function isEnvironmentAttributed(text: string): boolean;
|
|
16
|
+
interface Accusation {
|
|
17
|
+
file: string;
|
|
18
|
+
/** Character distance between the blame cue and the path token. */
|
|
19
|
+
distance: number;
|
|
20
|
+
/** The clause the accusation was made in — the defect summary source. */
|
|
21
|
+
clause: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The single file a FAIL text accuses, or null. Scans every blame cue, pairs it
|
|
25
|
+
* with the NEAREST path token within {@link BLAME_WINDOW} characters (a cue and its
|
|
26
|
+
* subject sit adjacent in practice: "pre-existing teardown bug in
|
|
27
|
+
* `test/teardown.ts`"), and keeps the closest pair overall. One FAIL has one root
|
|
28
|
+
* cause, so this deliberately returns at most one accusation rather than every
|
|
29
|
+
* path the reason happens to name.
|
|
30
|
+
*/
|
|
31
|
+
export declare function findAccusedFile(text: string): Accusation | null;
|
|
32
|
+
/**
|
|
33
|
+
* Collapse whitespace and clamp — a stored defect summary is one short line that
|
|
34
|
+
* has to read well inside a plan title. The gate's own verdict boilerplate ("work
|
|
35
|
+
* did not verify: ") and a leading repeat of the accused file are stripped: the
|
|
36
|
+
* title already names both the step kind and the file, so repeating them there
|
|
37
|
+
* spends the clamp budget on nothing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function summariseDefect(clause: string, file?: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* A runnable command quoted in the FAIL text — the repair task's VERIFY, per the
|
|
42
|
+
* requirement that it re-run the exact command the debt failed on. Only the first
|
|
43
|
+
* backticked token that STARTS like a shell command (optionally env-prefixed)
|
|
44
|
+
* qualifies, so prose in backticks is never mistaken for a command.
|
|
45
|
+
*/
|
|
46
|
+
export declare function extractFailingCommand(text: string): string | undefined;
|
|
47
|
+
export interface RootCauseInput {
|
|
48
|
+
/** The verify gate's FAIL reason. */
|
|
49
|
+
failReason: string;
|
|
50
|
+
/** The resolution research's rationale, when one ran ('' otherwise). */
|
|
51
|
+
rationale?: string;
|
|
52
|
+
/** The task whose verify FAILed. */
|
|
53
|
+
currentTaskId: string;
|
|
54
|
+
/**
|
|
55
|
+
* Paths the CURRENT task's own work touches. `null` means unknown (git
|
|
56
|
+
* unavailable) — the channel stands down rather than guessing, so an
|
|
57
|
+
* unreadable tree can only cost a repair task, never spawn a wrong one.
|
|
58
|
+
*/
|
|
59
|
+
touched: string[] | null;
|
|
60
|
+
/** file → introducing task (task-provenance.ts). May throw; a throw reads
|
|
61
|
+
* as unknown provenance. */
|
|
62
|
+
introducedBy: (rel: string) => string | null | Promise<string | null>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The repair candidate a verify FAIL justifies, or null. All three conditions from
|
|
66
|
+
* the module header must hold; anything unknown or environment-shaped returns null.
|
|
67
|
+
*/
|
|
68
|
+
export declare function findRepairCandidate(input: RootCauseInput): Promise<RepairCandidate | null>;
|
|
69
|
+
export declare function repairQueueFile(cwd: string): string;
|
|
70
|
+
/** Parse the stored queue. Malformed lines are skipped, never thrown on. */
|
|
71
|
+
export declare function parseRepairQueue(raw: string): RepairCandidate[];
|
|
72
|
+
/** Append one candidate. Best-effort — the queue never blocks a gate. */
|
|
73
|
+
export declare function recordRepairCandidate(cwd: string, c: RepairCandidate): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Read the queue and CLEAR it. Draining is what makes the "cap 1 repair task per
|
|
76
|
+
* file per run" bound hold without a second ledger: whatever is drained either
|
|
77
|
+
* becomes a plan entry (which is then itself the dedup key — see
|
|
78
|
+
* {@link planHasRepairFor}) or was already covered by one.
|
|
79
|
+
*/
|
|
80
|
+
export declare function drainRepairQueue(cwd: string): Promise<RepairCandidate[]>;
|
|
81
|
+
/** One merged repair per file, carrying every task the defect FAILed. */
|
|
82
|
+
export interface MergedRepair {
|
|
83
|
+
file: string;
|
|
84
|
+
owner: string;
|
|
85
|
+
defect: string;
|
|
86
|
+
blamed: string[];
|
|
87
|
+
verifyCommand?: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Collapse candidates by file — MANDATORY dedup: run 14's two teardown.ts debts
|
|
91
|
+
* (TASK_0013, TASK_0019) must yield exactly ONE repair task naming both. First
|
|
92
|
+
* record wins for defect/command (they describe the same fault); blamed tasks
|
|
93
|
+
* accumulate in first-seen order.
|
|
94
|
+
*/
|
|
95
|
+
export declare function mergeRepairCandidates(candidates: RepairCandidate[]): MergedRepair[];
|
|
96
|
+
/** Machine-recognisable prefix, so a repair entry can be found in a plan again. */
|
|
97
|
+
export declare const REPAIR_TITLE_PREFIX = "repair ";
|
|
98
|
+
/**
|
|
99
|
+
* The plan title for a repair task, in the fixed shape
|
|
100
|
+
* `repair <file>: <defect> (root cause of TASK_A, TASK_B debts)`. The file sits
|
|
101
|
+
* immediately after the prefix so {@link parseRepairTitleFile} can recover it —
|
|
102
|
+
* that recovery is both the dedup key and how the loop knows to attach the
|
|
103
|
+
* repair scope fence.
|
|
104
|
+
*/
|
|
105
|
+
export declare function buildRepairTitle(r: MergedRepair): string;
|
|
106
|
+
/** The file a repair title names, or null when the title is not a repair entry. */
|
|
107
|
+
export declare function parseRepairTitleFile(title: string): string | null;
|
|
108
|
+
/**
|
|
109
|
+
* Is a repair for `file` ALREADY in the plan? This is the cap-1-per-file-per-run
|
|
110
|
+
* bound: it counts checked-off entries too, so a repair task that ran and FAILed
|
|
111
|
+
* is never re-spawned — it lands in the accept-debt ledger like any other task.
|
|
112
|
+
*/
|
|
113
|
+
export declare function planHasRepairFor(titles: string[], file: string): boolean;
|
|
114
|
+
/**
|
|
115
|
+
* The extra scope fence a repair entry carries into refine. Without it, refine
|
|
116
|
+
* re-expands "repair test/teardown.ts: parameterized table names in TRUNCATE"
|
|
117
|
+
* into "overhaul the test infrastructure" — the /task-auto drift lesson. The
|
|
118
|
+
* fence pins the single editable file and pins the VERIFY to the exact command
|
|
119
|
+
* the debt failed on.
|
|
120
|
+
*/
|
|
121
|
+
export declare function buildRepairScopeFence(file: string, verifyCommand?: string): string;
|
|
122
|
+
export {};
|