mandrel 2.62.0 → 2.64.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 +0 -1
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +1 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +2 -19
- package/.agents/schemas/baselines/bundle-size.schema.json +0 -24
- package/.agents/schemas/baselines/coverage.schema.json +0 -26
- package/.agents/schemas/baselines/crap.schema.json +0 -28
- package/.agents/schemas/baselines/duplication.schema.json +0 -38
- package/.agents/schemas/baselines/maintainability.schema.json +0 -45
- package/.agents/schemas/baselines/mutation.schema.json +0 -28
- package/.agents/scripts/check-dead-exports.js +1 -1
- package/.agents/scripts/lib/audit-baselines/headroom.js +1 -1
- package/.agents/scripts/lib/audit-baselines/kinds.js +12 -7
- package/.agents/scripts/lib/audit-baselines/rollup.js +41 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +14 -3
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +1 -1
- package/.agents/scripts/lib/baselines/envelope.js +5 -50
- package/.agents/scripts/lib/baselines/merge-envelopes.js +16 -79
- package/.agents/scripts/lib/baselines/reader.js +7 -7
- package/.agents/scripts/lib/baselines/refresh-service.js +2 -7
- package/.agents/scripts/lib/baselines/writer.js +16 -45
- package/.agents/scripts/lib/coverage-baseline.js +0 -2
- package/.agents/scripts/lib/cyclomatic-ceiling.js +6 -18
- package/.agents/scripts/lib/git/sync-from-base.js +1 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +20 -27
- package/.agents/scripts/lib/orchestration/ci-red-handling.js +73 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +56 -11
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +108 -9
- package/.agents/scripts/merge-baseline.js +13 -11
- package/.agents/scripts/pr-watch-with-update.js +13 -19
- package/.agents/workflows/audit-baselines.md +2 -1
- package/docs/CHANGELOG.md +18 -0
- package/lib/cli/registry.js +1 -1
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.63.0-baseline-merge-queue-shape.js +96 -0
- package/package.json +1 -1
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure 3-way merge of baseline envelopes by row identity. A line-based git
|
|
3
|
-
* merge
|
|
4
|
-
* both sides into a row set no scorer produced.
|
|
3
|
+
* merge conflicts on disjoint rows that sit adjacent in sort order, and can
|
|
4
|
+
* splice both sides into a row set no scorer produced.
|
|
5
5
|
*
|
|
6
6
|
* Per identity the side that differs from base wins; both differing is a
|
|
7
|
-
* conflict; absence is a value.
|
|
8
|
-
* merged rows, never merged. Identity comes from the kind's `rowIdentity`,
|
|
7
|
+
* conflict; absence is a value. Identity comes from the kind's `rowIdentity`,
|
|
9
8
|
* never `keyField`. No I/O: the driver validates the result.
|
|
10
9
|
*
|
|
11
10
|
* @module lib/baselines/merge-envelopes
|
|
@@ -15,8 +14,8 @@ import { deepEqual } from '../json-utils.js';
|
|
|
15
14
|
import { KNOWN_KINDS } from './envelope.js';
|
|
16
15
|
import { getKindModule } from './kernel.js';
|
|
17
16
|
|
|
18
|
-
/**
|
|
19
|
-
const
|
|
17
|
+
/** Merged by identity, not as a stamp. */
|
|
18
|
+
const ROW_KEY = 'rows';
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* Kind from `$schema`, or `null` for anything not a known per-kind envelope.
|
|
@@ -76,20 +75,6 @@ function indexRows(rows, rowIdentity, side) {
|
|
|
76
75
|
return out;
|
|
77
76
|
}
|
|
78
77
|
|
|
79
|
-
/**
|
|
80
|
-
* @param {string|undefined} ours
|
|
81
|
-
* @param {string|undefined} theirs
|
|
82
|
-
* @returns {string|undefined}
|
|
83
|
-
*/
|
|
84
|
-
function laterStamp(ours, theirs) {
|
|
85
|
-
if (typeof ours !== 'string') return theirs;
|
|
86
|
-
if (typeof theirs !== 'string') return ours;
|
|
87
|
-
const a = Date.parse(ours);
|
|
88
|
-
const b = Date.parse(theirs);
|
|
89
|
-
if (Number.isNaN(a) || Number.isNaN(b)) return ours > theirs ? ours : theirs;
|
|
90
|
-
return a >= b ? ours : theirs;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
78
|
/**
|
|
94
79
|
* 3-way merge of envelope stamps; a double bump to different values is a
|
|
95
80
|
* real conflict (two scorers).
|
|
@@ -102,7 +87,7 @@ function laterStamp(ours, theirs) {
|
|
|
102
87
|
function mergeStamps(base, ours, theirs) {
|
|
103
88
|
const keys = new Set(
|
|
104
89
|
[...Object.keys(ours), ...Object.keys(theirs), ...Object.keys(base)].filter(
|
|
105
|
-
(k) =>
|
|
90
|
+
(k) => k !== ROW_KEY,
|
|
106
91
|
),
|
|
107
92
|
);
|
|
108
93
|
const merged = {};
|
|
@@ -170,15 +155,13 @@ function mergeRowSets({ baseEnv, ours, theirs, rowIdentity }) {
|
|
|
170
155
|
}
|
|
171
156
|
|
|
172
157
|
/**
|
|
173
|
-
* `base` is `null` when the file was added on both sides.
|
|
174
|
-
* defaults to `[]`, matching what a refresh writes.
|
|
158
|
+
* `base` is `null` when the file was added on both sides.
|
|
175
159
|
*
|
|
176
160
|
* @param {{
|
|
177
161
|
* base: object|null,
|
|
178
162
|
* ours: object,
|
|
179
163
|
* theirs: object,
|
|
180
164
|
* kind?: string,
|
|
181
|
-
* components?: Array<object>,
|
|
182
165
|
* }} params
|
|
183
166
|
* @returns {{
|
|
184
167
|
* kind: string,
|
|
@@ -186,13 +169,7 @@ function mergeRowSets({ baseEnv, ours, theirs, rowIdentity }) {
|
|
|
186
169
|
* conflicts: Array<{scope: string, identity: string, base?: unknown, ours?: unknown, theirs?: unknown}>,
|
|
187
170
|
* }}
|
|
188
171
|
*/
|
|
189
|
-
export function mergeEnvelopes({
|
|
190
|
-
base,
|
|
191
|
-
ours,
|
|
192
|
-
theirs,
|
|
193
|
-
kind,
|
|
194
|
-
components = [],
|
|
195
|
-
} = {}) {
|
|
172
|
+
export function mergeEnvelopes({ base, ours, theirs, kind } = {}) {
|
|
196
173
|
const resolvedKind =
|
|
197
174
|
kind ?? kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
|
|
198
175
|
if (!resolvedKind) {
|
|
@@ -217,12 +194,7 @@ export function mergeEnvelopes({
|
|
|
217
194
|
theirs ?? {},
|
|
218
195
|
);
|
|
219
196
|
|
|
220
|
-
const envelope = {
|
|
221
|
-
...stamps,
|
|
222
|
-
generatedAt: laterStamp(ours?.generatedAt, theirs?.generatedAt),
|
|
223
|
-
rollup: mod.rollup(sortedRows, components),
|
|
224
|
-
rows: sortedRows,
|
|
225
|
-
};
|
|
197
|
+
const envelope = { ...stamps, rows: sortedRows };
|
|
226
198
|
|
|
227
199
|
return {
|
|
228
200
|
kind: resolvedKind,
|
|
@@ -251,41 +223,15 @@ function orderKeys(obj, order) {
|
|
|
251
223
|
}
|
|
252
224
|
|
|
253
225
|
/**
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* and wrongly.
|
|
257
|
-
*
|
|
258
|
-
* @param {Array<object>} rows
|
|
259
|
-
* @returns {{ '*': { filesAboveCeiling: number, methodsAboveCeiling: number, maxCyclomatic: number } }}
|
|
260
|
-
*/
|
|
261
|
-
function cyclomaticRollup(rows) {
|
|
262
|
-
let methods = 0;
|
|
263
|
-
let max = 0;
|
|
264
|
-
for (const row of rows) {
|
|
265
|
-
methods += Number(row?.methodsAboveCeiling ?? 0);
|
|
266
|
-
const rowMax = Number(row?.maxCyclomatic ?? 0);
|
|
267
|
-
if (rowMax > max) max = rowMax;
|
|
268
|
-
}
|
|
269
|
-
return {
|
|
270
|
-
'*': {
|
|
271
|
-
filesAboveCeiling: rows.length,
|
|
272
|
-
methodsAboveCeiling: methods,
|
|
273
|
-
maxCyclomatic: max,
|
|
274
|
-
},
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Row-set baselines that are not per-kind envelopes. `rollup: null` is real:
|
|
280
|
-
* the dead-export generator writes none. `keyOrder` mirrors each generator.
|
|
226
|
+
* Row-set baselines that are not per-kind envelopes. `keyOrder` mirrors each
|
|
227
|
+
* generator.
|
|
281
228
|
*/
|
|
282
229
|
const PLAIN_BASELINE_KINDS = Object.freeze({
|
|
283
230
|
cyclomatic: Object.freeze({
|
|
284
231
|
rowIdentity: (row) => String(row?.file ?? ''),
|
|
285
232
|
sortRows: (rows) =>
|
|
286
233
|
[...rows].sort((a, b) => String(a.file).localeCompare(String(b.file))),
|
|
287
|
-
|
|
288
|
-
keyOrder: ['$schema', 'generatedAt', 'ceiling', 'rollup', 'rows'],
|
|
234
|
+
keyOrder: ['$schema', 'ceiling', 'rows'],
|
|
289
235
|
}),
|
|
290
236
|
'dead-exports': Object.freeze({
|
|
291
237
|
rowIdentity: (row) => `${row?.file ?? ''}::${row?.symbol ?? ''}`,
|
|
@@ -295,8 +241,7 @@ const PLAIN_BASELINE_KINDS = Object.freeze({
|
|
|
295
241
|
String(a.file).localeCompare(String(b.file)) ||
|
|
296
242
|
String(a.symbol).localeCompare(String(b.symbol)),
|
|
297
243
|
),
|
|
298
|
-
|
|
299
|
-
keyOrder: ['$schema', 'kernelVersion', 'generatedAt', 'mode', 'rows'],
|
|
244
|
+
keyOrder: ['$schema', 'kernelVersion', 'mode', 'rows'],
|
|
300
245
|
}),
|
|
301
246
|
});
|
|
302
247
|
|
|
@@ -357,15 +302,7 @@ export function mergePlainBaseline({ base, ours, theirs, kind } = {}) {
|
|
|
357
302
|
ours ?? {},
|
|
358
303
|
theirs ?? {},
|
|
359
304
|
);
|
|
360
|
-
const envelope = orderKeys(
|
|
361
|
-
{
|
|
362
|
-
...stamps,
|
|
363
|
-
generatedAt: laterStamp(ours?.generatedAt, theirs?.generatedAt),
|
|
364
|
-
...(spec.rollup ? { rollup: spec.rollup(sortedRows) } : {}),
|
|
365
|
-
rows: sortedRows,
|
|
366
|
-
},
|
|
367
|
-
spec.keyOrder,
|
|
368
|
-
);
|
|
305
|
+
const envelope = orderKeys({ ...stamps, rows: sortedRows }, spec.keyOrder);
|
|
369
306
|
return {
|
|
370
307
|
kind: resolvedKind,
|
|
371
308
|
envelope,
|
|
@@ -380,8 +317,8 @@ const REGENERATE_COMMANDS = Object.freeze({
|
|
|
380
317
|
});
|
|
381
318
|
|
|
382
319
|
/**
|
|
383
|
-
* After a conflicted merge, hand-
|
|
384
|
-
* the driver must name the regeneration command.
|
|
320
|
+
* After a conflicted merge, hand-resolved rows describe a tree nobody scored,
|
|
321
|
+
* so the driver must name the regeneration command.
|
|
385
322
|
*
|
|
386
323
|
* @param {string} kind
|
|
387
324
|
* @returns {string}
|
|
@@ -130,21 +130,21 @@ function validate(kind, parsed, sourceHint) {
|
|
|
130
130
|
/**
|
|
131
131
|
* The one narrowing projection for every loaded envelope. It is an
|
|
132
132
|
* allow-list: compat axes read stamps off the loaded object and fail closed
|
|
133
|
-
* on `undefined`, so every envelope stamp must be carried through here.
|
|
133
|
+
* on `undefined`, so every envelope stamp must be carried through here. The
|
|
134
|
+
* committed file carries no rollup; a consumer that needs one derives it from
|
|
135
|
+
* `rows` through the kind module.
|
|
134
136
|
*
|
|
135
137
|
* @param {string} kind
|
|
136
138
|
* @param {object} parsed A validated baseline envelope.
|
|
137
|
-
* @returns {{
|
|
139
|
+
* @returns {{ rows: Array<object>, kernelVersion: string }}
|
|
138
140
|
*/
|
|
139
141
|
function shapeEnvelope(kind, parsed) {
|
|
140
142
|
const rows = Array.isArray(parsed.rows)
|
|
141
143
|
? parsed.rows.map((row) => canonicaliseRow(kind, row))
|
|
142
144
|
: [];
|
|
143
145
|
return {
|
|
144
|
-
rollup: parsed.rollup ?? { '*': {} },
|
|
145
146
|
rows,
|
|
146
147
|
kernelVersion: parsed.kernelVersion,
|
|
147
|
-
generatedAt: parsed.generatedAt,
|
|
148
148
|
scoringSemantics: parsed.scoringSemantics,
|
|
149
149
|
tsTranspilerVersion: parsed.tsTranspilerVersion,
|
|
150
150
|
provenanceStamped: parsed.provenanceStamped,
|
|
@@ -154,7 +154,7 @@ function shapeEnvelope(kind, parsed) {
|
|
|
154
154
|
/**
|
|
155
155
|
* @param {string} kind
|
|
156
156
|
* @param {string} absolutePath
|
|
157
|
-
* @returns {{
|
|
157
|
+
* @returns {{ rows: Array<object>, kernelVersion: string }}
|
|
158
158
|
*/
|
|
159
159
|
function readAndShape(kind, absolutePath) {
|
|
160
160
|
let raw;
|
|
@@ -195,7 +195,7 @@ function inferKindFromSchema(schemaValue) {
|
|
|
195
195
|
/**
|
|
196
196
|
* @param {string} kind
|
|
197
197
|
* @param {{ configPath?: string, cwd?: string }} [opts]
|
|
198
|
-
* @returns {{
|
|
198
|
+
* @returns {{ rows: Array<object>, kernelVersion: string }}
|
|
199
199
|
*/
|
|
200
200
|
export function load(kind, opts = {}) {
|
|
201
201
|
if (!Object.hasOwn(KIND_TO_SCHEMA_FILE, kind)) {
|
|
@@ -214,7 +214,7 @@ export function load(kind, opts = {}) {
|
|
|
214
214
|
*
|
|
215
215
|
* @param {string} absolutePath
|
|
216
216
|
* @param {{ kind?: string }} [opts]
|
|
217
|
-
* @returns {{
|
|
217
|
+
* @returns {{ rows: Array<object>, kernelVersion: string }}
|
|
218
218
|
*/
|
|
219
219
|
export function loadFile(absolutePath, opts = {}) {
|
|
220
220
|
if (typeof absolutePath !== 'string' || absolutePath.length === 0) {
|
|
@@ -260,7 +260,6 @@ export function resolveDefaultScorer(kind, { cwd } = {}) {
|
|
|
260
260
|
* fs?: typeof nodeFs,
|
|
261
261
|
* gitDiff?: (args: { baseRef: string, headRef: string, cwd: string }) => Iterable<string> | Promise<Iterable<string>>,
|
|
262
262
|
* cwd?: string,
|
|
263
|
-
* generatedAt?: string,
|
|
264
263
|
* requireRowsForScopeFiles?: boolean,
|
|
265
264
|
* requiredScopeFilePredicate?: (file: string) => boolean,
|
|
266
265
|
* }} opts
|
|
@@ -285,7 +284,6 @@ export async function refreshBaseline(opts = {}) {
|
|
|
285
284
|
fs = nodeFs,
|
|
286
285
|
gitDiff = defaultGitDiff,
|
|
287
286
|
cwd = process.cwd(),
|
|
288
|
-
generatedAt,
|
|
289
287
|
requireRowsForScopeFiles = false,
|
|
290
288
|
requiredScopeFilePredicate,
|
|
291
289
|
} = opts;
|
|
@@ -349,11 +347,10 @@ export async function refreshBaseline(opts = {}) {
|
|
|
349
347
|
scope.mode === 'full'
|
|
350
348
|
? null
|
|
351
349
|
: { mode: 'diff', files: new Set(scope.files) },
|
|
352
|
-
generatedAt: generatedAt ?? process.env.MANDREL_BASELINE_GENERATED_AT,
|
|
353
350
|
});
|
|
354
351
|
|
|
355
352
|
// The writer returns the prior object itself when nothing changed; skip the
|
|
356
|
-
// write so on-disk bytes
|
|
353
|
+
// write so on-disk bytes stay verbatim.
|
|
357
354
|
let wrote = false;
|
|
358
355
|
if (priorEnvelope === null || envelope !== priorEnvelope) {
|
|
359
356
|
writeEnvelopeFile(writePath, envelope, { fsImpl: fs });
|
|
@@ -523,9 +520,7 @@ function readPriorEnvelope(writePath, fs) {
|
|
|
523
520
|
parsed &&
|
|
524
521
|
typeof parsed === 'object' &&
|
|
525
522
|
!Array.isArray(parsed) &&
|
|
526
|
-
Array.isArray(parsed.rows)
|
|
527
|
-
parsed.rollup &&
|
|
528
|
-
typeof parsed.rollup === 'object'
|
|
523
|
+
Array.isArray(parsed.rows)
|
|
529
524
|
) {
|
|
530
525
|
return parsed;
|
|
531
526
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared baseline writer, the single funnel for envelope assembly
|
|
3
|
-
* (`write`: project, canonical-assert, merge, stabilise, sort,
|
|
4
|
-
* validate) and serialisation (`writeFile`: atomic tmp + rename).
|
|
3
|
+
* (`write`: project, canonical-assert, merge, stabilise, sort, stamp, validate) and serialisation (`writeFile`: atomic tmp + rename).
|
|
5
4
|
*
|
|
6
5
|
* @module lib/baselines/writer
|
|
7
6
|
*/
|
|
@@ -16,16 +15,14 @@ import { assertCanonical } from './path-canon.js';
|
|
|
16
15
|
/**
|
|
17
16
|
* Assemble and validate an envelope (no I/O). With `scope`, out-of-scope
|
|
18
17
|
* `prior` rows are preserved first; with `epsilon`, sub-epsilon deltas then
|
|
19
|
-
* resolve to prior bytes. `prior` rows must already be canonical. When
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* resolve to prior bytes. `prior` rows must already be canonical. When the
|
|
19
|
+
* rows deep-equal the prior envelope's, the prior is returned unchanged so a
|
|
20
|
+
* no-op refresh leaves no diff.
|
|
22
21
|
*
|
|
23
22
|
* @param {{
|
|
24
23
|
* kind: string,
|
|
25
24
|
* rows: Array<object>,
|
|
26
|
-
* components?: Array<object>,
|
|
27
25
|
* kernelVersion?: string,
|
|
28
|
-
* generatedAt?: string,
|
|
29
26
|
* prior?: Array<object>,
|
|
30
27
|
* priorEnvelope?: object,
|
|
31
28
|
* epsilon?: number,
|
|
@@ -36,9 +33,7 @@ import { assertCanonical } from './path-canon.js';
|
|
|
36
33
|
export function write({
|
|
37
34
|
kind,
|
|
38
35
|
rows,
|
|
39
|
-
components,
|
|
40
36
|
kernelVersion,
|
|
41
|
-
generatedAt,
|
|
42
37
|
prior,
|
|
43
38
|
priorEnvelope,
|
|
44
39
|
epsilon,
|
|
@@ -79,20 +74,9 @@ export function write({
|
|
|
79
74
|
const stabilised = stabiliseRows(mod, merged, prior, epsilon);
|
|
80
75
|
|
|
81
76
|
const sortedRows = mod.sortRows(stabilised);
|
|
82
|
-
const rollup = mod.rollup(sortedRows, components ?? []);
|
|
83
|
-
|
|
84
|
-
if (!Object.hasOwn(rollup, '*')) {
|
|
85
|
-
throw new Error(
|
|
86
|
-
`writer.write: ${kind} rollup is missing the required "*" key`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
77
|
|
|
90
78
|
const priorEnv = resolvePriorEnvelope(priorEnvelope, prior);
|
|
91
|
-
if (
|
|
92
|
-
priorEnv &&
|
|
93
|
-
deepEqual(sortedRows, priorEnv.rows) &&
|
|
94
|
-
deepEqual(rollup, priorEnv.rollup)
|
|
95
|
-
) {
|
|
79
|
+
if (priorEnv && deepEqual(sortedRows, priorEnv.rows)) {
|
|
96
80
|
// No smuggling an invalid envelope through the short-circuit.
|
|
97
81
|
assertEnvelope(priorEnv);
|
|
98
82
|
return priorEnv;
|
|
@@ -101,9 +85,7 @@ export function write({
|
|
|
101
85
|
const envelope = buildEnvelope({
|
|
102
86
|
kind,
|
|
103
87
|
rows: sortedRows,
|
|
104
|
-
rollup,
|
|
105
88
|
kernelVersion: kernelVersion ?? currentKernelVersion(kind),
|
|
106
|
-
generatedAt,
|
|
107
89
|
extras:
|
|
108
90
|
typeof mod.envelopeExtras === 'function' ? mod.envelopeExtras() : null,
|
|
109
91
|
});
|
|
@@ -113,29 +95,20 @@ export function write({
|
|
|
113
95
|
|
|
114
96
|
/** `priorEnvelope`, else `prior` when it is a whole envelope, else null. */
|
|
115
97
|
function resolvePriorEnvelope(priorEnvelope, prior) {
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
typeof priorEnvelope === 'object' &&
|
|
119
|
-
!Array.isArray(priorEnvelope) &&
|
|
120
|
-
Array.isArray(priorEnvelope.rows) &&
|
|
121
|
-
priorEnvelope.rollup &&
|
|
122
|
-
typeof priorEnvelope.rollup === 'object'
|
|
123
|
-
) {
|
|
124
|
-
return priorEnvelope;
|
|
125
|
-
}
|
|
126
|
-
if (
|
|
127
|
-
prior &&
|
|
128
|
-
typeof prior === 'object' &&
|
|
129
|
-
!Array.isArray(prior) &&
|
|
130
|
-
Array.isArray(prior.rows) &&
|
|
131
|
-
prior.rollup &&
|
|
132
|
-
typeof prior.rollup === 'object'
|
|
133
|
-
) {
|
|
134
|
-
return prior;
|
|
135
|
-
}
|
|
98
|
+
if (isEnvelopeObject(priorEnvelope)) return priorEnvelope;
|
|
99
|
+
if (isEnvelopeObject(prior)) return prior;
|
|
136
100
|
return null;
|
|
137
101
|
}
|
|
138
102
|
|
|
103
|
+
function isEnvelopeObject(value) {
|
|
104
|
+
return (
|
|
105
|
+
Boolean(value) &&
|
|
106
|
+
typeof value === 'object' &&
|
|
107
|
+
!Array.isArray(value) &&
|
|
108
|
+
Array.isArray(value.rows)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
139
112
|
/**
|
|
140
113
|
* Serialise deterministically (fixed top-level key order, two-space indent,
|
|
141
114
|
* trailing newline) and atomically (tmp + rename).
|
|
@@ -161,11 +134,9 @@ export function writeFile(absPath, envelope, opts = {}) {
|
|
|
161
134
|
const canonical = {
|
|
162
135
|
$schema: envelope.$schema,
|
|
163
136
|
kernelVersion: envelope.kernelVersion,
|
|
164
|
-
generatedAt: envelope.generatedAt,
|
|
165
137
|
scoringSemantics: envelope.scoringSemantics,
|
|
166
138
|
tsTranspilerVersion: envelope.tsTranspilerVersion,
|
|
167
139
|
provenanceStamped: envelope.provenanceStamped,
|
|
168
|
-
rollup: envelope.rollup,
|
|
169
140
|
rows: envelope.rows,
|
|
170
141
|
};
|
|
171
142
|
|
|
@@ -156,8 +156,6 @@ function writeEnvelopeViaFsImpl(abs, envelope, fsImpl) {
|
|
|
156
156
|
const canonical = {
|
|
157
157
|
$schema: envelope.$schema,
|
|
158
158
|
kernelVersion: envelope.kernelVersion,
|
|
159
|
-
generatedAt: envelope.generatedAt,
|
|
160
|
-
rollup: envelope.rollup,
|
|
161
159
|
rows: envelope.rows,
|
|
162
160
|
};
|
|
163
161
|
fsImpl.writeFileSync(abs, `${JSON.stringify(canonical, null, 2)}\n`);
|
|
@@ -163,29 +163,17 @@ export function diffCyclomaticRows(baselineRows, currentRows) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
|
-
*
|
|
166
|
+
* No stamp and no rollup: both would rewrite on every refresh and conflict
|
|
167
|
+
* textually wherever the merge driver does not run.
|
|
168
|
+
*
|
|
169
|
+
* @param {{ rows: Array<object>, ceiling: number }} args
|
|
167
170
|
* @returns {object}
|
|
168
171
|
*/
|
|
169
|
-
export function buildCyclomaticEnvelope({ rows, ceiling
|
|
170
|
-
const safeRows = rows ?? [];
|
|
171
|
-
let methods = 0;
|
|
172
|
-
let max = 0;
|
|
173
|
-
for (const row of safeRows) {
|
|
174
|
-
methods += Number(row.methodsAboveCeiling ?? 0);
|
|
175
|
-
if (Number(row.maxCyclomatic ?? 0) > max) max = Number(row.maxCyclomatic);
|
|
176
|
-
}
|
|
172
|
+
export function buildCyclomaticEnvelope({ rows, ceiling }) {
|
|
177
173
|
return {
|
|
178
174
|
$schema: CYCLOMATIC_BASELINE_SCHEMA,
|
|
179
|
-
generatedAt: generatedAt ?? new Date().toISOString(),
|
|
180
175
|
ceiling,
|
|
181
|
-
|
|
182
|
-
'*': {
|
|
183
|
-
filesAboveCeiling: safeRows.length,
|
|
184
|
-
methodsAboveCeiling: methods,
|
|
185
|
-
maxCyclomatic: max,
|
|
186
|
-
},
|
|
187
|
-
},
|
|
188
|
-
rows: safeRows,
|
|
176
|
+
rows: rows ?? [],
|
|
189
177
|
};
|
|
190
178
|
}
|
|
191
179
|
|
|
@@ -38,7 +38,7 @@ function refuseWithoutMergeDriver(cwd, gitSpawn) {
|
|
|
38
38
|
`.gitattributes routes baselines/*.json through the mandrel-baseline merge ` +
|
|
39
39
|
`driver, but ${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset in this clone. ` +
|
|
40
40
|
'Merging origin now would text-merge generated baselines, which conflicts on ' +
|
|
41
|
-
'
|
|
41
|
+
'adjacent rows and can splice rows neither branch scored. Register the ' +
|
|
42
42
|
`driver, then re-run:\n ${BASELINE_MERGE_DRIVER_REMEDY}\n` +
|
|
43
43
|
' (or: npm run baselines:merge-driver)',
|
|
44
44
|
remedy: BASELINE_MERGE_DRIVER_REMEDY,
|
|
@@ -17,34 +17,28 @@ import { applyFloors, flattenBreaches } from './floors.js';
|
|
|
17
17
|
import { applyRefreshAcknowledgment } from './refresh-ack.js';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* drops them
|
|
23
|
-
*
|
|
20
|
+
* The floor rollup, derived from the rows (the committed file carries none).
|
|
21
|
+
* `ignoreGlobs` rows are dropped first in case a baseline was poisoned by some
|
|
22
|
+
* route other than generation, which already drops them; an ignored file
|
|
23
|
+
* could otherwise breach a global floor.
|
|
24
24
|
*
|
|
25
|
-
* @param {{ kind: string, baseline: {
|
|
25
|
+
* @param {{ kind: string, baseline: { rows?: object[] }, ignoreGlobs?: string[], cwd?: string }} args
|
|
26
26
|
* @returns {object}
|
|
27
27
|
*/
|
|
28
|
-
function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const p = row?.path;
|
|
43
|
-
return typeof p !== 'string' || !isIgnoredByGlobs(p, ignoreGlobs, cwd);
|
|
44
|
-
});
|
|
45
|
-
if (kept.length === rows.length) return rollup;
|
|
46
|
-
const recomputed = mod.rollup(kept);
|
|
47
|
-
return { ...rollup, '*': recomputed?.['*'] ?? rollup?.['*'] };
|
|
28
|
+
function floorRollupOf({ kind, baseline, ignoreGlobs, cwd }) {
|
|
29
|
+
const mod = getKindModule(kind);
|
|
30
|
+
const rows = Array.isArray(baseline?.rows) ? baseline.rows : [];
|
|
31
|
+
const filterIgnored =
|
|
32
|
+
mod.keyField === 'path' &&
|
|
33
|
+
Array.isArray(ignoreGlobs) &&
|
|
34
|
+
ignoreGlobs.length > 0;
|
|
35
|
+
const kept = filterIgnored
|
|
36
|
+
? rows.filter((row) => {
|
|
37
|
+
const p = row?.path;
|
|
38
|
+
return typeof p !== 'string' || !isIgnoredByGlobs(p, ignoreGlobs, cwd);
|
|
39
|
+
})
|
|
40
|
+
: rows;
|
|
41
|
+
return mod.rollup(kept);
|
|
48
42
|
}
|
|
49
43
|
|
|
50
44
|
function loadHeadBaseline(kind, cwd, configPath) {
|
|
@@ -87,7 +81,6 @@ function buildGateReport({
|
|
|
87
81
|
baseRef: cmp.baseRef ?? null,
|
|
88
82
|
// Without this a compare arm that never ran looks identical to a clean run.
|
|
89
83
|
baseRead: cmp.baseRead === true,
|
|
90
|
-
generatedAt: baseline.generatedAt,
|
|
91
84
|
acknowledged: ack.acknowledged,
|
|
92
85
|
// Acknowledgment can be partial; name which regressions it cleared.
|
|
93
86
|
acknowledgedKeys: ack.acknowledgedKeys,
|
|
@@ -114,7 +107,7 @@ export async function evaluateKind({
|
|
|
114
107
|
schemaError: { tag: 'semantics', message: semanticsError },
|
|
115
108
|
};
|
|
116
109
|
}
|
|
117
|
-
const floorRollup =
|
|
110
|
+
const floorRollup = floorRollupOf({
|
|
118
111
|
kind,
|
|
119
112
|
baseline,
|
|
120
113
|
ignoreGlobs: gateBlock.ignoreGlobs,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// .agents/scripts/lib/orchestration/ci-red-handling.js
|
|
2
|
+
/**
|
|
3
|
+
* ci-red-handling.js — the first-red half of the no-rerun guard in
|
|
4
|
+
* `rules/ci-remediation.md` § Verifier, shared by every path that observes a
|
|
5
|
+
* red required check: the watcher (`pr-watch-with-update.js`) and the
|
|
6
|
+
* close-and-land merge wait's `checks-failed` fail-fast. The digest itself
|
|
7
|
+
* lives in `ci-rerun-guard.js`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Logger } from '../Logger.js';
|
|
11
|
+
import {
|
|
12
|
+
resolveDigestScope,
|
|
13
|
+
resolvePrHeadSha,
|
|
14
|
+
writeCiDigest,
|
|
15
|
+
} from './ci-rerun-guard.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The first-red handling every required-check red goes through — the
|
|
19
|
+
* watcher's red path and the close's `checks-failed` fail-fast alike: disarm
|
|
20
|
+
* auto-merge FIRST (the race-free moment), then write the digest keyed to the
|
|
21
|
+
* red head SHA. One implementation, so the guard cannot drift per path.
|
|
22
|
+
* Never throws: a digest-write failure is returned as `digestError`.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {number|string|null} [opts.storyId]
|
|
26
|
+
* @param {number} opts.prNumber
|
|
27
|
+
* @param {string} opts.prRef
|
|
28
|
+
* @param {Array<{name:string, outcome:string}>} opts.failures
|
|
29
|
+
* @param {string} opts.tempRoot
|
|
30
|
+
* @param {string} opts.cwd
|
|
31
|
+
* @param {(args: { prRef: string }) => Promise<{ disarmed: boolean, alreadyUnarmed?: boolean, detail: string }>} opts.disarmFn
|
|
32
|
+
* @param {string|null} [opts.headSha] Already-observed head SHA; probed when absent.
|
|
33
|
+
* @param {Function} [opts.headShaFn]
|
|
34
|
+
* @param {Function} [opts.writeDigestFn]
|
|
35
|
+
* @param {object} [opts.logger]
|
|
36
|
+
* @returns {Promise<{ headSha: string|null, disarm: object, digestPaths: { jsonPath: string, mdPath: string }|null, digestError: string|null }>}
|
|
37
|
+
*/
|
|
38
|
+
export async function recordRequiredRed({
|
|
39
|
+
storyId = null,
|
|
40
|
+
prNumber,
|
|
41
|
+
prRef,
|
|
42
|
+
failures,
|
|
43
|
+
tempRoot,
|
|
44
|
+
cwd,
|
|
45
|
+
disarmFn,
|
|
46
|
+
headSha = null,
|
|
47
|
+
headShaFn = resolvePrHeadSha,
|
|
48
|
+
writeDigestFn = writeCiDigest,
|
|
49
|
+
logger = Logger,
|
|
50
|
+
}) {
|
|
51
|
+
const disarm = await disarmFn({ prRef });
|
|
52
|
+
const scope = resolveDigestScope({ storyId });
|
|
53
|
+
const redHeadSha = scope ? (headSha ?? headShaFn({ prRef, cwd })) : null;
|
|
54
|
+
let digestPaths = null;
|
|
55
|
+
let digestError = null;
|
|
56
|
+
try {
|
|
57
|
+
digestPaths = writeDigestFn({
|
|
58
|
+
storyId,
|
|
59
|
+
prNumber,
|
|
60
|
+
headSha: redHeadSha,
|
|
61
|
+
failures,
|
|
62
|
+
tempRoot,
|
|
63
|
+
cwd,
|
|
64
|
+
prRef,
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
digestError = String(err?.message ?? err);
|
|
68
|
+
logger?.warn?.(
|
|
69
|
+
`[ci-rerun-guard] failed to write CI digest (non-fatal): ${digestError}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return { headSha: redHeadSha, disarm, digestPaths, digestError };
|
|
73
|
+
}
|