@mjasnikovs/pi-task 0.42.4 → 0.42.6
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.js +45 -10
- package/dist/task/deferred-breakage.d.ts +5 -6
- package/dist/task/deferred-breakage.js +51 -25
- package/dist/task/gate-deps.d.ts +11 -0
- package/dist/task/gate-deps.js +18 -2
- package/dist/task/health-baseline.d.ts +13 -0
- package/dist/task/health-baseline.js +24 -1
- package/dist/task/health-repair.d.ts +6 -2
- package/dist/task/health-repair.js +7 -2
- package/dist/task/repo-health-check.d.ts +6 -1
- package/dist/task/repo-health-check.js +19 -5
- package/dist/task/task-gates.js +9 -4
- package/dist/task/verify-work.d.ts +4 -1
- package/dist/task/verify-work.js +25 -9
- package/package.json +1 -1
package/dist/task/accept-debt.js
CHANGED
|
@@ -36,6 +36,7 @@ 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
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';
|
|
@@ -341,6 +342,9 @@ function isStorableCommand(cmd) {
|
|
|
341
342
|
* command with fabricated provenance, which is the one thing this class may not do.
|
|
342
343
|
*/
|
|
343
344
|
export async function classifyVerifyCommand(cwd, taskId, reason) {
|
|
345
|
+
const suite = suiteCommandFromReason(cwd, reason);
|
|
346
|
+
if (suite !== null)
|
|
347
|
+
return suite;
|
|
344
348
|
if (taskId.trim().length === 0)
|
|
345
349
|
return null;
|
|
346
350
|
try {
|
|
@@ -355,6 +359,23 @@ export async function classifyVerifyCommand(cwd, taskId, reason) {
|
|
|
355
359
|
return null;
|
|
356
360
|
}
|
|
357
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
|
+
}
|
|
358
379
|
export function verifyCommandFromReason(reason, verifyCommands) {
|
|
359
380
|
const byText = new Map();
|
|
360
381
|
for (const c of verifyCommands) {
|
|
@@ -395,6 +416,19 @@ export async function recheckAcceptDebts(debts, opts) {
|
|
|
395
416
|
const resolved = [];
|
|
396
417
|
const trail = [];
|
|
397
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
|
+
};
|
|
398
432
|
for (const d of debts) {
|
|
399
433
|
if (d.origin === 'cross-task-deletion') {
|
|
400
434
|
const p = extractDeletedDebtPath(d.reason);
|
|
@@ -423,6 +457,15 @@ export async function recheckAcceptDebts(debts, opts) {
|
|
|
423
457
|
open.push(d);
|
|
424
458
|
continue;
|
|
425
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
|
+
}
|
|
426
469
|
if (rerunsLeft <= 0) {
|
|
427
470
|
trail.push(`${d.taskId}: NOT re-checked — the per-run re-run budget `
|
|
428
471
|
+ `(${MAX_VERIFY_RERUNS}) is spent; the debt stays open`);
|
|
@@ -438,16 +481,8 @@ export async function recheckAcceptDebts(debts, opts) {
|
|
|
438
481
|
// A harness fault observes nothing, so it proves nothing.
|
|
439
482
|
r = { outcome: 'gap', detail: 're-run harness fault' };
|
|
440
483
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
trail.push(`${d.taskId}: RESOLVED — re-ran \`${cmd}\` and it exited 0`);
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
446
|
-
trail.push(`${d.taskId}: still open — re-ran \`${cmd}\`: `
|
|
447
|
-
+ (r.outcome === 'fail' ?
|
|
448
|
-
`it FAILED${r.detail ? ` (${r.detail})` : ''}`
|
|
449
|
-
: `INCONCLUSIVE${r.detail ? ` (${r.detail})` : ''}, nothing was observed`));
|
|
450
|
-
open.push(d);
|
|
484
|
+
ran.set(cmd, r);
|
|
485
|
+
settle(d, cmd, r);
|
|
451
486
|
}
|
|
452
487
|
return { open, resolved, trail };
|
|
453
488
|
}
|
|
@@ -11,12 +11,11 @@
|
|
|
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
13
|
*
|
|
14
|
-
* SCOPE IS GRAMMATICAL, never a character count. A negation
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* about a check.
|
|
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.
|
|
20
19
|
*/
|
|
21
20
|
export declare function defersBreakage(answer: string): boolean;
|
|
22
21
|
/** The one re-ask a deferring answer gets before it is surfaced instead of promoted. */
|
|
@@ -11,23 +11,31 @@
|
|
|
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
13
|
*
|
|
14
|
-
* SCOPE IS GRAMMATICAL, never a character count. A negation
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* about a check.
|
|
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.
|
|
20
19
|
*/
|
|
21
20
|
/** A test, or a static check that the same clause calls broken. */
|
|
22
21
|
const TEST_NOUN = /\b(?:tests?|suites?|assertions?)\b/i;
|
|
23
22
|
const BUILD_NOUN = /\b(?:lint|linter|typecheck|build|ci)\b/i;
|
|
24
23
|
const FAILURE = /\b(?:fail\w*|red|broken|breaks?|breakage|errors?)\b/i;
|
|
25
|
-
/** Before a phrase in its clause: the phrase is rejected
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 condition's own half of the sentence
|
|
28
|
+
* poses an option for it to weigh: "IF NOT EXISTS would still leave the test
|
|
29
|
+
* failing" describes what a rejected option does. A bare hedge does not — "I
|
|
30
|
+
* would flag it as a known issue" is the decision, and treating every modal as
|
|
31
|
+
* hypothetical let the guard be rephrased away.
|
|
32
|
+
*/
|
|
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/;
|
|
31
39
|
const PHRASES = [
|
|
32
40
|
{ re: /\b(?:test|suite)[- ]owners?\b/i, needs: 'alone' },
|
|
33
41
|
{
|
|
@@ -43,9 +51,18 @@ const PHRASES = [
|
|
|
43
51
|
needs: 'alone'
|
|
44
52
|
},
|
|
45
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
|
+
// Ownership handed to a ROLE is handed to nobody. Handed to a named team it is
|
|
58
|
+
// handed to someone, so it counts only where the clause is about a check.
|
|
59
|
+
{
|
|
60
|
+
re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\s+(?:whoever|someone|somebody|another\b|a\s+later\b|the\s+(?:\w+\s+)?(?:owners?|maintainers?)\b)/i,
|
|
61
|
+
needs: 'alone'
|
|
62
|
+
},
|
|
63
|
+
{ re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\b/i, needs: 'check' },
|
|
46
64
|
{ re: /\bwhoever\b/i, needs: 'check' },
|
|
47
65
|
{ re: /\bowned\s+(?:by|follow[- ]?up)\b/i, needs: 'check' },
|
|
48
|
-
{ re: /\bownership\s+(?:belongs|lies|rests)\s+(?:to|with)\b/i, needs: 'check' },
|
|
49
66
|
{ re: /\bleft\s+for\b/i, needs: 'check' },
|
|
50
67
|
{ re: /\bknown[- ]issues?\b/i, needs: 'check' },
|
|
51
68
|
{ re: /\bfollow[- ]?ups?\b/i, needs: 'check' },
|
|
@@ -83,18 +100,27 @@ function clauses(sentence) {
|
|
|
83
100
|
export function defersBreakage(answer) {
|
|
84
101
|
for (const sentence of prose(answer).split(SENTENCE_BOUNDARY)) {
|
|
85
102
|
const sentenceBreaks = aboutACheck(sentence) && FAILURE.test(sentence);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
// A condition governs its own half of a semicolon. It joins two independent
|
|
104
|
+
// clauses, so "the test fails only if X; I would flag it as known" states a
|
|
105
|
+
// condition and then decides — the decision is not one of X's options.
|
|
106
|
+
for (const half of sentence.split(';')) {
|
|
107
|
+
const weighsOptions = HYPOTHETICAL.test(half);
|
|
108
|
+
for (const clause of clauses(half)) {
|
|
109
|
+
for (const { re, needs } of PHRASES) {
|
|
110
|
+
const hit = re.exec(clause);
|
|
111
|
+
if (!hit)
|
|
112
|
+
continue;
|
|
113
|
+
const before = clause.slice(0, hit.index);
|
|
114
|
+
if (NOT_A_DECISION.test(before))
|
|
115
|
+
continue;
|
|
116
|
+
if (MODAL.test(before) && weighsOptions)
|
|
117
|
+
continue;
|
|
118
|
+
if (needs === 'check' && !aboutACheck(clause))
|
|
119
|
+
continue;
|
|
120
|
+
if (needs === 'breakage' && !sentenceBreaks)
|
|
121
|
+
continue;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
98
124
|
}
|
|
99
125
|
}
|
|
100
126
|
}
|
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
|
*
|
|
@@ -730,7 +745,8 @@ export function buildGateDeps(params) {
|
|
|
730
745
|
// The project's own checks, suite included, once per tree for the run. A suite
|
|
731
746
|
// writes coverage, reports and databases into the tree; left there, they ride
|
|
732
747
|
// into the task's commit and read as enforce edits, so what the check created
|
|
733
|
-
// is removed before the tree is hashed again
|
|
748
|
+
// is removed before the tree is hashed again — except what it wrote as the
|
|
749
|
+
// repo's own record (see isSuiteRecord).
|
|
734
750
|
const gateHealth = (cwd2, onCommand) => currentRunContext(cwd2).healthFor(async () => {
|
|
735
751
|
const before = await untrackedFiles(cwd2);
|
|
736
752
|
try {
|
|
@@ -739,7 +755,7 @@ export function buildGateDeps(params) {
|
|
|
739
755
|
finally {
|
|
740
756
|
const after = before ? await untrackedFiles(cwd2) : null;
|
|
741
757
|
for (const rel of after ?? []) {
|
|
742
|
-
if (!before?.has(rel)) {
|
|
758
|
+
if (!before?.has(rel) && !isSuiteRecord(rel)) {
|
|
743
759
|
await fsp
|
|
744
760
|
.rm(path.join(cwd2, rel), { recursive: true, force: true })
|
|
745
761
|
.catch(() => { });
|
|
@@ -38,6 +38,19 @@ 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
|
+
/**
|
|
42
|
+
* Test commands the baseline RAN 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
|
+
* Ran, not passed. A baseline that ran the suite RED ran it, and deleting a red
|
|
51
|
+
* suite is the same move with a larger payoff: the whole check turns green.
|
|
52
|
+
*/
|
|
53
|
+
export declare function vanishedSuites(baseline: HealthSignal | null, after: HealthSignal): HealthCommandResult[];
|
|
41
54
|
/** The failing commands the baseline did not have failing the same way — what a
|
|
42
55
|
* `regressed` verdict is about. Every failing command when there is no baseline. */
|
|
43
56
|
export declare function regressedCommands(baseline: HealthSignal | null, after: HealthSignal): HealthCommandResult[];
|
|
@@ -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)
|
|
@@ -55,11 +57,32 @@ export function classifyHealthDelta(baseline, after) {
|
|
|
55
57
|
return regressedCommands(baseline, after).length > 0 ? 'regressed' : 'pre-existing';
|
|
56
58
|
}
|
|
57
59
|
const failureKey = (c) => JSON.stringify([c.cmd, c.exitCode]);
|
|
60
|
+
/**
|
|
61
|
+
* Test commands the baseline RAN 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
|
+
* Ran, not passed. A baseline that ran the suite RED ran it, and deleting a red
|
|
70
|
+
* suite is the same move with a larger payoff: the whole check turns green.
|
|
71
|
+
*/
|
|
72
|
+
export function vanishedSuites(baseline, after) {
|
|
73
|
+
if (!baseline)
|
|
74
|
+
return [];
|
|
75
|
+
const ran = new Set((baseline.commands ?? []).filter(c => c.outcome !== 'skip').map(c => c.cmd));
|
|
76
|
+
return (after.commands ?? []).filter(c => c.outcome === 'skip' && c.gap === 'empty-suite' && ran.has(c.cmd));
|
|
77
|
+
}
|
|
58
78
|
/** The failing commands the baseline did not have failing the same way — what a
|
|
59
79
|
* `regressed` verdict is about. Every failing command when there is no baseline. */
|
|
60
80
|
export function regressedCommands(baseline, after) {
|
|
61
81
|
const wasFailing = new Set(baseline ? failures(baseline).map(failureKey) : []);
|
|
62
|
-
return
|
|
82
|
+
return [
|
|
83
|
+
...failures(after).filter(c => !wasFailing.has(failureKey(c))),
|
|
84
|
+
...vanishedSuites(baseline, after)
|
|
85
|
+
];
|
|
63
86
|
}
|
|
64
87
|
/**
|
|
65
88
|
* The failing commands, as prompt/trail lines naming the exit code. A test
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* because its script is a placeholder `exit 1`, and no repair task can fix either.
|
|
23
23
|
*/
|
|
24
24
|
import type { HealthSignal } from './health-baseline.js';
|
|
25
|
-
import type
|
|
25
|
+
import { type HealthCommandResult } from './repo-health-check.js';
|
|
26
26
|
/** The failing check, and what its output named. */
|
|
27
27
|
export interface HealthRed {
|
|
28
28
|
command: string;
|
|
@@ -36,9 +36,13 @@ export interface HealthRedOwners {
|
|
|
36
36
|
owners: string[];
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
|
-
* What a red health result is about: its first
|
|
39
|
+
* What a red health result is about: its first red command that `mayRepair`
|
|
40
40
|
* admits. Null when there is none — a legacy baseline, a signal with no
|
|
41
41
|
* per-command detail, or only reds no repair can fix — so nothing to pin to.
|
|
42
|
+
*
|
|
43
|
+
* A vanished suite is red here though it failed nothing and left `ok` true. It
|
|
44
|
+
* is the regression the differential exists to catch, and without a subject
|
|
45
|
+
* ACCEPT queued no repair for it and the checkpoint spliced none.
|
|
42
46
|
*/
|
|
43
47
|
export declare function healthRedSubject(health: HealthSignal & {
|
|
44
48
|
output?: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHealthRed } from './repo-health-check.js';
|
|
1
2
|
import { parseRepairTitleFile } from './root-cause-repair.js';
|
|
2
3
|
import { failClassOfReason } from './verify-work.js';
|
|
3
4
|
/** A path-like token: at least one directory separator, ending in a file name. */
|
|
@@ -23,12 +24,16 @@ function resolveTracked(token, cwd, tracked) {
|
|
|
23
24
|
return bySuffix.length === 1 ? bySuffix[0] : null;
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
|
-
* What a red health result is about: its first
|
|
27
|
+
* What a red health result is about: its first red command that `mayRepair`
|
|
27
28
|
* admits. Null when there is none — a legacy baseline, a signal with no
|
|
28
29
|
* per-command detail, or only reds no repair can fix — so nothing to pin to.
|
|
30
|
+
*
|
|
31
|
+
* A vanished suite is red here though it failed nothing and left `ok` true. It
|
|
32
|
+
* is the regression the differential exists to catch, and without a subject
|
|
33
|
+
* ACCEPT queued no repair for it and the checkpoint spliced none.
|
|
29
34
|
*/
|
|
30
35
|
export function healthRedSubject(health, cwd, tracked, mayRepair = () => true) {
|
|
31
|
-
const failing = (health.commands ?? []).find(c => c
|
|
36
|
+
const failing = (health.commands ?? []).find(c => isHealthRed(c) && mayRepair(c));
|
|
32
37
|
if (!failing)
|
|
33
38
|
return null;
|
|
34
39
|
const files = [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CommandRunner } from './command-run.js';
|
|
1
|
+
import { type CommandGapId, type CommandRunner } from './command-run.js';
|
|
2
2
|
/**
|
|
3
3
|
* What ONE discovered command did. `outcome` is `classifyCommandRun`'s verdict, so
|
|
4
4
|
* a tool that could not run at all is `skip` rather than a zero-exit pass.
|
|
@@ -18,6 +18,9 @@ export interface HealthCommandResult {
|
|
|
18
18
|
/** Absent on a record written before the suite joined the check, which ran
|
|
19
19
|
* statics only. A test red is judged, owed and repaired differently. */
|
|
20
20
|
kind?: 'static' | 'test';
|
|
21
|
+
/** Why nothing was observed, on a `skip`. The differential reads it: a runner
|
|
22
|
+
* that found no tests is a gap alone and a regression against a suite. */
|
|
23
|
+
gap?: CommandGapId;
|
|
21
24
|
/** This command's own captured output, on a `fail` only. */
|
|
22
25
|
output?: string;
|
|
23
26
|
}
|
|
@@ -112,5 +115,7 @@ export declare function runRepoHealthCheck(cwd: string, opts?: {
|
|
|
112
115
|
* that will judge the result DIFFERENTIALLY may set this — see the header. */
|
|
113
116
|
withTests?: boolean;
|
|
114
117
|
}): Promise<HealthOutcome>;
|
|
118
|
+
/** A command the repo owes an answer for: it failed, or its suite went missing. */
|
|
119
|
+
export declare function isHealthRed(c: HealthCommandResult): boolean;
|
|
115
120
|
/** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
|
|
116
121
|
export declare function describeHealthFailures(commands: readonly HealthCommandResult[]): string;
|
|
@@ -125,9 +125,16 @@ export function discoverHealthCommands(cwd) {
|
|
|
125
125
|
}
|
|
126
126
|
return { ecosystem: null, cmds: [] };
|
|
127
127
|
}
|
|
128
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* `test:watch`, `jest --watchAll`, `vitest watch`, `bun test --watch`.
|
|
130
|
+
*
|
|
131
|
+
* The flag is read by its VALUE, not its presence: `--watchAll=false` is how a CI
|
|
132
|
+
* script turns watch off, and excluding it drops the only `test` script such a
|
|
133
|
+
* repo has.
|
|
134
|
+
*/
|
|
129
135
|
function isWatchScript(name, body) {
|
|
130
|
-
return (/watch/i.test(name)
|
|
136
|
+
return (/watch/i.test(name)
|
|
137
|
+
|| /(?:^|\s)--watch(?:All)?(?:=(?:true|1))?(?=\s|$)|(?:^|\s)watch(?=\s|$)/.test(body));
|
|
131
138
|
}
|
|
132
139
|
/**
|
|
133
140
|
* The project's OWN test commands, in the order the run-end gate runs them. One
|
|
@@ -242,7 +249,8 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
|
|
|
242
249
|
cmd,
|
|
243
250
|
outcome: passed ? 'pass' : 'skip',
|
|
244
251
|
exitCode: passed ? 0 : null,
|
|
245
|
-
kind
|
|
252
|
+
kind,
|
|
253
|
+
...(verdict.outcome === 'gap' ? { gap: verdict.gap } : {})
|
|
246
254
|
});
|
|
247
255
|
continue;
|
|
248
256
|
}
|
|
@@ -272,10 +280,16 @@ export async function runRepoHealthCheck(cwd, opts = {}) {
|
|
|
272
280
|
output: ''
|
|
273
281
|
};
|
|
274
282
|
}
|
|
283
|
+
/** A command the repo owes an answer for: it failed, or its suite went missing. */
|
|
284
|
+
export function isHealthRed(c) {
|
|
285
|
+
return c.outcome === 'fail' || c.gap === 'empty-suite';
|
|
286
|
+
}
|
|
275
287
|
/** "`bun run lint` exited 1; `bun run test` exited 1" — every failing command. */
|
|
276
288
|
export function describeHealthFailures(commands) {
|
|
277
289
|
return commands
|
|
278
|
-
.filter(
|
|
279
|
-
.map(c =>
|
|
290
|
+
.filter(isHealthRed)
|
|
291
|
+
.map(c => c.outcome === 'fail' ?
|
|
292
|
+
`\`${c.cmd}\` exited ${c.exitCode}`
|
|
293
|
+
: `\`${c.cmd}\` found no tests to run`)
|
|
280
294
|
.join('; ');
|
|
281
295
|
}
|
package/dist/task/task-gates.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { verifyFailClass } from './verify-work.js';
|
|
2
2
|
import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
|
|
3
3
|
import { resolveDisposition } from './gate-resolution.js';
|
|
4
|
-
import { classifyHealthDelta } from './health-baseline.js';
|
|
4
|
+
import { classifyHealthDelta, regressedCommands } from './health-baseline.js';
|
|
5
5
|
import { SessionUI, notifyBoth, notifyRun } from '../remote/bridge.js';
|
|
6
6
|
import { isYoloMode, YOLO_STAMP } from './yolo.js';
|
|
7
7
|
import { extractFailingCommand, findRepairCandidate, summariseDefect } from './root-cause-repair.js';
|
|
8
8
|
import { attributeEnforceFailure } from './enforce-attribution.js';
|
|
9
|
+
import { describeHealthFailures } from './repo-health-check.js';
|
|
9
10
|
import { healthRedSubject, parseHealthRepairTitle } from './health-repair.js';
|
|
10
11
|
// The debt ledger is reached through the injected `recordDebt` dep (so it stays
|
|
11
12
|
// absent-in-tests); only the origin TYPE and the cross-task-deletion reason SHAPE
|
|
@@ -417,15 +418,19 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
|
|
|
417
418
|
const delta = classifyHealthDelta(healthBefore ?? null, after);
|
|
418
419
|
if (delta === 'regressed') {
|
|
419
420
|
enforceEditsBlocked = true;
|
|
421
|
+
// What REGRESSED, not the check's own verdict: a vanished suite fails
|
|
422
|
+
// nothing, so `after.reason` reads "tests passed" under this discard.
|
|
423
|
+
const regressed = regressedCommands(healthBefore ?? null, after);
|
|
424
|
+
const why = regressed.length > 0 ? describeHealthFailures(regressed) : after.reason;
|
|
420
425
|
const outputTail = after.output ? ` — output:\n${clampOutput(after.output)}` : '';
|
|
421
426
|
if (deps.discardEdits) {
|
|
422
427
|
await deps.discardEdits(p.cwd);
|
|
423
|
-
await rec(`enforce: edits discarded pre-commit — REGRESSED repo health (${
|
|
428
|
+
await rec(`enforce: edits discarded pre-commit — REGRESSED repo health (${why})${outputTail}`);
|
|
424
429
|
}
|
|
425
430
|
else {
|
|
426
|
-
await rec(`enforce: edits REGRESSED repo health pre-commit (${
|
|
431
|
+
await rec(`enforce: edits REGRESSED repo health pre-commit (${why}) — no discard available, left uncommitted${outputTail}`);
|
|
427
432
|
}
|
|
428
|
-
notifyRun(active, `${p.tag}: guideline edits on "${p.title}" regressed repo health (${
|
|
433
|
+
notifyRun(active, `${p.tag}: guideline edits on "${p.title}" regressed repo health (${why.slice(0, 120)}) — discarded before commit.`, 'warning');
|
|
429
434
|
}
|
|
430
435
|
else if (delta === 'pre-existing') {
|
|
431
436
|
// Failing both before and after → not enforce's fault. Keep the edits;
|
|
@@ -65,9 +65,12 @@ export interface VerifyFail {
|
|
|
65
65
|
crossTaskDeletions?: CrossTaskDeletion[];
|
|
66
66
|
inheritedHealth?: string;
|
|
67
67
|
/** The health result behind a `repo-health` FAIL: which command, and what its
|
|
68
|
-
* output named. What an ACCEPT of this FAIL hands to the repair channel.
|
|
68
|
+
* output named. What an ACCEPT of this FAIL hands to the repair channel.
|
|
69
|
+
* `reason` describes what REGRESSED, which is not what the check itself said:
|
|
70
|
+
* a vanished suite fails nothing, so the check's verdict reads "tests passed". */
|
|
69
71
|
health?: HealthSignal & {
|
|
70
72
|
output?: string;
|
|
73
|
+
reason?: string;
|
|
71
74
|
};
|
|
72
75
|
/** What the deterministic probes found for THIS verdict, carried out so an
|
|
73
76
|
* AUTOFIX re-run is told what the gate already knows (see fix-context.ts)
|
package/dist/task/verify-work.js
CHANGED
|
@@ -918,7 +918,12 @@ export async function runWorkVerification(deps) {
|
|
|
918
918
|
if (deps.repoHealth) {
|
|
919
919
|
stage('repo health');
|
|
920
920
|
const h = await deps.repoHealth();
|
|
921
|
-
|
|
921
|
+
// `ok` is not the whole verdict: a suite this tree no longer finds observes
|
|
922
|
+
// nothing, so nothing fails, and only the differential sees it went away.
|
|
923
|
+
// Establishing a baseline can cost a worktree health run, so it is asked for
|
|
924
|
+
// only in the two shapes it can speak to.
|
|
925
|
+
const suiteGone = (h.commands ?? []).some(c => c.gap === 'empty-suite');
|
|
926
|
+
if (!h.ok || suiteGone) {
|
|
922
927
|
const baseline = deps.healthBaseline ? await deps.healthBaseline() : null;
|
|
923
928
|
const before = baseline?.outcome ?? null;
|
|
924
929
|
if (classifyHealthDelta(before, h) === 'regressed') {
|
|
@@ -934,19 +939,30 @@ export async function runWorkVerification(deps) {
|
|
|
934
939
|
};
|
|
935
940
|
}
|
|
936
941
|
const failClass = healthFailClass(regressed);
|
|
942
|
+
// Re-minted, never `h.reason`: a vanished suite fails nothing, so the
|
|
943
|
+
// check's own reason reads "tests passed" under a REGRESSED verdict.
|
|
944
|
+
const why = describeHealthFailures(regressed);
|
|
937
945
|
return {
|
|
938
946
|
ok: false,
|
|
939
947
|
failClass,
|
|
940
|
-
reason: `${VERIFY_FAIL_PREFIX[failClass]} ${
|
|
941
|
-
health: {
|
|
948
|
+
reason: `${VERIFY_FAIL_PREFIX[failClass]} ${why}`,
|
|
949
|
+
health: {
|
|
950
|
+
...h,
|
|
951
|
+
ok: false,
|
|
952
|
+
reason: why,
|
|
953
|
+
commands: regressed,
|
|
954
|
+
output: regressed[0].output ?? h.output
|
|
955
|
+
}
|
|
942
956
|
};
|
|
943
957
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
958
|
+
if (!h.ok) {
|
|
959
|
+
pre.repoHealth = inheritedHealthFindings(h);
|
|
960
|
+
const failing = h.commands?.filter(c => c.outcome === 'fail') ?? [];
|
|
961
|
+
inheritedHealth =
|
|
962
|
+
failing.length > 0 ?
|
|
963
|
+
`${VERIFY_FAIL_PREFIX[healthFailClass(failing)]} ${describeHealthFailures(failing)} — already failing before this task`
|
|
964
|
+
: `repo health: ${h.reason} — already failing before this task`;
|
|
965
|
+
}
|
|
950
966
|
}
|
|
951
967
|
}
|
|
952
968
|
const inherited = inheritedHealth === undefined ? {} : { inheritedHealth };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.6",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|