mandrel 2.15.0 → 2.17.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/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +28 -6
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-light.js +31 -3
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
- package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-context.js +4 -1
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/.agents/workflows/helpers/deliver-light.md +34 -8
- package/.agents/workflows/helpers/plan-reference.md +27 -6
- package/.agents/workflows/plan.md +4 -2
- package/.agents/workflows/prototype.md +104 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +41 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
* single write in the run is the report artifact, performed by the synthesis
|
|
27
27
|
* agent, which is granted the read-only allowlist plus `Write`.
|
|
28
28
|
*
|
|
29
|
+
* ## Partial-failure posture
|
|
30
|
+
*
|
|
31
|
+
* The per-dimension fan-out is settled, not all-or-nothing (Story #4783). One
|
|
32
|
+
* rejected dimension used to discard every sibling's completed work; the
|
|
33
|
+
* engine now partitions the settled results, flows the fulfilled dimensions on
|
|
34
|
+
* to the next phase, and records the rejected ones as an explicit
|
|
35
|
+
* degraded-coverage note in the report's Executive Summary. This mirrors the
|
|
36
|
+
* posture `close-validation/runner.js` already takes with per-gate errors:
|
|
37
|
+
* capture the failure into the result rather than rejecting the whole run.
|
|
38
|
+
* Only a *total* loss — every dimension rejected — throws, because there is
|
|
39
|
+
* then nothing to synthesise.
|
|
40
|
+
*
|
|
29
41
|
* ## Report-contract self-check
|
|
30
42
|
*
|
|
31
43
|
* After synthesis the engine calls the caller-supplied `assertReportContract`
|
|
@@ -43,6 +55,14 @@
|
|
|
43
55
|
* @module dynamic-workflow/audit-orchestrator
|
|
44
56
|
*/
|
|
45
57
|
|
|
58
|
+
import { withDegradedCoverageNote } from './degraded-coverage.js';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A dimension that did not complete, as recorded by the settled fan-out.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {import('./degraded-coverage.js').DimensionFailure} DimensionFailure
|
|
64
|
+
*/
|
|
65
|
+
|
|
46
66
|
/**
|
|
47
67
|
* The live dynamic-workflow runtime context the host passes to a saved
|
|
48
68
|
* `.claude/workflows/*.workflow.js` entry point. Re-exported as a typedef so
|
|
@@ -82,8 +102,11 @@
|
|
|
82
102
|
* Compose the analysis prompt for one dimension (lens-specific).
|
|
83
103
|
* @property {(dimension: string, findings: string) => string} buildCrossCheckPrompt
|
|
84
104
|
* Compose the adversarial cross-check prompt for one dimension's findings.
|
|
85
|
-
* @property {(crossCheckedBlocks: string[]) => string} buildSynthesisPrompt
|
|
86
|
-
* Compose the synthesis prompt that assembles the report and writes it.
|
|
105
|
+
* @property {(crossCheckedBlocks: string[], degraded: DimensionFailure[]) => string} buildSynthesisPrompt
|
|
106
|
+
* Compose the synthesis prompt that assembles the report and writes it. The
|
|
107
|
+
* second argument lists the dimensions that did not complete (empty on a
|
|
108
|
+
* full-coverage run); lenses may ignore it — the engine annotates the report
|
|
109
|
+
* with the degraded-coverage note either way.
|
|
87
110
|
* @property {(report: string) => { conformant: boolean, missingSections: string[], hasTitle: boolean }} assertReportContract
|
|
88
111
|
* Self-check the synthesised report against the lens's report contract.
|
|
89
112
|
* @property {(check: { conformant: boolean, missingSections: string[], hasTitle: boolean }) => string} [formatContractError]
|
|
@@ -122,6 +145,45 @@ export function defaultContractError(check) {
|
|
|
122
145
|
return `report failed contract check: missing ${titlePart}sections=[${sections}]`;
|
|
123
146
|
}
|
|
124
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Reduce a rejection reason to a single-line message.
|
|
150
|
+
*
|
|
151
|
+
* @param {unknown} reason
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
function describeRejection(reason) {
|
|
155
|
+
if (reason instanceof Error) return reason.message;
|
|
156
|
+
return String(reason ?? 'unknown error');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Partition settled fan-out results into the values that completed and the
|
|
161
|
+
* dimensions that did not. `dimensions[i]` names the dimension behind
|
|
162
|
+
* `settled[i]`, so a rejection is always attributable.
|
|
163
|
+
*
|
|
164
|
+
* @template T
|
|
165
|
+
* @param {readonly string[]} dimensions
|
|
166
|
+
* @param {readonly PromiseSettledResult<T>[]} settled
|
|
167
|
+
* @param {string} phaseName Phase recorded on each failure.
|
|
168
|
+
* @returns {{ fulfilled: T[], failures: DimensionFailure[] }}
|
|
169
|
+
*/
|
|
170
|
+
function partitionSettled(dimensions, settled, phaseName) {
|
|
171
|
+
const fulfilled = [];
|
|
172
|
+
const failures = [];
|
|
173
|
+
settled.forEach((result, index) => {
|
|
174
|
+
if (result.status === 'fulfilled') {
|
|
175
|
+
fulfilled.push(result.value);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
failures.push({
|
|
179
|
+
dimension: dimensions[index],
|
|
180
|
+
phase: phaseName,
|
|
181
|
+
reason: describeRejection(result.reason),
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
return { fulfilled, failures };
|
|
185
|
+
}
|
|
186
|
+
|
|
125
187
|
/**
|
|
126
188
|
* Run the shared three-phase audit-lens orchestration: parallel per-dimension
|
|
127
189
|
* analysis → adversarial cross-check → synthesis + report-contract self-check.
|
|
@@ -149,9 +211,10 @@ export async function runAuditOrchestration(spec) {
|
|
|
149
211
|
|
|
150
212
|
const { agent, phase } = ctx;
|
|
151
213
|
|
|
152
|
-
// Phase 1 — parallel per-dimension analysis (read-only agents).
|
|
153
|
-
|
|
154
|
-
|
|
214
|
+
// Phase 1 — parallel per-dimension analysis (read-only agents). Settled, not
|
|
215
|
+
// all-or-nothing: one dimension's rejection must not discard its siblings.
|
|
216
|
+
const analyzed = await phase(ORCHESTRATION_PHASES.ANALYZE, async () =>
|
|
217
|
+
Promise.allSettled(
|
|
155
218
|
dimensions.map(async (dimension) => {
|
|
156
219
|
const { output } = await agent({
|
|
157
220
|
prompt: buildDimensionPrompt(dimension),
|
|
@@ -161,11 +224,15 @@ export async function runAuditOrchestration(spec) {
|
|
|
161
224
|
}),
|
|
162
225
|
),
|
|
163
226
|
);
|
|
227
|
+
const { fulfilled: rawFindings, failures: analyzeFailures } =
|
|
228
|
+
partitionSettled(dimensions, analyzed, 'analyze');
|
|
164
229
|
|
|
165
230
|
// Phase 2 — adversarial cross-check: an independent agent re-verifies each
|
|
166
|
-
// dimension's findings and filters false positives before
|
|
167
|
-
|
|
168
|
-
|
|
231
|
+
// surviving dimension's findings and filters false positives before
|
|
232
|
+
// inclusion. Settled for the same reason as phase 1.
|
|
233
|
+
const checkedDimensions = rawFindings.map((entry) => entry.dimension);
|
|
234
|
+
const checked = await phase(ORCHESTRATION_PHASES.CROSS_CHECK, async () =>
|
|
235
|
+
Promise.allSettled(
|
|
169
236
|
rawFindings.map(async ({ dimension, findings }) => {
|
|
170
237
|
const { output } = await agent({
|
|
171
238
|
prompt: buildCrossCheckPrompt(dimension, findings),
|
|
@@ -175,18 +242,38 @@ export async function runAuditOrchestration(spec) {
|
|
|
175
242
|
}),
|
|
176
243
|
),
|
|
177
244
|
);
|
|
245
|
+
const { fulfilled: crossChecked, failures: crossCheckFailures } =
|
|
246
|
+
partitionSettled(checkedDimensions, checked, 'cross-check');
|
|
247
|
+
|
|
248
|
+
const degraded = [...analyzeFailures, ...crossCheckFailures];
|
|
249
|
+
if (dimensions.length > 0 && crossChecked.length === 0) {
|
|
250
|
+
// Nothing survived — there is no partial report to salvage.
|
|
251
|
+
throw new Error(
|
|
252
|
+
`every audit dimension failed: ${degraded
|
|
253
|
+
.map((f) => `${f.dimension} (${f.phase}: ${f.reason})`)
|
|
254
|
+
.join('; ')}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
178
257
|
|
|
179
258
|
// Phase 3 — synthesis: assemble the report contract and write the artifact.
|
|
180
|
-
const { output:
|
|
259
|
+
const { output: synthesised } = await phase(
|
|
181
260
|
ORCHESTRATION_PHASES.SYNTHESIZE,
|
|
182
261
|
async () =>
|
|
183
262
|
agent({
|
|
184
|
-
prompt: buildSynthesisPrompt(crossChecked),
|
|
263
|
+
prompt: buildSynthesisPrompt(crossChecked, degraded),
|
|
185
264
|
// Synthesis is the one stage permitted to write the report artifact.
|
|
186
265
|
allowedTools: [...readOnlyTools, SYNTHESIS_WRITE_TOOL],
|
|
187
266
|
}),
|
|
188
267
|
);
|
|
189
268
|
|
|
269
|
+
// The coverage gap is annotated by the engine, never left to the synthesis
|
|
270
|
+
// agent's discretion.
|
|
271
|
+
const report = withDegradedCoverageNote(
|
|
272
|
+
synthesised,
|
|
273
|
+
degraded,
|
|
274
|
+
dimensions.length,
|
|
275
|
+
);
|
|
276
|
+
|
|
190
277
|
// Self-verify report-contract conformance before returning.
|
|
191
278
|
const check = assertReportContract(report);
|
|
192
279
|
if (!check.conformant) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// .agents/scripts/lib/dynamic-workflow/degraded-coverage.js
|
|
2
|
+
/**
|
|
3
|
+
* Degraded-coverage annotation for audit-lens reports (Story #4783).
|
|
4
|
+
*
|
|
5
|
+
* An audit lens fans out one sub-agent per analysis dimension. When one of
|
|
6
|
+
* those dimensions rejects — a sub-agent that ran out of context, a
|
|
7
|
+
* measurement command that failed, a transient runtime error — the run used to
|
|
8
|
+
* discard every sibling dimension's completed work along with it.
|
|
9
|
+
*
|
|
10
|
+
* The engine now partitions instead: the fulfilled dimensions flow on to
|
|
11
|
+
* synthesis, and the rejected ones are recorded here as an explicit note in
|
|
12
|
+
* the report's Executive Summary. A lens that covers four of five dimensions
|
|
13
|
+
* *and says which one is missing* is strictly more useful than one that yields
|
|
14
|
+
* nothing — but only if the gap is visible. An unannotated partial report is
|
|
15
|
+
* worse than no report, because it reads as complete coverage.
|
|
16
|
+
*
|
|
17
|
+
* The annotation is applied by the engine, not requested of the synthesis
|
|
18
|
+
* agent: a coverage disclaimer that depends on an LLM remembering to write it
|
|
19
|
+
* is not a disclaimer.
|
|
20
|
+
*
|
|
21
|
+
* @module dynamic-workflow/degraded-coverage
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A dimension that did not complete.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {object} DimensionFailure
|
|
28
|
+
* @property {string} dimension The analysis dimension that failed.
|
|
29
|
+
* @property {string} phase The phase it failed in (`analyze` / `cross-check`).
|
|
30
|
+
* @property {string} reason The rejection's message.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Matches the Executive Summary heading at any heading level. */
|
|
34
|
+
const EXECUTIVE_SUMMARY_HEADING = /^#{1,6}\s+Executive Summary\b/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render the degraded-coverage note. Names every failed dimension, the phase
|
|
38
|
+
* it failed in, and the reason, so a reader can tell coverage loss apart from
|
|
39
|
+
* an absence of findings.
|
|
40
|
+
*
|
|
41
|
+
* @param {readonly DimensionFailure[]} failures
|
|
42
|
+
* @param {number} totalDimensions Dimensions the run set out to cover.
|
|
43
|
+
* @returns {string} A markdown blockquote.
|
|
44
|
+
*/
|
|
45
|
+
function formatDegradedCoverageNote(failures, totalDimensions) {
|
|
46
|
+
const detail = failures
|
|
47
|
+
.map((f) => `**${f.dimension}** (${f.phase}: ${f.reason})`)
|
|
48
|
+
.join('; ');
|
|
49
|
+
const noun = failures.length === 1 ? 'dimension' : 'dimensions';
|
|
50
|
+
return [
|
|
51
|
+
`> ⚠️ **Degraded coverage** — ${failures.length} of ${totalDimensions} analysis ${noun} did not complete`,
|
|
52
|
+
`> and ${failures.length === 1 ? 'is' : 'are'} unrepresented in this report: ${detail}.`,
|
|
53
|
+
'> Findings for the remaining dimensions are complete; the gap above is not evidence of their absence.',
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Annotate a synthesised report with the degraded-coverage note, inserted
|
|
59
|
+
* directly beneath the `## Executive Summary` heading (every lens report
|
|
60
|
+
* contract requires that section). A report without the heading is prefixed
|
|
61
|
+
* instead, so the note can never be silently dropped.
|
|
62
|
+
*
|
|
63
|
+
* Returns the report unchanged when nothing failed — a full-coverage run must
|
|
64
|
+
* not carry a coverage caveat.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} report
|
|
67
|
+
* @param {readonly DimensionFailure[]} failures
|
|
68
|
+
* @param {number} totalDimensions
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function withDegradedCoverageNote(report, failures, totalDimensions) {
|
|
72
|
+
if (!Array.isArray(failures) || failures.length === 0) return report;
|
|
73
|
+
const note = formatDegradedCoverageNote(failures, totalDimensions);
|
|
74
|
+
const lines = String(report).split('\n');
|
|
75
|
+
const headingIndex = lines.findIndex((line) =>
|
|
76
|
+
EXECUTIVE_SUMMARY_HEADING.test(line.trim()),
|
|
77
|
+
);
|
|
78
|
+
if (headingIndex === -1) return `${note}\n\n${report}`;
|
|
79
|
+
lines.splice(headingIndex + 1, 0, '', note);
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
@@ -134,16 +134,22 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
|
|
|
134
134
|
*
|
|
135
135
|
* Caller-specific log lines and error text are passed in as the `messages`
|
|
136
136
|
* data bag so behaviour stays byte-identical to the pre-extraction switches.
|
|
137
|
-
* The git seams (`spawn`, `existsLocally`, `existsRemotely`) are
|
|
138
|
-
* each caller can bind its own cwd (and tests can
|
|
137
|
+
* The git seams (`spawn`, `existsLocally`, `existsRemotely`) are injectable so
|
|
138
|
+
* each caller can bind its own cwd (and tests can substitute stubs through the
|
|
139
|
+
* parameter rather than by module mocking). Per
|
|
140
|
+
* `.agents/rules/test-seams.md` rule 1 each seam **defaults to the real
|
|
141
|
+
* implementation** bound to `cwd`, so a caller that only knows its checkout
|
|
142
|
+
* passes `cwd` and nothing else; `single-story-init.js` keeps passing its own
|
|
143
|
+
* pre-bound seams and is unaffected.
|
|
139
144
|
*
|
|
140
145
|
* @param {object} opts
|
|
141
146
|
* @param {string} opts.storyBranch
|
|
142
147
|
* @param {string} opts.baseRef Ref to branch from on `create`.
|
|
148
|
+
* @param {string} [opts.cwd] Checkout the default seams bind to.
|
|
143
149
|
* @param {boolean} [opts.swallowCreateRace=false]
|
|
144
|
-
* @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} opts.spawn
|
|
145
|
-
* @param {(branch: string) => boolean} opts.existsLocally
|
|
146
|
-
* @param {(branch: string) => boolean} opts.existsRemotely
|
|
150
|
+
* @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} [opts.spawn]
|
|
151
|
+
* @param {(branch: string) => boolean} [opts.existsLocally]
|
|
152
|
+
* @param {(branch: string) => boolean} [opts.existsRemotely]
|
|
147
153
|
* @param {(level: string, message: string) => void} [opts.progress]
|
|
148
154
|
* @param {object} opts.messages
|
|
149
155
|
* @param {(b: string) => string} opts.messages.reuse
|
|
@@ -158,10 +164,11 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
|
|
|
158
164
|
export function seedStoryBranchRef({
|
|
159
165
|
storyBranch,
|
|
160
166
|
baseRef,
|
|
167
|
+
cwd,
|
|
161
168
|
swallowCreateRace = false,
|
|
162
|
-
spawn,
|
|
163
|
-
existsLocally,
|
|
164
|
-
existsRemotely,
|
|
169
|
+
spawn = (args) => gitSpawn(cwd, ...args),
|
|
170
|
+
existsLocally = (branch) => branchExistsLocally(branch, cwd),
|
|
171
|
+
existsRemotely = (branch) => branchExistsViaTrackingRef(branch, cwd),
|
|
165
172
|
progress = () => {},
|
|
166
173
|
messages,
|
|
167
174
|
}) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { readBaseFromGit } from '../../../baselines/git-base.js';
|
|
13
13
|
import { getKindModule } from '../../../baselines/kernel.js';
|
|
14
14
|
import { resolveScope } from '../../../baselines/scope.js';
|
|
15
|
+
import { Logger } from '../../../Logger.js';
|
|
15
16
|
import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
|
|
16
17
|
|
|
17
18
|
function baselineRelativePath(kind, gateBlock) {
|
|
@@ -67,6 +68,29 @@ export async function evaluateCompare({ kind, gateBlock, scope, cwd }) {
|
|
|
67
68
|
return { baseRef: scope.ref, baseRead: true, basePayload, kindModule };
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Is the base baseline comparable to the head baseline (Story #4775)?
|
|
73
|
+
*
|
|
74
|
+
* A kind can change its SCORING SEMANTICS — how it derives a row's metric —
|
|
75
|
+
* without moving `kernelVersion`. Across that boundary the same row can carry
|
|
76
|
+
* a different score for reasons that have nothing to do with the branch's
|
|
77
|
+
* changes, so a head-vs-base diff manufactures phantom regressions (and can
|
|
78
|
+
* hide real ones behind them).
|
|
79
|
+
*
|
|
80
|
+
* The head-side stamp is already a fail-closed gate: a stale HEAD baseline
|
|
81
|
+
* never reaches this point. What reaches here is the opposite and legitimate
|
|
82
|
+
* case — a branch that DOES carry a re-derived baseline, compared against a
|
|
83
|
+
* base that predates the change. The only honest verdict is "no comparison";
|
|
84
|
+
* floors still run, so a genuine ceiling breach is still caught, and once the
|
|
85
|
+
* refreshed baseline is the base the ratchet returns to full strength on the
|
|
86
|
+
* very next run without anything to remember to reset.
|
|
87
|
+
*/
|
|
88
|
+
function baseIsComparable(headBaseline, basePayload) {
|
|
89
|
+
const head = headBaseline?.scoringSemantics ?? null;
|
|
90
|
+
const base = basePayload?.scoringSemantics ?? null;
|
|
91
|
+
return head === base;
|
|
92
|
+
}
|
|
93
|
+
|
|
70
94
|
export function runCompareStage(headBaseline, cmp) {
|
|
71
95
|
const empty = {
|
|
72
96
|
regressions: [],
|
|
@@ -75,6 +99,17 @@ export function runCompareStage(headBaseline, cmp) {
|
|
|
75
99
|
additions: [],
|
|
76
100
|
};
|
|
77
101
|
if (!cmp.baseRead || !cmp.basePayload || !cmp.kindModule) return empty;
|
|
102
|
+
if (!baseIsComparable(headBaseline, cmp.basePayload)) {
|
|
103
|
+
Logger.warn(
|
|
104
|
+
`[${cmp.kindModule.name}] ⚠ base baseline was scored under different ` +
|
|
105
|
+
`semantics (base=${cmp.basePayload.scoringSemantics ?? '<unstamped>'} ` +
|
|
106
|
+
`head=${headBaseline?.scoringSemantics ?? '<unstamped>'}); its rows are ` +
|
|
107
|
+
'not comparable, so the head-vs-base compare is skipped for this run. ' +
|
|
108
|
+
'Floors still enforced. The ratchet resumes once the re-derived ' +
|
|
109
|
+
'baseline is the base.',
|
|
110
|
+
);
|
|
111
|
+
return empty;
|
|
112
|
+
}
|
|
78
113
|
try {
|
|
79
114
|
const baseRows = Array.isArray(cmp.basePayload.rows)
|
|
80
115
|
? cmp.basePayload.rows
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '../../../baselines/env-overrides.js';
|
|
14
14
|
import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
|
|
15
15
|
import {
|
|
16
|
+
checkBaselineSemantics,
|
|
16
17
|
checkKernelVersion,
|
|
17
18
|
getKindModule,
|
|
18
19
|
} from '../../../baselines/kernel.js';
|
|
@@ -247,6 +248,18 @@ export async function evaluateKind({
|
|
|
247
248
|
const headLoad = loadHeadBaseline(kind, cwd, configPath);
|
|
248
249
|
if (headLoad.schemaError) return { kind, schemaError: headLoad.schemaError };
|
|
249
250
|
const baseline = headLoad.baseline;
|
|
251
|
+
// Story #4775 — scoring-semantics gate. A baseline whose rows were produced
|
|
252
|
+
// by superseded scoring semantics is structurally valid but semantically
|
|
253
|
+
// incomparable, so schema validation alone would wave it through. Fail
|
|
254
|
+
// closed on the `semantics` tag rather than compare across the boundary;
|
|
255
|
+
// the message names the exact re-baseline command.
|
|
256
|
+
const semanticsError = checkBaselineSemantics(kind, baseline);
|
|
257
|
+
if (semanticsError) {
|
|
258
|
+
return {
|
|
259
|
+
kind,
|
|
260
|
+
schemaError: { tag: 'semantics', message: semanticsError },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
250
263
|
const floorRollup = rollupExcludingIgnored({
|
|
251
264
|
kind,
|
|
252
265
|
baseline,
|