mandrel 2.41.0 → 2.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/story-worker.md +24 -14
- package/.agents/docs/agentrc-reference.json +7 -2
- package/.agents/docs/configuration.md +5 -2
- package/.agents/schemas/agentrc.schema.json +17 -2
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +68 -3
- package/.agents/scripts/coverage-capture.js +25 -8
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
- package/.agents/scripts/lib/baselines/git-base.js +74 -38
- package/.agents/scripts/lib/close-validation/gates.js +153 -25
- package/.agents/scripts/lib/close-validation/process.js +30 -1
- package/.agents/scripts/lib/close-validation/runner.js +5 -0
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
- package/.agents/scripts/lib/config/quality.js +36 -21
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
- package/.agents/scripts/lib/crap-baseline-join.js +11 -7
- package/.agents/scripts/lib/full-suite-lock.js +311 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
- package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
- package/.agents/workflows/helpers/deliver-digest.md +31 -10
- package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
- package/.agents/workflows/helpers/deliver-story.md +23 -21
- package/docs/CHANGELOG.md +18 -0
- package/package.json +1 -1
|
@@ -7,22 +7,16 @@
|
|
|
7
7
|
* @module lib/orchestration/check-baselines/phases/evaluate
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
|
|
11
|
-
import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
|
|
12
10
|
import {
|
|
13
11
|
checkBaselineSemantics,
|
|
14
12
|
checkKernelVersion,
|
|
15
13
|
getKindModule,
|
|
16
14
|
} from '../../../baselines/kernel.js';
|
|
17
15
|
import * as reader from '../../../baselines/reader.js';
|
|
18
|
-
import { Logger } from '../../../Logger.js';
|
|
19
16
|
import { isIgnoredByGlobs } from '../../../maintainability-utils.js';
|
|
20
17
|
import { applyTolerance, evaluateCompare, runCompareStage } from './compare.js';
|
|
21
18
|
import { applyFloors, flattenBreaches } from './floors.js';
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
/** Default refresh-tag substring when the gate omits `refreshTag`. */
|
|
25
|
-
const DEFAULT_REFRESH_TAG = 'baseline-refresh:';
|
|
19
|
+
import { applyRefreshAcknowledgment } from './refresh-ack.js';
|
|
26
20
|
|
|
27
21
|
/**
|
|
28
22
|
* Defense-in-depth against an `ignoreGlobs`-poisoned baseline (Epic #4326
|
|
@@ -87,97 +81,6 @@ function loadHeadBaseline(kind, cwd, configPath) {
|
|
|
87
81
|
}
|
|
88
82
|
}
|
|
89
83
|
|
|
90
|
-
/**
|
|
91
|
-
* Resolve the one-shot refresh trigger for any ratcheted kind (Story #4802,
|
|
92
|
-
* generalizing Story #151's bundle-size env flag and Story #4731's
|
|
93
|
-
* maintainability env-or-commit-tag pair). Two paths, either of which
|
|
94
|
-
* acknowledges:
|
|
95
|
-
*
|
|
96
|
-
* 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked,
|
|
97
|
-
* so the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
|
|
98
|
-
* `MAINTAINABILITY_REFRESH`) keep working unchanged.
|
|
99
|
-
* 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
|
|
100
|
-
* subject contains the configured `refreshTag` AND whose diff touches
|
|
101
|
-
* that kind's baseline file. One-shot by construction — once merged, the
|
|
102
|
-
* refreshed baseline becomes the base and the tag leaves the range.
|
|
103
|
-
*
|
|
104
|
-
* The tag is matched as a plain substring of a conventional commit subject, so
|
|
105
|
-
* commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
|
|
106
|
-
*
|
|
107
|
-
* Fails closed: a kind whose baseline path is neither configured nor present
|
|
108
|
-
* in `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
|
|
109
|
-
* throwing, leaving the run un-acknowledged.
|
|
110
|
-
*
|
|
111
|
-
* @returns {{ triggered: boolean, reasons: string[] }}
|
|
112
|
-
*/
|
|
113
|
-
function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
|
|
114
|
-
const reasons = [];
|
|
115
|
-
const { acknowledged: envAck, overrides } = resolveKindRefreshOverrides(
|
|
116
|
-
kind,
|
|
117
|
-
env,
|
|
118
|
-
);
|
|
119
|
-
if (envAck) reasons.push(...overrides);
|
|
120
|
-
|
|
121
|
-
const baseRef = cmp?.baseRef ?? null;
|
|
122
|
-
if (baseRef) {
|
|
123
|
-
const refreshTag =
|
|
124
|
-
typeof gateBlock?.refreshTag === 'string' && gateBlock.refreshTag.length
|
|
125
|
-
? gateBlock.refreshTag
|
|
126
|
-
: DEFAULT_REFRESH_TAG;
|
|
127
|
-
const baselinePath =
|
|
128
|
-
typeof gateBlock?.baselinePath === 'string' &&
|
|
129
|
-
gateBlock.baselinePath.length
|
|
130
|
-
? gateBlock.baselinePath
|
|
131
|
-
: DEFAULT_BASELINE_PATHS[kind];
|
|
132
|
-
if (typeof baselinePath === 'string' && baselinePath.length) {
|
|
133
|
-
const subjects = readRangeSubjectsTouchingFile(baseRef, baselinePath, {
|
|
134
|
-
cwd,
|
|
135
|
-
});
|
|
136
|
-
const match = subjects.find((s) => s.includes(refreshTag));
|
|
137
|
-
if (match) {
|
|
138
|
-
reasons.push(
|
|
139
|
-
`refresh commit "${match}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return { triggered: reasons.length > 0, reasons };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* One-shot baseline refresh/acknowledge for any ratcheted kind (Story #4802).
|
|
150
|
-
* When triggered (env flag OR a `baseline-refresh:`-tagged range commit
|
|
151
|
-
* touching that kind's baseline), demote every head-vs-base regression to
|
|
152
|
-
* `unchanged` for this run only — floors still apply, so a row below its floor
|
|
153
|
-
* still breaches. The trigger is read fresh every run and never persisted:
|
|
154
|
-
* post-merge the refreshed baseline is the new base and the tag leaves the
|
|
155
|
-
* range, so the ratchet returns to full strength automatically.
|
|
156
|
-
*
|
|
157
|
-
* A no-op absent a trigger, so an unacknowledged run of any kind reports its
|
|
158
|
-
* regressions exactly as before.
|
|
159
|
-
*/
|
|
160
|
-
function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
|
|
161
|
-
const { triggered, reasons } = resolveRefreshTrigger({ ...ctx, kind });
|
|
162
|
-
if (!triggered || compareOutput.regressions.length === 0) {
|
|
163
|
-
return { compareOutput, acknowledged: false };
|
|
164
|
-
}
|
|
165
|
-
Logger.warn(
|
|
166
|
-
`[${kind}] ⚠ ${reasons.join('; ')} — ` +
|
|
167
|
-
`${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
|
|
168
|
-
'floors still enforced. This does not persist: once the refresh is the ' +
|
|
169
|
-
'new base the ratchet re-enforces at full strength.',
|
|
170
|
-
);
|
|
171
|
-
return {
|
|
172
|
-
acknowledged: true,
|
|
173
|
-
compareOutput: {
|
|
174
|
-
...compareOutput,
|
|
175
|
-
regressions: [],
|
|
176
|
-
unchanged: [...compareOutput.unchanged, ...compareOutput.regressions],
|
|
177
|
-
},
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
84
|
function buildGateReport({
|
|
182
85
|
kind,
|
|
183
86
|
gateBlock,
|
|
@@ -186,7 +89,7 @@ function buildGateReport({
|
|
|
186
89
|
breaches,
|
|
187
90
|
compareOutput,
|
|
188
91
|
cmp,
|
|
189
|
-
|
|
92
|
+
ack,
|
|
190
93
|
}) {
|
|
191
94
|
const kernel = checkKernelVersion(kind, baseline.kernelVersion);
|
|
192
95
|
return {
|
|
@@ -212,7 +115,12 @@ function buildGateReport({
|
|
|
212
115
|
// the JSON report alone.
|
|
213
116
|
baseRead: cmp.baseRead === true,
|
|
214
117
|
generatedAt: baseline.generatedAt,
|
|
215
|
-
acknowledged,
|
|
118
|
+
acknowledged: ack.acknowledged,
|
|
119
|
+
// Story #5179 — `acknowledged` is now a PARTIAL statement: a refresh commit
|
|
120
|
+
// clears only the rows it actually refreshed, so a run can acknowledge some
|
|
121
|
+
// regressions and still fail on others. Naming the acknowledged keys makes
|
|
122
|
+
// which-half-was-which readable from the JSON report without re-walking git.
|
|
123
|
+
acknowledgedKeys: ack.acknowledgedKeys,
|
|
216
124
|
};
|
|
217
125
|
}
|
|
218
126
|
|
|
@@ -258,17 +166,16 @@ export async function evaluateKind({
|
|
|
258
166
|
cmp,
|
|
259
167
|
cwd,
|
|
260
168
|
env,
|
|
169
|
+
headBaseline: baseline,
|
|
261
170
|
});
|
|
262
|
-
const compareOutput = ack.compareOutput;
|
|
263
|
-
const acknowledged = ack.acknowledged;
|
|
264
171
|
return buildGateReport({
|
|
265
172
|
kind,
|
|
266
173
|
gateBlock,
|
|
267
174
|
baseline,
|
|
268
175
|
findings,
|
|
269
176
|
breaches,
|
|
270
|
-
compareOutput,
|
|
177
|
+
compareOutput: ack.compareOutput,
|
|
271
178
|
cmp,
|
|
272
|
-
|
|
179
|
+
ack,
|
|
273
180
|
});
|
|
274
181
|
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* refresh-ack.js — the one-shot baseline refresh acknowledgment (Story #5179).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `evaluate.js`, where it grew from a bundle-size env flag
|
|
5
|
+
* (Story #151) through a maintainability env-or-commit-tag pair (Story #4731)
|
|
6
|
+
* into the kind-generic trigger (Story #4802). Story #5179 narrowed the
|
|
7
|
+
* commit-tag arm from a whole-run blanket to the rows the tagged commit
|
|
8
|
+
* actually refreshed, which is enough logic to own its own module.
|
|
9
|
+
*
|
|
10
|
+
* @module lib/orchestration/check-baselines/phases/refresh-ack
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
|
|
14
|
+
import {
|
|
15
|
+
readBaseFromGit,
|
|
16
|
+
readRangeCommitsTouchingFile,
|
|
17
|
+
} from '../../../baselines/git-base.js';
|
|
18
|
+
import { getKindModule } from '../../../baselines/kernel.js';
|
|
19
|
+
import { Logger } from '../../../Logger.js';
|
|
20
|
+
import { applyTolerance } from './compare.js';
|
|
21
|
+
import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
|
|
22
|
+
|
|
23
|
+
/** Default refresh-tag substring when the gate omits `refreshTag`. */
|
|
24
|
+
const DEFAULT_REFRESH_TAG = 'baseline-refresh:';
|
|
25
|
+
|
|
26
|
+
function resolveBaselinePath(kind, gateBlock) {
|
|
27
|
+
const configured =
|
|
28
|
+
typeof gateBlock?.baselinePath === 'string' && gateBlock.baselinePath.length
|
|
29
|
+
? gateBlock.baselinePath
|
|
30
|
+
: null;
|
|
31
|
+
return configured ?? DEFAULT_BASELINE_PATHS[kind] ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveRefreshTag(gateBlock) {
|
|
35
|
+
return typeof gateBlock?.refreshTag === 'string' &&
|
|
36
|
+
gateBlock.refreshTag.length
|
|
37
|
+
? gateBlock.refreshTag
|
|
38
|
+
: DEFAULT_REFRESH_TAG;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the one-shot refresh trigger for any ratcheted kind. Two paths, either
|
|
43
|
+
* of which acknowledges:
|
|
44
|
+
*
|
|
45
|
+
* 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked, so
|
|
46
|
+
* the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
|
|
47
|
+
* `MAINTAINABILITY_REFRESH`) keep working unchanged.
|
|
48
|
+
* 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
|
|
49
|
+
* subject contains the configured `refreshTag` AND whose diff touches that
|
|
50
|
+
* kind's baseline file. One-shot by construction — once merged, the
|
|
51
|
+
* refreshed baseline becomes the base and the tag leaves the range.
|
|
52
|
+
*
|
|
53
|
+
* The tag is matched as a plain substring of a conventional commit subject, so
|
|
54
|
+
* commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
|
|
55
|
+
*
|
|
56
|
+
* The two arms are reported separately because Story #5179 scopes them
|
|
57
|
+
* differently: see `applyRefreshAcknowledgment`.
|
|
58
|
+
*
|
|
59
|
+
* Fails closed: a kind whose baseline path is neither configured nor present in
|
|
60
|
+
* `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
|
|
61
|
+
* throwing, leaving that arm un-acknowledged.
|
|
62
|
+
*
|
|
63
|
+
* @returns {{ triggered: boolean, reasons: string[], envAcknowledged: boolean,
|
|
64
|
+
* refreshCommits: { sha: string, subject: string }[], baselinePath: string | null }}
|
|
65
|
+
*/
|
|
66
|
+
function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
|
|
67
|
+
const reasons = [];
|
|
68
|
+
const { acknowledged: envAcknowledged, overrides } =
|
|
69
|
+
resolveKindRefreshOverrides(kind, env);
|
|
70
|
+
if (envAcknowledged) reasons.push(...overrides);
|
|
71
|
+
|
|
72
|
+
const baselinePath = resolveBaselinePath(kind, gateBlock);
|
|
73
|
+
const refreshCommits = [];
|
|
74
|
+
const baseRef = cmp?.baseRef ?? null;
|
|
75
|
+
if (baseRef && typeof baselinePath === 'string' && baselinePath.length) {
|
|
76
|
+
const refreshTag = resolveRefreshTag(gateBlock);
|
|
77
|
+
const commits = readRangeCommitsTouchingFile(baseRef, baselinePath, {
|
|
78
|
+
cwd,
|
|
79
|
+
});
|
|
80
|
+
for (const commit of commits) {
|
|
81
|
+
if (!commit.subject.includes(refreshTag)) continue;
|
|
82
|
+
refreshCommits.push(commit);
|
|
83
|
+
reasons.push(
|
|
84
|
+
`refresh commit "${commit.subject}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
triggered: reasons.length > 0,
|
|
91
|
+
reasons,
|
|
92
|
+
envAcknowledged,
|
|
93
|
+
refreshCommits,
|
|
94
|
+
baselinePath,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read the baseline rows as of one refresh commit. Returns `null` — never
|
|
100
|
+
* throws and never a partial row set — when the blob is absent, unreadable or
|
|
101
|
+
* unparseable at that SHA. The caller treats `null` as "this commit
|
|
102
|
+
* acknowledges nothing", which keeps the ratchet at full strength rather than
|
|
103
|
+
* acknowledging on a guess.
|
|
104
|
+
*/
|
|
105
|
+
function readRowsAtCommit(sha, baselinePath, cwd) {
|
|
106
|
+
let raw;
|
|
107
|
+
try {
|
|
108
|
+
raw = readBaseFromGit(sha, baselinePath, { cwd });
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (raw === null) return null;
|
|
113
|
+
try {
|
|
114
|
+
const payload = JSON.parse(raw);
|
|
115
|
+
return Array.isArray(payload?.rows) ? payload.rows : null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Classify the head rows against one refresh commit's rows, using the kind's
|
|
123
|
+
* own classifier and the gate's own tolerance.
|
|
124
|
+
*
|
|
125
|
+
* Row identity is deliberately never derived here. A kind's `keyField` names
|
|
126
|
+
* the row property the kind is *about*, which is not always its compare key:
|
|
127
|
+
* CRAP declares `keyField: 'path'` but keys rows as `path::method@startLine`,
|
|
128
|
+
* because one file holds many methods. Reading `row[keyField]` would produce a
|
|
129
|
+
* key matching no regression, silently acknowledging nothing for that kind.
|
|
130
|
+
* `compare()` is the one thing that knows a kind's key, so every key here comes
|
|
131
|
+
* back out of it — the same reason direction and tolerance are delegated rather
|
|
132
|
+
* than reimplemented.
|
|
133
|
+
*
|
|
134
|
+
* That also collapses both tests into one classification:
|
|
135
|
+
*
|
|
136
|
+
* - `regressions` — present in both, worse at head: post-refresh drift.
|
|
137
|
+
* - `improvements` / `unchanged` — present in both, no worse: the rows this
|
|
138
|
+
* commit vouches for.
|
|
139
|
+
* - `additions` — present at head but absent from the refresh blob, i.e.
|
|
140
|
+
* never refreshed by this commit, so deliberately in neither set.
|
|
141
|
+
*
|
|
142
|
+
* @returns {{ ok: string[], drifted: string[] } | null} null when the
|
|
143
|
+
* classifier is unusable, which acknowledges nothing.
|
|
144
|
+
*/
|
|
145
|
+
function classifyAgainstRefresh({ mod, headRows, refreshRows, tolerance }) {
|
|
146
|
+
try {
|
|
147
|
+
const result = mod.compare({ rows: headRows }, { rows: refreshRows });
|
|
148
|
+
const tolerated = applyTolerance(
|
|
149
|
+
{
|
|
150
|
+
regressions: result?.regressions ?? [],
|
|
151
|
+
improvements: result?.improvements ?? [],
|
|
152
|
+
unchanged: result?.unchanged ?? [],
|
|
153
|
+
additions: result?.additions ?? [],
|
|
154
|
+
},
|
|
155
|
+
tolerance ?? null,
|
|
156
|
+
);
|
|
157
|
+
return {
|
|
158
|
+
ok: [...tolerated.improvements, ...tolerated.unchanged].map((r) => r.key),
|
|
159
|
+
drifted: tolerated.regressions.map((r) => r.key),
|
|
160
|
+
};
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Which regression keys are acknowledgeable by the tagged commits (Story #5179)?
|
|
168
|
+
*
|
|
169
|
+
* The acknowledgment is a statement about what a refresh commit re-scored, so a
|
|
170
|
+
* key is acknowledgeable only when the commit both covered it and recorded a
|
|
171
|
+
* value the head has not since fallen below. Both tests come out of
|
|
172
|
+
* `classifyAgainstRefresh` above:
|
|
173
|
+
*
|
|
174
|
+
* 1. **Row membership** — a key absent from the refresh blob lands in
|
|
175
|
+
* `additions`, never in `ok`. This was the larger half of the leak: a
|
|
176
|
+
* single tagged commit cleared regressions on rows in directories it never
|
|
177
|
+
* touched.
|
|
178
|
+
* 2. **No post-refresh drift** — a key worse at head than the commit recorded
|
|
179
|
+
* lands in `drifted`. Drift from commits landing AFTER the refresh is what
|
|
180
|
+
* "the baseline commit must be the branch's last score-moving commit" asks
|
|
181
|
+
* for by convention and nothing enforced.
|
|
182
|
+
*
|
|
183
|
+
* Fails closed at every step: an unreadable blob, a missing `compare`, or a
|
|
184
|
+
* classifier that throws contributes nothing, so those regressions stand.
|
|
185
|
+
*
|
|
186
|
+
* @returns {Set<string>}
|
|
187
|
+
*/
|
|
188
|
+
function acknowledgeableKeys({
|
|
189
|
+
kind,
|
|
190
|
+
headBaseline,
|
|
191
|
+
refreshCommits,
|
|
192
|
+
baselinePath,
|
|
193
|
+
cwd,
|
|
194
|
+
tolerance,
|
|
195
|
+
}) {
|
|
196
|
+
const acknowledgeable = new Set();
|
|
197
|
+
if (refreshCommits.length === 0) return acknowledgeable;
|
|
198
|
+
if (typeof baselinePath !== 'string' || baselinePath.length === 0)
|
|
199
|
+
return acknowledgeable;
|
|
200
|
+
|
|
201
|
+
let mod;
|
|
202
|
+
try {
|
|
203
|
+
mod = getKindModule(kind);
|
|
204
|
+
} catch {
|
|
205
|
+
return acknowledgeable;
|
|
206
|
+
}
|
|
207
|
+
if (typeof mod?.compare !== 'function') return acknowledgeable;
|
|
208
|
+
|
|
209
|
+
const headRows = Array.isArray(headBaseline?.rows) ? headBaseline.rows : [];
|
|
210
|
+
// `readRangeCommitsTouchingFile` returns newest-first, and a key the newest
|
|
211
|
+
// refresh already ruled on is not reopened by an older one: the newest is the
|
|
212
|
+
// state the branch is asking to be held to.
|
|
213
|
+
const decided = new Set();
|
|
214
|
+
for (const { sha } of refreshCommits) {
|
|
215
|
+
const refreshRows = readRowsAtCommit(sha, baselinePath, cwd);
|
|
216
|
+
if (refreshRows === null) continue;
|
|
217
|
+
const verdict = classifyAgainstRefresh({
|
|
218
|
+
mod,
|
|
219
|
+
headRows,
|
|
220
|
+
refreshRows,
|
|
221
|
+
tolerance,
|
|
222
|
+
});
|
|
223
|
+
if (verdict === null) continue;
|
|
224
|
+
for (const key of verdict.ok) {
|
|
225
|
+
if (decided.has(key)) continue;
|
|
226
|
+
decided.add(key);
|
|
227
|
+
acknowledgeable.add(key);
|
|
228
|
+
}
|
|
229
|
+
for (const key of verdict.drifted) decided.add(key);
|
|
230
|
+
}
|
|
231
|
+
return acknowledgeable;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function partitionRegressions(regressions, isAcknowledgeable) {
|
|
235
|
+
const acknowledged = [];
|
|
236
|
+
const kept = [];
|
|
237
|
+
for (const reg of regressions) {
|
|
238
|
+
if (isAcknowledgeable(reg)) acknowledged.push(reg);
|
|
239
|
+
else kept.push(reg);
|
|
240
|
+
}
|
|
241
|
+
return { acknowledged, kept };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function logAcknowledgment({ kind, reasons, acknowledged, kept }) {
|
|
245
|
+
const held =
|
|
246
|
+
kept.length > 0
|
|
247
|
+
? `${kept.length} regression(s) NOT acknowledged (outside the refreshed rows, or drifted further after the refresh) and still fail the gate; `
|
|
248
|
+
: '';
|
|
249
|
+
Logger.warn(
|
|
250
|
+
`[${kind}] ⚠ ${reasons.join('; ')} — ` +
|
|
251
|
+
`${acknowledged.length} regression(s) acknowledged for this run only; ` +
|
|
252
|
+
`${held}floors still enforced. This does not persist: once the refresh is ` +
|
|
253
|
+
'the new base the ratchet re-enforces at full strength.',
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One-shot baseline refresh/acknowledge for any ratcheted kind. When triggered,
|
|
259
|
+
* demote acknowledged head-vs-base regressions to `unchanged` for this run only
|
|
260
|
+
* — floors still apply, so a row below its floor still breaches. The trigger is
|
|
261
|
+
* read fresh every run and never persisted: post-merge the refreshed baseline is
|
|
262
|
+
* the new base and the tag leaves the range, so the ratchet returns to full
|
|
263
|
+
* strength automatically.
|
|
264
|
+
*
|
|
265
|
+
* The two trigger arms are scoped differently, deliberately:
|
|
266
|
+
*
|
|
267
|
+
* - **Commit tag** — scoped to the rows the tagged commits actually refreshed,
|
|
268
|
+
* per `acknowledgeableKeys`. Before Story #5179 this cleared every regression
|
|
269
|
+
* in the range, so a branch merged carrying a stale row and the ratchet ran
|
|
270
|
+
* loose on that file; the failure recurred six times.
|
|
271
|
+
* - **Env parity** (`<KIND>_REFRESH=1`) — stays whole-run. There is no commit to
|
|
272
|
+
* anchor row-scoping to, and setting the variable is an explicit, deliberate
|
|
273
|
+
* operator act rather than a signal inferred from history.
|
|
274
|
+
*
|
|
275
|
+
* A no-op absent a trigger, so an unacknowledged run of any kind reports its
|
|
276
|
+
* regressions exactly as before.
|
|
277
|
+
*
|
|
278
|
+
* @returns {{ compareOutput: object, acknowledged: boolean, acknowledgedKeys: string[] }}
|
|
279
|
+
*/
|
|
280
|
+
export function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
|
|
281
|
+
const trigger = resolveRefreshTrigger({ ...ctx, kind });
|
|
282
|
+
if (!trigger.triggered || compareOutput.regressions.length === 0) {
|
|
283
|
+
return { compareOutput, acknowledged: false, acknowledgedKeys: [] };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let isAcknowledgeable;
|
|
287
|
+
if (trigger.envAcknowledged) {
|
|
288
|
+
isAcknowledgeable = () => true;
|
|
289
|
+
} else {
|
|
290
|
+
const keys = acknowledgeableKeys({
|
|
291
|
+
kind,
|
|
292
|
+
headBaseline: ctx.headBaseline,
|
|
293
|
+
refreshCommits: trigger.refreshCommits,
|
|
294
|
+
baselinePath: trigger.baselinePath,
|
|
295
|
+
cwd: ctx.cwd,
|
|
296
|
+
tolerance: ctx.gateBlock?.tolerance ?? null,
|
|
297
|
+
});
|
|
298
|
+
isAcknowledgeable = (reg) => keys.has(reg.key);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const { acknowledged, kept } = partitionRegressions(
|
|
302
|
+
compareOutput.regressions,
|
|
303
|
+
isAcknowledgeable,
|
|
304
|
+
);
|
|
305
|
+
if (acknowledged.length === 0) {
|
|
306
|
+
return { compareOutput, acknowledged: false, acknowledgedKeys: [] };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
logAcknowledgment({ kind, reasons: trigger.reasons, acknowledged, kept });
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
acknowledged: true,
|
|
313
|
+
acknowledgedKeys: acknowledged.map((reg) => reg.key),
|
|
314
|
+
compareOutput: {
|
|
315
|
+
...compareOutput,
|
|
316
|
+
regressions: kept,
|
|
317
|
+
unchanged: [...compareOutput.unchanged, ...acknowledged],
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -34,7 +34,14 @@ function formatGateLine(g) {
|
|
|
34
34
|
g.baseRef && g.baseRead === false
|
|
35
35
|
? ' [baseRead=false — compare skipped]'
|
|
36
36
|
: '';
|
|
37
|
-
|
|
37
|
+
// Story #5179 — a refresh commit acknowledges only the rows it refreshed, so
|
|
38
|
+
// the count says how much was cleared and the status ahead of it still shows
|
|
39
|
+
// FAIL when regressions outside those rows survived. `buildGateReport` always
|
|
40
|
+
// sets `acknowledgedKeys` alongside `acknowledged` from the same result
|
|
41
|
+
// object, so the array needs no guard of its own here.
|
|
42
|
+
const ack = g.acknowledged
|
|
43
|
+
? ` [ACKNOWLEDGED ${g.acknowledgedKeys.length} row(s) — this run only]`
|
|
44
|
+
: '';
|
|
38
45
|
return ` - ${g.kind}: ${status}${drift}${baseRef}${baseRead}${ack}`;
|
|
39
46
|
}
|
|
40
47
|
|
|
@@ -27,8 +27,12 @@ import {
|
|
|
27
27
|
const PHASE_ORDER = Object.freeze([
|
|
28
28
|
'init',
|
|
29
29
|
'wrong-tree-guard',
|
|
30
|
-
|
|
30
|
+
// Story #5172 — base-sync now precedes close-validation, so the tree the
|
|
31
|
+
// gates validate is the tree the push sends. The order here is not
|
|
32
|
+
// decoration: it is how a failed terminal decides which gates had already
|
|
33
|
+
// cleared, so it MUST track `runPrePushPhases`.
|
|
31
34
|
'base-sync',
|
|
35
|
+
'close-validation',
|
|
32
36
|
'push',
|
|
33
37
|
'pull-request',
|
|
34
38
|
'code-review',
|
|
@@ -45,6 +49,62 @@ const GATE_PHASES = Object.freeze([
|
|
|
45
49
|
['codeReview', 'code-review'],
|
|
46
50
|
]);
|
|
47
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The names the split baselines gate registers under, mirrored from
|
|
54
|
+
* `BASELINES_GATE_NAMES` in `lib/close-validation/gates.js` (Story #5172).
|
|
55
|
+
*
|
|
56
|
+
* Deliberately a local copy rather than an import: several close suites
|
|
57
|
+
* replace that module wholesale via `t.mock.module`, and a named import here
|
|
58
|
+
* would fail to link against a mock that does not re-export the constant —
|
|
59
|
+
* turning an unrelated test's mock into a load error on the CLI's own entry
|
|
60
|
+
* path. `tests/close-validation-gates-enum.test.js` pins the two lists
|
|
61
|
+
* against each other so the copy cannot drift.
|
|
62
|
+
*/
|
|
63
|
+
const BASELINES_ENTRY_NAMES = Object.freeze([
|
|
64
|
+
'check-baselines-independent',
|
|
65
|
+
'check-baselines-coverage',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Outcome for each split baselines entry on a run that died at `phase`.
|
|
70
|
+
*
|
|
71
|
+
* The two entries sit in ONE pipeline phase, so the phase walk alone cannot
|
|
72
|
+
* separate them — `failedGate` (tagged onto the error by the close-validation
|
|
73
|
+
* phase) is what names the entry that actually broke. Rules, in the module's
|
|
74
|
+
* house style of never claiming a pass it cannot prove:
|
|
75
|
+
* - validation skipped, or the run died before reaching it → both `skipped`.
|
|
76
|
+
* - the run cleared validation entirely → both `passed`.
|
|
77
|
+
* - the run died IN validation on the coverage-independent entry → that one
|
|
78
|
+
* `failed`, the coverage one `skipped` (it runs behind `coverage-capture`,
|
|
79
|
+
* which the failure pre-empted).
|
|
80
|
+
* - died on the coverage-consuming entry → that one `failed`, and the
|
|
81
|
+
* independent one `passed`: it is in the parallel partition that must go
|
|
82
|
+
* green before any serial gate starts.
|
|
83
|
+
* - died in validation on some other gate → both `skipped`; which of them
|
|
84
|
+
* had run is not knowable from the phase alone.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} phase
|
|
87
|
+
* @param {{ skipValidation?: boolean, failedGate?: string|null }} args
|
|
88
|
+
* @returns {Record<string, 'passed'|'failed'|'skipped'>}
|
|
89
|
+
*/
|
|
90
|
+
function baselinesGatesForFailedPhase(phase, { skipValidation, failedGate }) {
|
|
91
|
+
const [independent, coverage] = BASELINES_ENTRY_NAMES;
|
|
92
|
+
const both = (outcome) => ({ [independent]: outcome, [coverage]: outcome });
|
|
93
|
+
const failedAt = PHASE_ORDER.indexOf(phase);
|
|
94
|
+
const validationAt = PHASE_ORDER.indexOf('close-validation');
|
|
95
|
+
if (skipValidation || failedAt < 0 || failedAt < validationAt) {
|
|
96
|
+
return both('skipped');
|
|
97
|
+
}
|
|
98
|
+
if (failedAt > validationAt) return both('passed');
|
|
99
|
+
if (failedGate === independent) {
|
|
100
|
+
return { [independent]: 'failed', [coverage]: 'skipped' };
|
|
101
|
+
}
|
|
102
|
+
if (failedGate === coverage) {
|
|
103
|
+
return { [independent]: 'passed', [coverage]: 'failed' };
|
|
104
|
+
}
|
|
105
|
+
return both('skipped');
|
|
106
|
+
}
|
|
107
|
+
|
|
48
108
|
/**
|
|
49
109
|
* Report every gate's outcome for a run that died at `phase`.
|
|
50
110
|
*
|
|
@@ -59,8 +119,14 @@ const GATE_PHASES = Object.freeze([
|
|
|
59
119
|
* turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
|
|
60
120
|
* not pass — it never ran).
|
|
61
121
|
*
|
|
122
|
+
* Story #5172 — the reported set also carries the two split baselines
|
|
123
|
+
* entries under their own names, so a failed close says WHICH half of the
|
|
124
|
+
* baselines gate breached instead of a single generic verdict.
|
|
125
|
+
*
|
|
62
126
|
* @param {string} phase The phase the run died in.
|
|
63
|
-
* @param {{ skipValidation?: boolean, skipSync?: boolean }} args
|
|
127
|
+
* @param {{ skipValidation?: boolean, skipSync?: boolean, failedGate?: string|null }} args
|
|
128
|
+
* Parsed CLI args, plus the gate name tagged onto the error by the
|
|
129
|
+
* close-validation phase.
|
|
64
130
|
* @returns {Record<string, 'passed'|'failed'|'skipped'>}
|
|
65
131
|
*/
|
|
66
132
|
export function gatesForFailedPhase(phase, args = {}) {
|
|
@@ -73,7 +139,13 @@ export function gatesForFailedPhase(phase, args = {}) {
|
|
|
73
139
|
else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
|
|
74
140
|
else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
|
|
75
141
|
}
|
|
76
|
-
return
|
|
142
|
+
return {
|
|
143
|
+
...gates,
|
|
144
|
+
...baselinesGatesForFailedPhase(phase, {
|
|
145
|
+
skipValidation: args.skipValidation,
|
|
146
|
+
failedGate: args.failedGate ?? null,
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
77
149
|
}
|
|
78
150
|
|
|
79
151
|
/**
|
|
@@ -91,6 +163,10 @@ export function gatesForFailedPhase(phase, args = {}) {
|
|
|
91
163
|
* holding the script had been reaped mid-run. On failure this returns null
|
|
92
164
|
* and the caller rethrows the original.
|
|
93
165
|
*
|
|
166
|
+
* `err.closeGate` — tagged by the close-validation phase — names the gate that
|
|
167
|
+
* died inside that phase, which is what lets the reported gates separate the
|
|
168
|
+
* two split baselines entries (Story #5172).
|
|
169
|
+
*
|
|
94
170
|
* @param {unknown} err
|
|
95
171
|
* @param {{ storyId?: string|number, skipValidation?: boolean, skipSync?: boolean }} args
|
|
96
172
|
* Parsed CLI args — the story id the envelope reports on, plus the skip
|
|
@@ -108,7 +184,10 @@ export function failedTerminalFor(err, args = {}) {
|
|
|
108
184
|
storyId,
|
|
109
185
|
status: 'failed',
|
|
110
186
|
phase,
|
|
111
|
-
gates: gatesForFailedPhase(phase,
|
|
187
|
+
gates: gatesForFailedPhase(phase, {
|
|
188
|
+
...args,
|
|
189
|
+
failedGate: err?.closeGate ?? null,
|
|
190
|
+
}),
|
|
112
191
|
failure: { reason: String(err?.message ?? err) },
|
|
113
192
|
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
114
193
|
elapsedSeconds: 0,
|