@mjasnikovs/pi-task 0.42.3 → 0.42.5
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 +3 -2
- package/dist/task/accept-debt.js +50 -14
- package/dist/task/auto-orchestrator.js +8 -6
- package/dist/task/command-run.d.ts +12 -1
- package/dist/task/command-run.js +13 -0
- package/dist/task/deferred-breakage.d.ts +19 -0
- package/dist/task/deferred-breakage.js +98 -22
- package/dist/task/gate-deps.d.ts +11 -0
- package/dist/task/gate-deps.js +49 -14
- package/dist/task/gate-resolution.js +1 -0
- package/dist/task/health-baseline.d.ts +21 -4
- package/dist/task/health-baseline.js +41 -9
- package/dist/task/health-repair.d.ts +19 -4
- package/dist/task/health-repair.js +18 -6
- package/dist/task/phases.js +75 -75
- package/dist/task/repo-health-check.d.ts +21 -9
- package/dist/task/repo-health-check.js +49 -12
- package/dist/task/run-context.d.ts +14 -0
- package/dist/task/run-context.js +29 -2
- package/dist/task/verify-work.d.ts +7 -1
- package/dist/task/verify-work.js +55 -13
- package/dist/task/yolo.d.ts +8 -4
- package/dist/task/yolo.js +17 -9
- package/package.json +1 -1
|
@@ -155,11 +155,12 @@ export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Prom
|
|
|
155
155
|
/** The debts nothing has closed yet — the only ones a re-check may read. */
|
|
156
156
|
export declare function readOpenAcceptDebts(cwd: string): Promise<AcceptDebt[]>;
|
|
157
157
|
/**
|
|
158
|
-
* Close every open
|
|
158
|
+
* Close every open health-class debt that names `command`, stamping the task
|
|
159
159
|
* whose verified work made that check pass again. Returns the debts closed.
|
|
160
160
|
* A reason that quotes the command is the whole match: the health-check reason
|
|
161
161
|
* (`repo health: \`bun run lint\` exited 1`) and its inherited form both do,
|
|
162
|
-
* and nothing else in the ledger quotes a health command.
|
|
162
|
+
* and nothing else in the ledger quotes a health command. A suite debt closes
|
|
163
|
+
* here too: the repair verified clean, and that check runs the suite. Best-effort.
|
|
163
164
|
*/
|
|
164
165
|
export declare function closeHealthDebts(cwd: string, command: string, resolvedBy: string): Promise<AcceptDebt[]>;
|
|
165
166
|
/**
|
package/dist/task/accept-debt.js
CHANGED
|
@@ -35,7 +35,8 @@ import { existsSync } from 'node:fs';
|
|
|
35
35
|
import * as path from 'node:path';
|
|
36
36
|
import * as fsp from 'node:fs/promises';
|
|
37
37
|
import { runVerifyCommandLine, spawnCommand } from './command-run.js';
|
|
38
|
-
import { failClassOfReason, isStaticClass } from './verify-work.js';
|
|
38
|
+
import { failClassOfReason, isHealthClass, isStaticClass } from './verify-work.js';
|
|
39
|
+
import { discoverTestCommands } from './repo-health-check.js';
|
|
39
40
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
40
41
|
import { makeLedger } from './ledger.js';
|
|
41
42
|
import { parseVerifyBlockStrict } from './spec-validation.js';
|
|
@@ -251,18 +252,19 @@ export async function readOpenAcceptDebts(cwd) {
|
|
|
251
252
|
return (await readAcceptDebts(cwd)).filter(d => d.resolvedBy === undefined);
|
|
252
253
|
}
|
|
253
254
|
/**
|
|
254
|
-
* Close every open
|
|
255
|
+
* Close every open health-class debt that names `command`, stamping the task
|
|
255
256
|
* whose verified work made that check pass again. Returns the debts closed.
|
|
256
257
|
* A reason that quotes the command is the whole match: the health-check reason
|
|
257
258
|
* (`repo health: \`bun run lint\` exited 1`) and its inherited form both do,
|
|
258
|
-
* and nothing else in the ledger quotes a health command.
|
|
259
|
+
* and nothing else in the ledger quotes a health command. A suite debt closes
|
|
260
|
+
* here too: the repair verified clean, and that check runs the suite. Best-effort.
|
|
259
261
|
*/
|
|
260
262
|
export async function closeHealthDebts(cwd, command, resolvedBy) {
|
|
261
263
|
try {
|
|
262
264
|
const all = await readAcceptDebts(cwd);
|
|
263
265
|
const quoted = `\`${command}\``;
|
|
264
266
|
const closing = all.filter(d => d.resolvedBy === undefined
|
|
265
|
-
&&
|
|
267
|
+
&& isHealthClass(failClassOfReason(d.reason))
|
|
266
268
|
&& d.reason.includes(quoted));
|
|
267
269
|
if (closing.length === 0)
|
|
268
270
|
return [];
|
|
@@ -340,6 +342,9 @@ function isStorableCommand(cmd) {
|
|
|
340
342
|
* command with fabricated provenance, which is the one thing this class may not do.
|
|
341
343
|
*/
|
|
342
344
|
export async function classifyVerifyCommand(cwd, taskId, reason) {
|
|
345
|
+
const suite = suiteCommandFromReason(cwd, reason);
|
|
346
|
+
if (suite !== null)
|
|
347
|
+
return suite;
|
|
343
348
|
if (taskId.trim().length === 0)
|
|
344
349
|
return null;
|
|
345
350
|
try {
|
|
@@ -354,6 +359,23 @@ export async function classifyVerifyCommand(cwd, taskId, reason) {
|
|
|
354
359
|
return null;
|
|
355
360
|
}
|
|
356
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* The command a `test suite:` reason names, when the repo's own check is what
|
|
364
|
+
* discovers it. The suite is not a task's VERIFY line — it belongs to the repo —
|
|
365
|
+
* so without this the class has NO closure path at all: a passing lint may not
|
|
366
|
+
* close it (that is why the class exists), the run-end static check does not run
|
|
367
|
+
* the suite, and a repair splices only for a task's own regression. An inherited
|
|
368
|
+
* red suite was then reported open for the rest of the run after it went green.
|
|
369
|
+
*
|
|
370
|
+
* Provenance is the manifest: the span must equal a command `discoverTestCommands`
|
|
371
|
+
* produced, exactly as the VERIFY-block match must equal a parsed line.
|
|
372
|
+
*/
|
|
373
|
+
function suiteCommandFromReason(cwd, reason) {
|
|
374
|
+
if (failClassOfReason(reason) !== 'test-suite')
|
|
375
|
+
return null;
|
|
376
|
+
const hit = verifyCommandFromReason(reason, discoverTestCommands(cwd).cmds.map(([bin, args]) => `${bin} ${args.join(' ')}`));
|
|
377
|
+
return hit !== null && isStorableCommand(hit) ? hit : null;
|
|
378
|
+
}
|
|
357
379
|
export function verifyCommandFromReason(reason, verifyCommands) {
|
|
358
380
|
const byText = new Map();
|
|
359
381
|
for (const c of verifyCommands) {
|
|
@@ -394,6 +416,19 @@ export async function recheckAcceptDebts(debts, opts) {
|
|
|
394
416
|
const resolved = [];
|
|
395
417
|
const trail = [];
|
|
396
418
|
let rerunsLeft = MAX_VERIFY_RERUNS;
|
|
419
|
+
const ran = new Map();
|
|
420
|
+
const settle = (d, cmd, r) => {
|
|
421
|
+
if (r.outcome === 'pass') {
|
|
422
|
+
resolved.push(d);
|
|
423
|
+
trail.push(`${d.taskId}: RESOLVED — re-ran \`${cmd}\` and it exited 0`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
trail.push(`${d.taskId}: still open — re-ran \`${cmd}\`: `
|
|
427
|
+
+ (r.outcome === 'fail' ?
|
|
428
|
+
`it FAILED${r.detail ? ` (${r.detail})` : ''}`
|
|
429
|
+
: `INCONCLUSIVE${r.detail ? ` (${r.detail})` : ''}, nothing was observed`));
|
|
430
|
+
open.push(d);
|
|
431
|
+
};
|
|
397
432
|
for (const d of debts) {
|
|
398
433
|
if (d.origin === 'cross-task-deletion') {
|
|
399
434
|
const p = extractDeletedDebtPath(d.reason);
|
|
@@ -422,6 +457,15 @@ export async function recheckAcceptDebts(debts, opts) {
|
|
|
422
457
|
open.push(d);
|
|
423
458
|
continue;
|
|
424
459
|
}
|
|
460
|
+
// One command, one run. A run that inherits a red suite records the same
|
|
461
|
+
// `bun run test` against every task in it, and re-running it once per debt
|
|
462
|
+
// would spend the whole budget proving the same thing and leave the rest
|
|
463
|
+
// open. The budget counts commands, which is what it was for.
|
|
464
|
+
const already = ran.get(cmd);
|
|
465
|
+
if (already !== undefined) {
|
|
466
|
+
settle(d, cmd, already);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
425
469
|
if (rerunsLeft <= 0) {
|
|
426
470
|
trail.push(`${d.taskId}: NOT re-checked — the per-run re-run budget `
|
|
427
471
|
+ `(${MAX_VERIFY_RERUNS}) is spent; the debt stays open`);
|
|
@@ -437,16 +481,8 @@ export async function recheckAcceptDebts(debts, opts) {
|
|
|
437
481
|
// A harness fault observes nothing, so it proves nothing.
|
|
438
482
|
r = { outcome: 'gap', detail: 're-run harness fault' };
|
|
439
483
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
trail.push(`${d.taskId}: RESOLVED — re-ran \`${cmd}\` and it exited 0`);
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
trail.push(`${d.taskId}: still open — re-ran \`${cmd}\`: `
|
|
446
|
-
+ (r.outcome === 'fail' ?
|
|
447
|
-
`it FAILED${r.detail ? ` (${r.detail})` : ''}`
|
|
448
|
-
: `INCONCLUSIVE${r.detail ? ` (${r.detail})` : ''}, nothing was observed`));
|
|
449
|
-
open.push(d);
|
|
484
|
+
ran.set(cmd, r);
|
|
485
|
+
settle(d, cmd, r);
|
|
450
486
|
}
|
|
451
487
|
return { open, resolved, trail };
|
|
452
488
|
}
|
|
@@ -20,7 +20,7 @@ import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
|
|
|
20
20
|
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, UNNAMED_COVERAGE_GAP, parseTaskList, planKeyAt, checkOffTask, stampTaskInProgress, beginTaskAttempt, recordTaskEnd, insertTaskAfter, insertTaskBefore, findResumableAutoDetailed } from './auto-io.js';
|
|
21
21
|
import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
|
|
22
22
|
import { ENTRY_ATTEMPT_BUDGET } from './gate-resolution.js';
|
|
23
|
-
import { recordDebt } from './accept-debt.js';
|
|
23
|
+
import { readOpenAcceptDebts, recordDebt } from './accept-debt.js';
|
|
24
24
|
import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
|
|
25
25
|
import { writeTaskFile, readTaskFile, readSection, updateTaskFrontMatter, taskFilePath } from './task-io.js';
|
|
26
26
|
// Re-exported as well as used: the @-mention helpers moved to their own module so
|
|
@@ -38,7 +38,7 @@ import { getParentContextWindow } from './context-usage.js';
|
|
|
38
38
|
import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
|
|
39
39
|
import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
|
|
40
40
|
import { runGatesForTask } from './task-gates.js';
|
|
41
|
-
import { buildHealthRepairFence, buildHealthRepairTitle, healthRedSubject, parseHealthRepairTitle, planCoversHealthRed } from './health-repair.js';
|
|
41
|
+
import { buildHealthRepairFence, buildHealthRepairTitle, healthRedSubject, suiteRegressionOwed, parseHealthRepairTitle, planCoversHealthRed } from './health-repair.js';
|
|
42
42
|
import { HEALTH_BASELINE_SECTION, parseHealthBaseline } from './health-baseline.js';
|
|
43
43
|
import { runFinalGateStage } from './run-final-gate.js';
|
|
44
44
|
import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
|
|
@@ -392,7 +392,8 @@ async function schedulePendingRepairs(cwd, id, afterIndex, ctx, deps) {
|
|
|
392
392
|
*/
|
|
393
393
|
async function spliceHealthRepair(cwd, id, next, entries, health, ctx, deps) {
|
|
394
394
|
try {
|
|
395
|
-
const
|
|
395
|
+
const debts = await readOpenAcceptDebts(cwd);
|
|
396
|
+
const red = healthRedSubject(health, cwd, (await deps.repoFiles?.(cwd)) ?? null, c => c.kind !== 'test' || suiteRegressionOwed(c.cmd, debts));
|
|
396
397
|
if (!red)
|
|
397
398
|
return false;
|
|
398
399
|
if (planCoversHealthRed(entries.map(e => e.title), red))
|
|
@@ -609,8 +610,9 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
|
|
|
609
610
|
}
|
|
610
611
|
// YOLO: take the recommended option (index 0 / the green card) without ever
|
|
611
612
|
// building the prompt. Clarify has no anti-synthesis channel — it runs before
|
|
612
|
-
// any research — so the
|
|
613
|
-
// recommendation to take
|
|
613
|
+
// any research — so the step-asides here are a question with no
|
|
614
|
+
// recommendation to take, and one whose every option defers a breakage the
|
|
615
|
+
// triage just refused; each is skipped rather than guessed.
|
|
614
616
|
const outcome = await settleQuestion({
|
|
615
617
|
ui,
|
|
616
618
|
transcript,
|
|
@@ -1366,7 +1368,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1366
1368
|
notifyRun(active, `${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
|
|
1367
1369
|
}
|
|
1368
1370
|
// REPO-HEALTH BASELINE, taken here because the checkpoint above just made
|
|
1369
|
-
// the tree clean: what the project's own
|
|
1371
|
+
// the tree clean: what the project's own checks say now is what this task
|
|
1370
1372
|
// INHERITED, and the verify gate attributes a red check against it instead
|
|
1371
1373
|
// of failing the task for a sibling's defect (health-baseline.ts). The
|
|
1372
1374
|
// inner task file does not exist yet, so the result is handed to the
|
|
@@ -109,8 +109,14 @@ export declare const ENV_GAP_OUTPUT_RE: RegExp;
|
|
|
109
109
|
* `gapPatterns`, which is why that parameter exists rather than a boolean.
|
|
110
110
|
*/
|
|
111
111
|
export declare const INFRA_GAP_OUTPUT_RE: RegExp;
|
|
112
|
+
/**
|
|
113
|
+
* A test runner that found no tests to run: bun, jest, vitest, mocha and pytest
|
|
114
|
+
* each exit non-zero on it. A suite that ran nothing observed nothing, which is the
|
|
115
|
+
* contract's definition of a gap. Test commands only (see `emptySuite`).
|
|
116
|
+
*/
|
|
117
|
+
export declare const EMPTY_SUITE_OUTPUT_RE: RegExp;
|
|
112
118
|
/** Which way a command failed to tell us anything. */
|
|
113
|
-
export type CommandGapId = 'spawn-failed' | 'killed' | 'command-not-found' | 'missing-runtime' | 'infrastructure';
|
|
119
|
+
export type CommandGapId = 'spawn-failed' | 'killed' | 'command-not-found' | 'missing-runtime' | 'infrastructure' | 'empty-suite';
|
|
114
120
|
export type CommandVerdict =
|
|
115
121
|
/** Nothing was observed. Never fails a gate, never closes a debt. */
|
|
116
122
|
{
|
|
@@ -145,6 +151,11 @@ export interface ClassifyOptions {
|
|
|
145
151
|
* and tell the gate the repo is healthy.
|
|
146
152
|
*/
|
|
147
153
|
runtimeGap?: boolean;
|
|
154
|
+
/**
|
|
155
|
+
* May this command's output claim it found NO TESTS? False by default: the
|
|
156
|
+
* wording is only a gap when a test runner printed it.
|
|
157
|
+
*/
|
|
158
|
+
emptySuite?: boolean;
|
|
148
159
|
}
|
|
149
160
|
/**
|
|
150
161
|
* Decide what one finished command proved. Pure — no spawning, no filesystem, no
|
package/dist/task/command-run.js
CHANGED
|
@@ -233,6 +233,12 @@ export const ENV_GAP_OUTPUT_RE = /Executable doesn't exist|playwright install|br
|
|
|
233
233
|
* `gapPatterns`, which is why that parameter exists rather than a boolean.
|
|
234
234
|
*/
|
|
235
235
|
export const INFRA_GAP_OUTPUT_RE = /ECONNREFUSED|connection refused|ENOTFOUND|EAI_AGAIN|is the server running|could not connect|cannot connect to the docker daemon|connect: connection|no such host/i;
|
|
236
|
+
/**
|
|
237
|
+
* A test runner that found no tests to run: bun, jest, vitest, mocha and pytest
|
|
238
|
+
* each exit non-zero on it. A suite that ran nothing observed nothing, which is the
|
|
239
|
+
* contract's definition of a gap. Test commands only (see `emptySuite`).
|
|
240
|
+
*/
|
|
241
|
+
export const EMPTY_SUITE_OUTPUT_RE = /\b0 test files matching\b|\bNo tests found\b|\bNo test files found\b|\bno tests ran\b|\bcollected 0 items\b/i;
|
|
236
242
|
/**
|
|
237
243
|
* The gap ladder, in order. FIRST MATCH WINS.
|
|
238
244
|
*
|
|
@@ -268,6 +274,11 @@ const GAP_RULES = [
|
|
|
268
274
|
id: 'infrastructure',
|
|
269
275
|
detail: () => 'external infrastructure unreachable',
|
|
270
276
|
applies: (_run, output, gapPatterns) => gapPatterns.some(re => re.test(output))
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: 'empty-suite',
|
|
280
|
+
detail: () => 'no tests found',
|
|
281
|
+
applies: (_run, output) => EMPTY_SUITE_OUTPUT_RE.test(output)
|
|
271
282
|
}
|
|
272
283
|
];
|
|
273
284
|
/** Last ~`limit` chars of the command's combined output, one line, for the reason. */
|
|
@@ -296,6 +307,8 @@ export function classifyCommandRun(run, gapPatterns = [], opts = {}) {
|
|
|
296
307
|
for (const rule of GAP_RULES) {
|
|
297
308
|
if (rule.id === 'missing-runtime' && !runtimeGap)
|
|
298
309
|
continue;
|
|
310
|
+
if (rule.id === 'empty-suite' && opts.emptySuite !== true)
|
|
311
|
+
continue;
|
|
299
312
|
if (rule.applies(run, output, gapPatterns)) {
|
|
300
313
|
return { outcome: 'gap', gap: rule.id, detail: rule.detail(run) };
|
|
301
314
|
}
|
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does an auto-answer hand a breakage to someone who does not exist?
|
|
3
|
+
*
|
|
4
|
+
* MEASURED (mx5-n TASK_0004, 2026-09-17): "flag the test/migrate.test.ts breakage
|
|
5
|
+
* as a known issue for the test owner". Nothing in a /task-auto run owns a test:
|
|
6
|
+
* the answer was stamped `(auto)`, verify passed the task with the suite red, and
|
|
7
|
+
* four tasks later an unsatisfiable spec looped until the runaway guard fired.
|
|
8
|
+
*
|
|
9
|
+
* This is the deterministic backstop behind the prompt's GREEN-SUITE CHECK: a
|
|
10
|
+
* model that ignores the rule still cannot promote a deferral into a decision.
|
|
11
|
+
* The phrases are the ones a model reaches for when it wants to defer, not the
|
|
12
|
+
* word "test" — "add a test later" is a plan, not a deferral.
|
|
13
|
+
*
|
|
14
|
+
* SCOPE IS GRAMMATICAL, never a character count. A negation cancels a phrase only
|
|
15
|
+
* inside the phrase's own clause: "rather than flag it as a known issue" rejects
|
|
16
|
+
* the phrase, and "without touching the test file, accepting that it fails" does
|
|
17
|
+
* not. "Known issue", "follow-up" and "a later step" also name legitimate plans —
|
|
18
|
+
* an upstream bug, a scope cut — so they count only in a clause about a check.
|
|
19
|
+
*/
|
|
1
20
|
export declare function defersBreakage(answer: string): boolean;
|
|
2
21
|
/** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
|
|
3
22
|
export declare function deferredBreakageReaskHint(answer: string): string;
|
|
@@ -10,33 +10,109 @@
|
|
|
10
10
|
* model that ignores the rule still cannot promote a deferral into a decision.
|
|
11
11
|
* The phrases are the ones a model reaches for when it wants to defer, not the
|
|
12
12
|
* word "test" — "add a test later" is a plan, not a deferral.
|
|
13
|
+
*
|
|
14
|
+
* SCOPE IS GRAMMATICAL, never a character count. A negation cancels a phrase only
|
|
15
|
+
* inside the phrase's own clause: "rather than flag it as a known issue" rejects
|
|
16
|
+
* the phrase, and "without touching the test file, accepting that it fails" does
|
|
17
|
+
* not. "Known issue", "follow-up" and "a later step" also name legitimate plans —
|
|
18
|
+
* an upstream bug, a scope cut — so they count only in a clause about a check.
|
|
19
|
+
*/
|
|
20
|
+
/** A test, or a static check that the same clause calls broken. */
|
|
21
|
+
const TEST_NOUN = /\b(?:tests?|suites?|assertions?)\b/i;
|
|
22
|
+
const BUILD_NOUN = /\b(?:lint|linter|typecheck|build|ci)\b/i;
|
|
23
|
+
const FAILURE = /\b(?:fail\w*|red|broken|breaks?|breakage|errors?)\b/i;
|
|
24
|
+
/** Before a phrase in its clause: the phrase is rejected. */
|
|
25
|
+
const NOT_A_DECISION = /\b(?:not|never|no|don't|do not|doesn't|does not|rather than|instead of|isn't|is not|without|avoid|avoiding)\b/i;
|
|
26
|
+
/**
|
|
27
|
+
* A modal cancels a phrase only where the sentence poses an option for it to
|
|
28
|
+
* weigh: "IF NOT EXISTS would still leave the test failing" describes what a
|
|
29
|
+
* rejected option does. A bare hedge does not — "I would flag it as a known
|
|
30
|
+
* issue" is the decision, and treating every modal as hypothetical let the guard
|
|
31
|
+
* be rephrased away.
|
|
13
32
|
*/
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/\
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
const MODAL = /\b(?:would|could|might)\b/i;
|
|
34
|
+
const HYPOTHETICAL = /\b(?:if|unless|either|whether|option|alternative|otherwise)\b/i;
|
|
35
|
+
/** Where one clause ends and the next begins. A semicolon joins clauses of ONE
|
|
36
|
+
* thought, so the breakage a clause defers may sit in the other half. */
|
|
37
|
+
const CLAUSE_BOUNDARY = /[,:;()]|\s[—–-]\s|\b(?:and|but|so|then|while|whereas|although|though|because|since|however)\b/gi;
|
|
38
|
+
const SENTENCE_BOUNDARY = /[.!?](?=\s|$)|\n/;
|
|
39
|
+
const PHRASES = [
|
|
40
|
+
{ re: /\b(?:test|suite)[- ]owners?\b/i, needs: 'alone' },
|
|
41
|
+
{
|
|
42
|
+
re: /\bflag(?:s|ged|ging)?\b.*?\b(?:as\s+(?:an?\s+|the\s+)?(?:known|owned)\b|for\s+(?:whoever|later|a\s+later)\b)/i,
|
|
43
|
+
needs: 'alone'
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
re: /\bleav(?:e|es|ing)\b.*?\b(?:tests?|suites?|assertions?|lint|build|checks?|ci)\b.*?\b(?:failing|red|broken|as[- ]is)\b/i,
|
|
47
|
+
needs: 'alone'
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
re: /\bskip(?:s|ping)?\s+(?:updating|fixing|adjusting|changing|touching)\b.*?\b(?:tests?|suites?|assertions?)\b/i,
|
|
51
|
+
needs: 'alone'
|
|
52
|
+
},
|
|
53
|
+
{ re: /\baccept(?:s|ed|ing)?\b.*?\b(?:fail\w*|red|broken)\b/i, needs: 'check' },
|
|
54
|
+
// Handing the work to an unnamed someone is the deferral itself, whatever the
|
|
55
|
+
// clause is about; bare `whoever` below still needs a check to be one.
|
|
56
|
+
{ re: /\bwhoever\s+(?:owns|revisits|maintains|touches)\b/i, needs: 'alone' },
|
|
57
|
+
{ re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\b/i, needs: 'alone' },
|
|
58
|
+
{ re: /\bwhoever\b/i, needs: 'check' },
|
|
59
|
+
{ re: /\bowned\s+(?:by|follow[- ]?up)\b/i, needs: 'check' },
|
|
60
|
+
{ re: /\bleft\s+for\b/i, needs: 'check' },
|
|
61
|
+
{ re: /\bknown[- ]issues?\b/i, needs: 'check' },
|
|
62
|
+
{ re: /\bfollow[- ]?ups?\b/i, needs: 'check' },
|
|
63
|
+
{
|
|
64
|
+
re: /\b(?:a|the|another|some)\s+(?:later|future|subsequent|separate)\s+(?:step|task|change|pr)\b/i,
|
|
65
|
+
needs: 'check'
|
|
66
|
+
},
|
|
67
|
+
{ re: /\bdefer(?:s|red|ring)?\b/i, needs: 'check' },
|
|
68
|
+
{ re: /\bout\s+of\s+scope\b/i, needs: 'check' },
|
|
69
|
+
{
|
|
70
|
+
re: /\b(?:that|this|which|it|they|those)\s+(?:is|are|remains?)\s+out\s+of\s+scope\b/i,
|
|
71
|
+
needs: 'breakage'
|
|
72
|
+
}
|
|
26
73
|
];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
74
|
+
function aboutACheck(text) {
|
|
75
|
+
return TEST_NOUN.test(text) || (BUILD_NOUN.test(text) && FAILURE.test(text));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Parenthetical asides go, and a code span keeps its words but loses the
|
|
79
|
+
* punctuation that would split a clause in two: `toEqual([{filename: X}])` is
|
|
80
|
+
* one token of the sentence around it, not three clauses.
|
|
81
|
+
*/
|
|
82
|
+
function prose(answer) {
|
|
83
|
+
let text = answer.replace(/`([^`]*)`/g, (_m, code) => code.replace(/[,;:()[\]{}]/g, ' '));
|
|
84
|
+
let before;
|
|
85
|
+
do {
|
|
86
|
+
before = text;
|
|
87
|
+
text = text.replace(/\([^()]*\)/g, ' ');
|
|
88
|
+
} while (text !== before);
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
function clauses(sentence) {
|
|
92
|
+
return sentence.split(CLAUSE_BOUNDARY).filter(c => c.trim().length > 0);
|
|
93
|
+
}
|
|
30
94
|
export function defersBreakage(answer) {
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
for (const
|
|
34
|
-
const
|
|
35
|
-
|
|
95
|
+
for (const sentence of prose(answer).split(SENTENCE_BOUNDARY)) {
|
|
96
|
+
const sentenceBreaks = aboutACheck(sentence) && FAILURE.test(sentence);
|
|
97
|
+
for (const clause of clauses(sentence)) {
|
|
98
|
+
for (const { re, needs } of PHRASES) {
|
|
99
|
+
const hit = re.exec(clause);
|
|
100
|
+
if (!hit)
|
|
101
|
+
continue;
|
|
102
|
+
const before = clause.slice(0, hit.index);
|
|
103
|
+
if (NOT_A_DECISION.test(before))
|
|
104
|
+
continue;
|
|
105
|
+
if (MODAL.test(before) && HYPOTHETICAL.test(sentence))
|
|
106
|
+
continue;
|
|
107
|
+
if (needs === 'check' && !aboutACheck(clause))
|
|
108
|
+
continue;
|
|
109
|
+
if (needs === 'breakage' && !sentenceBreaks)
|
|
110
|
+
continue;
|
|
36
111
|
return true;
|
|
112
|
+
}
|
|
37
113
|
}
|
|
38
|
-
|
|
39
|
-
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
40
116
|
}
|
|
41
117
|
/** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
|
|
42
118
|
export function deferredBreakageReaskHint(answer) {
|
package/dist/task/gate-deps.d.ts
CHANGED
|
@@ -27,6 +27,17 @@ export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failRea
|
|
|
27
27
|
* FinalFixDeps.ignoredKnown) — a failed attempt's ignored writes survive its
|
|
28
28
|
* discard and can green a later attempt. */
|
|
29
29
|
ignoredKnown?: string[]) => Promise<FinalFixResult>;
|
|
30
|
+
/**
|
|
31
|
+
* A file the suite wrote as the repo's OWN record, not as a report about the run.
|
|
32
|
+
* A test added this task and never run locally generates its snapshot on the
|
|
33
|
+
* gate's run; deleting it commits a snapshot test with no snapshot, and the next
|
|
34
|
+
* suite — or CI — fails on a file the task was supposed to carry.
|
|
35
|
+
*
|
|
36
|
+
* A named set, not a shape test: the alternative is an allowlist of throwaway
|
|
37
|
+
* artefacts, and anything it misses rides into the commit, which is the problem
|
|
38
|
+
* the cleanup exists for. A stray snapshot is the smaller error.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isSuiteRecord(rel: string): boolean;
|
|
30
41
|
/**
|
|
31
42
|
* Collect the task's changed files as pure GIT SHAPE — path + added-line count,
|
|
32
43
|
* no content, no language parsing — for the self-verification probe. Before the
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -82,6 +82,21 @@ export function truncateToolResult(text, limit = TOOL_RESULT_LOG_LIMIT) {
|
|
|
82
82
|
}
|
|
83
83
|
/** Keep the gate machinery's own artifacts out of every git pathspec below. */
|
|
84
84
|
const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
|
|
85
|
+
/**
|
|
86
|
+
* A file the suite wrote as the repo's OWN record, not as a report about the run.
|
|
87
|
+
* A test added this task and never run locally generates its snapshot on the
|
|
88
|
+
* gate's run; deleting it commits a snapshot test with no snapshot, and the next
|
|
89
|
+
* suite — or CI — fails on a file the task was supposed to carry.
|
|
90
|
+
*
|
|
91
|
+
* A named set, not a shape test: the alternative is an allowlist of throwaway
|
|
92
|
+
* artefacts, and anything it misses rides into the commit, which is the problem
|
|
93
|
+
* the cleanup exists for. A stray snapshot is the smaller error.
|
|
94
|
+
*/
|
|
95
|
+
export function isSuiteRecord(rel) {
|
|
96
|
+
return (/(?:^|[\\/])__(?:snapshots|image_snapshots)__[\\/]/.test(rel)
|
|
97
|
+
|| /\.snap$/.test(rel)
|
|
98
|
+
|| /\.approved\.[^.\\/]+$/.test(rel));
|
|
99
|
+
}
|
|
85
100
|
/**
|
|
86
101
|
* Pin the diff header prefixes on any command whose output we PARSE for paths.
|
|
87
102
|
*
|
|
@@ -537,7 +552,10 @@ export async function healthBaselineFor(cwd, taskId, signal) {
|
|
|
537
552
|
return stored;
|
|
538
553
|
const fresh = await lazyHealthBaseline({
|
|
539
554
|
git: makeGit(cwd, signal),
|
|
540
|
-
|
|
555
|
+
// Statics only. The worktree has none of the tree's ignored files (the
|
|
556
|
+
// installed dependencies, a `.env`), so its suite fails for that, and a
|
|
557
|
+
// red recorded here would excuse the real regression it matches.
|
|
558
|
+
runHealthIn: dir => runRepoHealthCheck(dir, { signal })
|
|
541
559
|
});
|
|
542
560
|
if (fresh) {
|
|
543
561
|
await setTaskSection(cwd, taskId, HEALTH_BASELINE_SECTION, formatHealthBaseline(fresh)).catch(() => { });
|
|
@@ -720,7 +738,32 @@ export function buildGateDeps(params) {
|
|
|
720
738
|
await git(cwd2, ['checkout', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
721
739
|
await git(cwd2, ['clean', '-fd', '-e', '.pi-tasks'], signal);
|
|
722
740
|
};
|
|
723
|
-
|
|
741
|
+
const untrackedFiles = async (cwd2) => {
|
|
742
|
+
const r = await git(cwd2, ['ls-files', '--others', '--exclude-standard', '-z', '--', '.', EXCLUDE_TASKS_DIR], signal);
|
|
743
|
+
return r.exitCode === 0 ? new Set(r.stdout.split('\u0000').filter(f => f.length > 0)) : null;
|
|
744
|
+
};
|
|
745
|
+
// The project's own checks, suite included, once per tree for the run. A suite
|
|
746
|
+
// writes coverage, reports and databases into the tree; left there, they ride
|
|
747
|
+
// into the task's commit and read as enforce edits, so what the check created
|
|
748
|
+
// is removed before the tree is hashed again — except what it wrote as the
|
|
749
|
+
// repo's own record (see isSuiteRecord).
|
|
750
|
+
const gateHealth = (cwd2, onCommand) => currentRunContext(cwd2).healthFor(async () => {
|
|
751
|
+
const before = await untrackedFiles(cwd2);
|
|
752
|
+
try {
|
|
753
|
+
return await runRepoHealthCheck(cwd2, { signal, withTests: true, onCommand });
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
const after = before ? await untrackedFiles(cwd2) : null;
|
|
757
|
+
for (const rel of after ?? []) {
|
|
758
|
+
if (!before?.has(rel) && !isSuiteRecord(rel)) {
|
|
759
|
+
await fsp
|
|
760
|
+
.rm(path.join(cwd2, rel), { recursive: true, force: true })
|
|
761
|
+
.catch(() => { });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
// The project's own checks, under a live loader naming the running command.
|
|
724
767
|
// Each run is as long as that command, and a gate step that long with no widget
|
|
725
768
|
// is indistinguishable from a hang. Shared by the enforce pre-commit gate (a
|
|
726
769
|
// baseline before the edit pass, a differential after it) and by the
|
|
@@ -737,12 +780,8 @@ export function buildGateDeps(params) {
|
|
|
737
780
|
startedAt,
|
|
738
781
|
lastLine: running ? `repo health · ${running}` : 'repo health'
|
|
739
782
|
}));
|
|
740
|
-
return
|
|
741
|
-
|
|
742
|
-
withTests: true,
|
|
743
|
-
onCommand: c => {
|
|
744
|
-
running = c;
|
|
745
|
-
}
|
|
783
|
+
return gateHealth(cwd2, c => {
|
|
784
|
+
running = c;
|
|
746
785
|
}).finally(stop);
|
|
747
786
|
};
|
|
748
787
|
// Adapter onto the shared gate-child runner (gate-child.ts). What survives
|
|
@@ -946,12 +985,8 @@ export function buildGateDeps(params) {
|
|
|
946
985
|
// progress hook here would be uncancellable and mute for reasons
|
|
947
986
|
// unrelated to the thing under test. The arm's only difference is
|
|
948
987
|
// the LOADER, above.
|
|
949
|
-
repoHealth: () =>
|
|
950
|
-
|
|
951
|
-
withTests: true,
|
|
952
|
-
onCommand: c => {
|
|
953
|
-
stageLine = `repo health · ${c}`;
|
|
954
|
-
}
|
|
988
|
+
repoHealth: () => gateHealth(cwd2, c => {
|
|
989
|
+
stageLine = `repo health · ${c}`;
|
|
955
990
|
}),
|
|
956
991
|
// What those checks said before the task started, so a red one
|
|
957
992
|
// is attributed rather than absolutely failed. Read only when
|
|
@@ -11,6 +11,7 @@ const REATTEMPTABLE_BUDGET = 3;
|
|
|
11
11
|
export const AUTOFIX_BUDGET = {
|
|
12
12
|
'repo-health': REATTEMPTABLE_BUDGET,
|
|
13
13
|
'static-checks': REATTEMPTABLE_BUDGET,
|
|
14
|
+
'test-suite': REATTEMPTABLE_BUDGET,
|
|
14
15
|
'model-verdict': REATTEMPTABLE_BUDGET,
|
|
15
16
|
unobserved: 0,
|
|
16
17
|
'harness-fault': 0
|
|
@@ -38,7 +38,24 @@ export type HealthDelta = 'clean' | 'regressed' | 'pre-existing';
|
|
|
38
38
|
* default the enforce site used to carry.
|
|
39
39
|
*/
|
|
40
40
|
export declare function classifyHealthDelta(baseline: HealthSignal | null, after: HealthSignal): HealthDelta;
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Test commands the baseline saw PASS that now find no tests to run.
|
|
43
|
+
*
|
|
44
|
+
* A runner that found nothing observed nothing, which is a gap — in isolation. A
|
|
45
|
+
* task that deleted the test directory, renamed it, or broke the config's glob
|
|
46
|
+
* leaves the same gap, and the check reports the repo healthy because a gap never
|
|
47
|
+
* fails. Against a baseline that ran the suite, the suite is gone: this task's
|
|
48
|
+
* regression, and the largest one it can hide behind a green.
|
|
49
|
+
*/
|
|
50
|
+
export declare function vanishedSuites(baseline: HealthSignal | null, after: HealthSignal): HealthCommandResult[];
|
|
51
|
+
/** The failing commands the baseline did not have failing the same way — what a
|
|
52
|
+
* `regressed` verdict is about. Every failing command when there is no baseline. */
|
|
53
|
+
export declare function regressedCommands(baseline: HealthSignal | null, after: HealthSignal): HealthCommandResult[];
|
|
54
|
+
/**
|
|
55
|
+
* The failing commands, as prompt/trail lines naming the exit code. A test
|
|
56
|
+
* runner exits 1 for one failing test or for fifty, so for a suite the line
|
|
57
|
+
* claims only the exit code: which tests fail was not compared.
|
|
58
|
+
*/
|
|
42
59
|
export declare function inheritedHealthFindings(after: HealthSignal): string[];
|
|
43
60
|
export declare const HEALTH_BASELINE_SECTION = "health baseline";
|
|
44
61
|
/**
|
|
@@ -46,9 +63,9 @@ export declare const HEALTH_BASELINE_SECTION = "health baseline";
|
|
|
46
63
|
* grammar: this round-trips through a committed file that a later run parses, and
|
|
47
64
|
* a second grammar is a second thing to drift.
|
|
48
65
|
*
|
|
49
|
-
* The captured
|
|
50
|
-
* file committed with every task, for a field the
|
|
51
|
-
* live run's own trail already carries it.
|
|
66
|
+
* The captured output is dropped, the outcome's and each command's — up to 40
|
|
67
|
+
* lines of a linter's report, in a file committed with every task, for a field the
|
|
68
|
+
* differential never reads. The live run's own trail already carries it.
|
|
52
69
|
*/
|
|
53
70
|
export declare function formatHealthBaseline(b: HealthBaseline): string;
|
|
54
71
|
/** Parse a `## health baseline` section back. Null on anything unreadable — an
|
|
@@ -41,6 +41,8 @@ function failures(signal) {
|
|
|
41
41
|
* default the enforce site used to carry.
|
|
42
42
|
*/
|
|
43
43
|
export function classifyHealthDelta(baseline, after) {
|
|
44
|
+
if (vanishedSuites(baseline, after).length > 0)
|
|
45
|
+
return 'regressed';
|
|
44
46
|
if (after.ok)
|
|
45
47
|
return 'clean';
|
|
46
48
|
if (!baseline)
|
|
@@ -52,13 +54,42 @@ export function classifyHealthDelta(baseline, after) {
|
|
|
52
54
|
const detailed = now.length > 0 && (baseline.ok || before.length > 0);
|
|
53
55
|
if (!detailed)
|
|
54
56
|
return baseline.ok ? 'regressed' : 'pre-existing';
|
|
55
|
-
|
|
56
|
-
const wasFailing = new Set(before.map(key));
|
|
57
|
-
return now.every(c => wasFailing.has(key(c))) ? 'pre-existing' : 'regressed';
|
|
57
|
+
return regressedCommands(baseline, after).length > 0 ? 'regressed' : 'pre-existing';
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
const failureKey = (c) => JSON.stringify([c.cmd, c.exitCode]);
|
|
60
|
+
/**
|
|
61
|
+
* Test commands the baseline saw PASS that now find no tests to run.
|
|
62
|
+
*
|
|
63
|
+
* A runner that found nothing observed nothing, which is a gap — in isolation. A
|
|
64
|
+
* task that deleted the test directory, renamed it, or broke the config's glob
|
|
65
|
+
* leaves the same gap, and the check reports the repo healthy because a gap never
|
|
66
|
+
* fails. Against a baseline that ran the suite, the suite is gone: this task's
|
|
67
|
+
* regression, and the largest one it can hide behind a green.
|
|
68
|
+
*/
|
|
69
|
+
export function vanishedSuites(baseline, after) {
|
|
70
|
+
if (!baseline)
|
|
71
|
+
return [];
|
|
72
|
+
const passed = new Set((baseline.commands ?? []).filter(c => c.outcome === 'pass').map(c => c.cmd));
|
|
73
|
+
return (after.commands ?? []).filter(c => c.outcome === 'skip' && c.gap === 'empty-suite' && passed.has(c.cmd));
|
|
74
|
+
}
|
|
75
|
+
/** The failing commands the baseline did not have failing the same way — what a
|
|
76
|
+
* `regressed` verdict is about. Every failing command when there is no baseline. */
|
|
77
|
+
export function regressedCommands(baseline, after) {
|
|
78
|
+
const wasFailing = new Set(baseline ? failures(baseline).map(failureKey) : []);
|
|
79
|
+
return [
|
|
80
|
+
...failures(after).filter(c => !wasFailing.has(failureKey(c))),
|
|
81
|
+
...vanishedSuites(baseline, after)
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The failing commands, as prompt/trail lines naming the exit code. A test
|
|
86
|
+
* runner exits 1 for one failing test or for fifty, so for a suite the line
|
|
87
|
+
* claims only the exit code: which tests fail was not compared.
|
|
88
|
+
*/
|
|
60
89
|
export function inheritedHealthFindings(after) {
|
|
61
|
-
return failures(after).map(c =>
|
|
90
|
+
return failures(after).map(c => c.kind === 'test' ?
|
|
91
|
+
`\`${c.cmd}\` exits ${c.exitCode}, as it did before this task — the same exit code, not proof the same tests fail`
|
|
92
|
+
: `\`${c.cmd}\` exits ${c.exitCode} (and did before this task)`);
|
|
62
93
|
}
|
|
63
94
|
// ─── The task-file section ───────────────────────────────────────────────────
|
|
64
95
|
export const HEALTH_BASELINE_SECTION = 'health baseline';
|
|
@@ -67,12 +98,13 @@ export const HEALTH_BASELINE_SECTION = 'health baseline';
|
|
|
67
98
|
* grammar: this round-trips through a committed file that a later run parses, and
|
|
68
99
|
* a second grammar is a second thing to drift.
|
|
69
100
|
*
|
|
70
|
-
* The captured
|
|
71
|
-
* file committed with every task, for a field the
|
|
72
|
-
* live run's own trail already carries it.
|
|
101
|
+
* The captured output is dropped, the outcome's and each command's — up to 40
|
|
102
|
+
* lines of a linter's report, in a file committed with every task, for a field the
|
|
103
|
+
* differential never reads. The live run's own trail already carries it.
|
|
73
104
|
*/
|
|
74
105
|
export function formatHealthBaseline(b) {
|
|
75
|
-
const { output: _output, ...
|
|
106
|
+
const { output: _output, commands, ...rest } = b.outcome;
|
|
107
|
+
const outcome = { ...rest, commands: commands.map(({ output: _o, ...c }) => c) };
|
|
76
108
|
return ['```json', JSON.stringify({ ...b, outcome }, null, 2), '```'].join('\n');
|
|
77
109
|
}
|
|
78
110
|
/** Parse a `## health baseline` section back. Null on anything unreadable — an
|