mandrel 2.16.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/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-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/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/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
- 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-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/README.md +14 -6
- package/docs/CHANGELOG.md +24 -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
|
@@ -133,6 +133,7 @@ function resolveGeneratedAt(explicit) {
|
|
|
133
133
|
* rows: Array<object>,
|
|
134
134
|
* kernelVersion: string,
|
|
135
135
|
* generatedAt?: string,
|
|
136
|
+
* extras?: Record<string, unknown>,
|
|
136
137
|
* }} params
|
|
137
138
|
* @returns {{
|
|
138
139
|
* $schema: string,
|
|
@@ -148,6 +149,7 @@ export function buildEnvelope({
|
|
|
148
149
|
rows,
|
|
149
150
|
kernelVersion,
|
|
150
151
|
generatedAt,
|
|
152
|
+
extras,
|
|
151
153
|
} = {}) {
|
|
152
154
|
if (typeof kind !== 'string' || !KNOWN_KINDS.includes(kind)) {
|
|
153
155
|
throw new TypeError(
|
|
@@ -176,10 +178,15 @@ export function buildEnvelope({
|
|
|
176
178
|
throw new TypeError('envelope.buildEnvelope: rows must be an array');
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
// Per-kind envelope-level stamps (Story #4775). A kind whose SCORING
|
|
182
|
+
// SEMANTICS can change independently of its kernel version contributes them
|
|
183
|
+
// here; `assertEnvelope` still validates the result against the kind's
|
|
184
|
+
// schema, so an unrecognised extra fails closed rather than being persisted.
|
|
179
185
|
return {
|
|
180
186
|
$schema: schemaRefFor(kind),
|
|
181
187
|
kernelVersion,
|
|
182
188
|
generatedAt: resolveGeneratedAt(generatedAt),
|
|
189
|
+
...(extras && typeof extras === 'object' ? extras : {}),
|
|
183
190
|
rollup,
|
|
184
191
|
rows,
|
|
185
192
|
};
|
|
@@ -50,7 +50,9 @@ import {
|
|
|
50
50
|
} from './kinds/coverage.js';
|
|
51
51
|
import {
|
|
52
52
|
applyEpsilon as crapApplyEpsilon,
|
|
53
|
+
assertBaselineCompatible as crapAssertBaselineCompatible,
|
|
53
54
|
compare as crapCompare,
|
|
55
|
+
envelopeExtras as crapEnvelopeExtras,
|
|
54
56
|
kernelVersion as crapKernelVersion,
|
|
55
57
|
keyField as crapKeyField,
|
|
56
58
|
mergeRows as crapMergeRows,
|
|
@@ -139,6 +141,12 @@ function bindKindModule(members) {
|
|
|
139
141
|
compare: members.compare,
|
|
140
142
|
applyEpsilon: members.applyEpsilon,
|
|
141
143
|
mergeRows: members.mergeRows,
|
|
144
|
+
// Optional per-kind hooks (Story #4775). `envelopeExtras` contributes
|
|
145
|
+
// envelope-level stamps the shared writer would not otherwise know about;
|
|
146
|
+
// `assertBaselineCompatible` lets a kind refuse a loaded baseline whose
|
|
147
|
+
// scoring semantics predate the running scorer.
|
|
148
|
+
envelopeExtras: members.envelopeExtras,
|
|
149
|
+
assertBaselineCompatible: members.assertBaselineCompatible,
|
|
142
150
|
});
|
|
143
151
|
}
|
|
144
152
|
|
|
@@ -179,6 +187,8 @@ const KIND_MODULES = Object.freeze({
|
|
|
179
187
|
compare: crapCompare,
|
|
180
188
|
applyEpsilon: crapApplyEpsilon,
|
|
181
189
|
mergeRows: crapMergeRows,
|
|
190
|
+
envelopeExtras: crapEnvelopeExtras,
|
|
191
|
+
assertBaselineCompatible: crapAssertBaselineCompatible,
|
|
182
192
|
}),
|
|
183
193
|
maintainability: bindKindModule({
|
|
184
194
|
name: maintainabilityName,
|
|
@@ -270,6 +280,27 @@ export function currentKernelVersion(kind) {
|
|
|
270
280
|
return getKindModule(kind).kernelVersion();
|
|
271
281
|
}
|
|
272
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Ask a kind whether a loaded baseline is compatible with the running
|
|
285
|
+
* scorer's SEMANTICS — a dimension `kernelVersion` cannot express, because a
|
|
286
|
+
* kind's scoring can change while the upstream package it stamps does not
|
|
287
|
+
* (Story #4775). Kinds without the hook always answer "compatible".
|
|
288
|
+
*
|
|
289
|
+
* @param {string} kind
|
|
290
|
+
* @param {object|null} baseline
|
|
291
|
+
* @returns {string|null} Operator-facing message, or null when compatible.
|
|
292
|
+
*/
|
|
293
|
+
export function checkBaselineSemantics(kind, baseline) {
|
|
294
|
+
let mod;
|
|
295
|
+
try {
|
|
296
|
+
mod = getKindModule(kind);
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
if (typeof mod.assertBaselineCompatible !== 'function') return null;
|
|
301
|
+
return mod.assertBaselineCompatible(baseline);
|
|
302
|
+
}
|
|
303
|
+
|
|
273
304
|
/**
|
|
274
305
|
* Compare a baseline's stamped version against the currently running
|
|
275
306
|
* kernel for the same kind. Returns `{ match, current }` so callers can
|
|
@@ -75,6 +75,41 @@ export function kernelVersion() {
|
|
|
75
75
|
return '0.0.0';
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Scoring-semantics stamp (Story #4775, fix part 5).
|
|
80
|
+
*
|
|
81
|
+
* `kernelVersion()` above tracks the `typhonjs-escomplex` package and
|
|
82
|
+
* `escomplexVersion` tracks the same dependency — so a change in how THIS
|
|
83
|
+
* repo joins escomplex methods to istanbul coverage moves neither. Rows
|
|
84
|
+
* scored by the pre-#4775 join (exact transpiled-line equality, methods
|
|
85
|
+
* dropped when unresolved) are not comparable to rows scored by the join
|
|
86
|
+
* that replaced it (original-source coordinates, containment matching,
|
|
87
|
+
* honest `requireCoverage: false`): the same method can carry a different
|
|
88
|
+
* `crap`, a different `startLine`, or exist in one baseline and not the
|
|
89
|
+
* other. Comparing across that boundary produces phantom regressions and,
|
|
90
|
+
* worse, phantom passes.
|
|
91
|
+
*
|
|
92
|
+
* The stamp makes the boundary explicit and fails closed. Bump it whenever
|
|
93
|
+
* the coverage join, the line coordinate system, or the unresolved-method
|
|
94
|
+
* policy changes.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately module-local: `envelopeExtras()` is the single production door
|
|
97
|
+
* to this value, so exporting the bare constant would add a second entry
|
|
98
|
+
* point that nothing in production reaches. Callers and tests that need the
|
|
99
|
+
* string read it off `envelopeExtras().scoringSemantics`.
|
|
100
|
+
*/
|
|
101
|
+
const SCORING_SEMANTICS = 'coverage-join-v2';
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Envelope-level stamps this kind contributes beyond the shared envelope
|
|
105
|
+
* keys. Consumed by `writer.write` via the kind-module protocol.
|
|
106
|
+
*
|
|
107
|
+
* @returns {{scoringSemantics: string}}
|
|
108
|
+
*/
|
|
109
|
+
export function envelopeExtras() {
|
|
110
|
+
return { scoringSemantics: SCORING_SEMANTICS };
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
export function projectRow(row) {
|
|
79
114
|
return {
|
|
80
115
|
path: canonicalise(row.path ?? row.file),
|
|
@@ -372,6 +407,23 @@ export const CRAP_COMPAT_AXES = [
|
|
|
372
407
|
: null,
|
|
373
408
|
},
|
|
374
409
|
kernelDriftAxis('CRAP'),
|
|
410
|
+
{
|
|
411
|
+
name: 'scoring-semantics-drift',
|
|
412
|
+
severity: 'fatal',
|
|
413
|
+
check: ({ baseline }) => {
|
|
414
|
+
if (!baseline) return null;
|
|
415
|
+
const stamped = baseline.scoringSemantics ?? null;
|
|
416
|
+
if (stamped === SCORING_SEMANTICS) return null;
|
|
417
|
+
return (
|
|
418
|
+
`[CRAP] scoring semantics changed: baseline=${stamped ?? '<unstamped>'} ` +
|
|
419
|
+
`running=${SCORING_SEMANTICS}. Rows scored by the previous per-method ` +
|
|
420
|
+
'coverage join are not comparable to rows scored by the current one, ' +
|
|
421
|
+
'so this baseline cannot be compared — it must be re-derived. Run ' +
|
|
422
|
+
"'npm run test:coverage' then 'npm run crap:update -- --full-scope' " +
|
|
423
|
+
"and commit the result with a 'baseline-refresh:' subject."
|
|
424
|
+
);
|
|
425
|
+
},
|
|
426
|
+
},
|
|
375
427
|
{
|
|
376
428
|
name: 'ts-transpiler-drift',
|
|
377
429
|
severity: 'warn',
|
|
@@ -401,6 +453,30 @@ export function evaluateBaselineCompatibility(ctx) {
|
|
|
401
453
|
return reduceCompatAxes(CRAP_COMPAT_AXES, ctx);
|
|
402
454
|
}
|
|
403
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Kind-module hook (Story #4775): the subset of the compat table that a
|
|
458
|
+
* *loaded* v2 envelope can be judged against on its own, with no running
|
|
459
|
+
* dependency versions to compare. The unified `check-baselines` gate calls it
|
|
460
|
+
* straight after `reader.load` and turns a message into a fail-closed
|
|
461
|
+
* schema-class error, so a baseline written by the previous scoring semantics
|
|
462
|
+
* can never be silently compared against new-semantics scores.
|
|
463
|
+
*
|
|
464
|
+
* The version-drift axes stay out: the v2 envelope does not carry
|
|
465
|
+
* `escomplexVersion` / `tsTranspilerVersion`, and running those checks against
|
|
466
|
+
* an absent field would compare `undefined` to `undefined` and pass
|
|
467
|
+
* vacuously — worse than not running them.
|
|
468
|
+
*
|
|
469
|
+
* @param {object|null} baseline A loaded v2 baseline envelope.
|
|
470
|
+
* @returns {string|null} Operator-facing message, or null when compatible.
|
|
471
|
+
*/
|
|
472
|
+
export function assertBaselineCompatible(baseline) {
|
|
473
|
+
if (!baseline) return null;
|
|
474
|
+
const axis = CRAP_COMPAT_AXES.find(
|
|
475
|
+
(a) => a.name === 'scoring-semantics-drift',
|
|
476
|
+
);
|
|
477
|
+
return axis ? axis.check({ baseline }) : null;
|
|
478
|
+
}
|
|
479
|
+
|
|
404
480
|
/**
|
|
405
481
|
* Pure helper: resolve the CRAP baseline either from the working tree
|
|
406
482
|
* (via `getCrapBaseline`) or, when `epicRef` is supplied, from
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
// hand-edited while inside a story worktree — so downstream
|
|
19
19
|
// consumers see canonical repo-relative paths.
|
|
20
20
|
// 5. Returns the envelope's headline fields plus rows/rollup as a
|
|
21
|
-
// narrow contract: `{ rollup, rows, kernelVersion, generatedAt
|
|
21
|
+
// narrow contract: `{ rollup, rows, kernelVersion, generatedAt,
|
|
22
|
+
// scoringSemantics }`.
|
|
22
23
|
//
|
|
23
24
|
// Reader-only: the writer side lives in a sibling module (Story #1891).
|
|
24
25
|
// No I/O happens here beyond reading the JSON file itself.
|
|
@@ -215,6 +216,11 @@ function readAndShape(kind, absolutePath) {
|
|
|
215
216
|
rows,
|
|
216
217
|
kernelVersion: parsed.kernelVersion,
|
|
217
218
|
generatedAt: parsed.generatedAt,
|
|
219
|
+
// Story #4775 — carry the per-kind scoring-semantics stamp through the
|
|
220
|
+
// narrowing. The gate's compat check reads it off the LOADED envelope, so
|
|
221
|
+
// dropping it here would make every baseline look unstamped and fail the
|
|
222
|
+
// whole repo closed on a stamp that is actually present on disk.
|
|
223
|
+
scoringSemantics: parsed.scoringSemantics,
|
|
218
224
|
};
|
|
219
225
|
}
|
|
220
226
|
|
|
@@ -303,6 +309,11 @@ export function loadFile(absolutePath, opts = {}) {
|
|
|
303
309
|
rows,
|
|
304
310
|
kernelVersion: parsed.kernelVersion,
|
|
305
311
|
generatedAt: parsed.generatedAt,
|
|
312
|
+
// Story #4775 — carry the per-kind scoring-semantics stamp through the
|
|
313
|
+
// narrowing. The gate's compat check reads it off the LOADED envelope, so
|
|
314
|
+
// dropping it here would make every baseline look unstamped and fail the
|
|
315
|
+
// whole repo closed on a stamp that is actually present on disk.
|
|
316
|
+
scoringSemantics: parsed.scoringSemantics,
|
|
306
317
|
};
|
|
307
318
|
}
|
|
308
319
|
|
|
@@ -355,11 +355,17 @@ const KIND_SCORER_BUILDERS = Object.freeze({
|
|
|
355
355
|
* rather than crashing the refresh. The production crap/maintainability paths
|
|
356
356
|
* never rely on this fallback — they inject an explicit, configured scorer.
|
|
357
357
|
*
|
|
358
|
+
* Exported since Story #4776 so the full-scope drift detector
|
|
359
|
+
* (`check-baseline-drift.js`) re-scores through the *same* scorer that
|
|
360
|
+
* writes the baseline. A drift check scoring by a second, parallel
|
|
361
|
+
* implementation would report the two implementations' disagreement as
|
|
362
|
+
* drift, which is exactly the false signal it exists to rule out.
|
|
363
|
+
*
|
|
358
364
|
* @param {string} kind
|
|
359
365
|
* @param {{ cwd: string }} opts
|
|
360
366
|
* @returns {((files: string[], opts: object) => Promise<object[]> | object[]) | undefined}
|
|
361
367
|
*/
|
|
362
|
-
function resolveDefaultScorer(kind, { cwd } = {}) {
|
|
368
|
+
export function resolveDefaultScorer(kind, { cwd } = {}) {
|
|
363
369
|
const builder = KIND_SCORER_BUILDERS[kind];
|
|
364
370
|
if (typeof builder !== 'function') return undefined;
|
|
365
371
|
const effectiveCwd = cwd ?? process.cwd();
|
|
@@ -184,6 +184,8 @@ export function write({
|
|
|
184
184
|
rollup,
|
|
185
185
|
kernelVersion: kernelVersion ?? currentKernelVersion(kind),
|
|
186
186
|
generatedAt,
|
|
187
|
+
extras:
|
|
188
|
+
typeof mod.envelopeExtras === 'function' ? mod.envelopeExtras() : null,
|
|
187
189
|
});
|
|
188
190
|
assertEnvelope(envelope);
|
|
189
191
|
return envelope;
|
|
@@ -258,10 +260,18 @@ export function writeFile(absPath, envelope, opts = {}) {
|
|
|
258
260
|
// Canonical key order on the top-level envelope keeps diffs stable
|
|
259
261
|
// across runs and platforms. Per-kind row keys retain their natural
|
|
260
262
|
// declaration order; the row sort is done by `sortRows()`.
|
|
263
|
+
//
|
|
264
|
+
// Story #4775: the projection is deliberately explicit, so any per-kind
|
|
265
|
+
// envelope stamp (`scoringSemantics`) must be carried through by name or it
|
|
266
|
+
// is silently dropped on the way to disk — the stamp would then be present
|
|
267
|
+
// in memory, validated, and absent from the file it exists to protect.
|
|
261
268
|
const canonical = {
|
|
262
269
|
$schema: envelope.$schema,
|
|
263
270
|
kernelVersion: envelope.kernelVersion,
|
|
264
271
|
generatedAt: envelope.generatedAt,
|
|
272
|
+
...(envelope.scoringSemantics === undefined
|
|
273
|
+
? {}
|
|
274
|
+
: { scoringSemantics: envelope.scoringSemantics }),
|
|
265
275
|
rollup: envelope.rollup,
|
|
266
276
|
rows: envelope.rows,
|
|
267
277
|
};
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* fan-out target, or `&` shell backgrounding) within a small line window.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import
|
|
29
|
+
import nodeFs from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
|
|
32
32
|
const SCAN_ROOT_DEFAULT = '.agents';
|
|
@@ -54,14 +54,20 @@ const BACKGROUND_TOKENS = [
|
|
|
54
54
|
* and `.md` sources. Skips `node_modules`, `.worktrees`, and directories
|
|
55
55
|
* starting with `.git`.
|
|
56
56
|
*
|
|
57
|
+
* The optional final `fsImpl` parameter defaults to the real `node:fs`
|
|
58
|
+
* (`.agents/rules/test-seams.md` rule 1) and is forwarded to the recursive
|
|
59
|
+
* call rather than re-acquired there (rule 4), so a test drives the whole walk
|
|
60
|
+
* through a plain stub object instead of module mocking (rule 5).
|
|
61
|
+
*
|
|
57
62
|
* @param {string} dir
|
|
63
|
+
* @param {typeof nodeFs} [fsImpl]
|
|
58
64
|
* @returns {string[]}
|
|
59
65
|
*/
|
|
60
|
-
function walkSources(dir) {
|
|
66
|
+
export function walkSources(dir, fsImpl = nodeFs) {
|
|
61
67
|
const out = [];
|
|
62
68
|
let entries;
|
|
63
69
|
try {
|
|
64
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
70
|
+
entries = fsImpl.readdirSync(dir, { withFileTypes: true });
|
|
65
71
|
} catch {
|
|
66
72
|
return out;
|
|
67
73
|
}
|
|
@@ -75,7 +81,7 @@ function walkSources(dir) {
|
|
|
75
81
|
) {
|
|
76
82
|
continue;
|
|
77
83
|
}
|
|
78
|
-
out.push(...walkSources(full));
|
|
84
|
+
out.push(...walkSources(full, fsImpl));
|
|
79
85
|
continue;
|
|
80
86
|
}
|
|
81
87
|
if (!entry.isFile()) continue;
|
|
@@ -94,11 +100,14 @@ function walkSources(dir) {
|
|
|
94
100
|
* any dedicated check module (this file): the source-of-truth
|
|
95
101
|
* implementation legitimately mentions itself.
|
|
96
102
|
*
|
|
103
|
+
* Pure: it is handed the already-read source, so it needs no filesystem seam
|
|
104
|
+
* of its own.
|
|
105
|
+
*
|
|
97
106
|
* @param {string} file
|
|
98
107
|
* @param {string} src
|
|
99
108
|
* @returns {Array<{ line: number, kind: string }>}
|
|
100
109
|
*/
|
|
101
|
-
function scanFile(file, src) {
|
|
110
|
+
export function scanFile(file, src) {
|
|
102
111
|
const offences = [];
|
|
103
112
|
// Don't flag the actual story-init script, self-references, or the
|
|
104
113
|
// parallel-tooling helper — the helper documents both Rule 2
|
|
@@ -149,15 +158,21 @@ export default {
|
|
|
149
158
|
scope: ['story-close', 'retro'],
|
|
150
159
|
autoCorrect: 'refuse-and-print',
|
|
151
160
|
|
|
152
|
-
|
|
161
|
+
/**
|
|
162
|
+
* @param {{ cwd?: string, scanRoot?: string, scope?: string }} [state]
|
|
163
|
+
* @param {typeof nodeFs} [fsImpl] Optional final filesystem seam; defaults
|
|
164
|
+
* to the real `node:fs` (`.agents/rules/test-seams.md` rule 1) and is
|
|
165
|
+
* forwarded to {@link walkSources} rather than re-acquired (rule 4).
|
|
166
|
+
*/
|
|
167
|
+
detect(state, fsImpl = nodeFs) {
|
|
153
168
|
const cwd = state?.cwd ?? process.cwd();
|
|
154
169
|
const root = state?.scanRoot ?? path.join(cwd, SCAN_ROOT_DEFAULT);
|
|
155
|
-
const files = walkSources(root);
|
|
170
|
+
const files = walkSources(root, fsImpl);
|
|
156
171
|
const offences = [];
|
|
157
172
|
for (const file of files) {
|
|
158
173
|
let src;
|
|
159
174
|
try {
|
|
160
|
-
src = readFileSync(file, 'utf8');
|
|
175
|
+
src = fsImpl.readFileSync(file, 'utf8');
|
|
161
176
|
} catch {
|
|
162
177
|
continue;
|
|
163
178
|
}
|
|
@@ -18,6 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { respondToHelp } from './cli-usage.js';
|
|
20
20
|
import { formatCliError } from './error-redactor.js';
|
|
21
|
+
import { flushStdio } from './stdio-flush.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Is the current module being executed directly as a CLI (as opposed to
|
|
@@ -32,11 +33,48 @@ export function isDirectInvocation(importMetaUrl) {
|
|
|
32
33
|
return fileURLToPath(importMetaUrl) === path.resolve(entry);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Settle one CLI run: await `main`, translate its outcome into an exit code,
|
|
38
|
+
* then flush stdio.
|
|
39
|
+
*
|
|
40
|
+
* **The exit is never eager** (Story #4783). This helper assigns
|
|
41
|
+
* `process.exitCode` and returns, letting Node terminate once the event loop
|
|
42
|
+
* is empty — which is *after* the pending stdout writes drain. The previous
|
|
43
|
+
* `process.exit()` call terminated first, discarding anything still queued
|
|
44
|
+
* behind a full pipe buffer, so any CLI emitting more than 64 KiB into a pipe
|
|
45
|
+
* truncated silently while still reporting success. The resulting exit code is
|
|
46
|
+
* identical for every caller: `code ?? 0` on the `propagateExitCode` path,
|
|
47
|
+
* `options.exitCode` (default 1) on the fatal-error path, and an untouched 0
|
|
48
|
+
* everywhere else.
|
|
49
|
+
*
|
|
50
|
+
* @param {() => Promise<unknown>} main
|
|
51
|
+
* @param {{ source: string, exitCode: number, onError?: (err: Error) => void,
|
|
52
|
+
* propagateExitCode: boolean, errorPrefix?: string }} settings
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
async function settleCli(main, settings) {
|
|
56
|
+
const { source, exitCode, onError, propagateExitCode, errorPrefix } =
|
|
57
|
+
settings;
|
|
58
|
+
try {
|
|
59
|
+
const code = await main();
|
|
60
|
+
if (propagateExitCode) process.exitCode = code ?? 0;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (typeof onError === 'function') {
|
|
63
|
+
onError(err);
|
|
64
|
+
} else {
|
|
65
|
+
const prefix = errorPrefix ?? `[${source}] Fatal error`;
|
|
66
|
+
console.error(`${prefix}: ${formatCliError(err)}`);
|
|
67
|
+
process.exitCode = exitCode;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await flushStdio();
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
/**
|
|
36
74
|
* Run `main` as the CLI entry point for the caller's module. No-op when the
|
|
37
75
|
* module is imported rather than invoked directly. Promise rejection from
|
|
38
76
|
* `main` is funnelled through either the caller-supplied `onError` callback
|
|
39
|
-
* or the default handler (prefixed stderr line + `process.
|
|
77
|
+
* or the default handler (prefixed stderr line + `process.exitCode`).
|
|
40
78
|
*
|
|
41
79
|
* A `usage` option makes the script self-describing: when the argv carries
|
|
42
80
|
* `--help` / `-h`, the rendered usage block goes to stdout and `main` is
|
|
@@ -51,6 +89,9 @@ export function isDirectInvocation(importMetaUrl) {
|
|
|
51
89
|
* @param {string} [options.source='CLI'] Prefix used in the default error message.
|
|
52
90
|
* @param {number} [options.exitCode=1] Exit code used by the default error handler.
|
|
53
91
|
* @param {(err: Error) => void} [options.onError] Full override of the error handler.
|
|
92
|
+
* @param {boolean} [options.propagateExitCode=false] Adopt `main`'s resolved
|
|
93
|
+
* value as the process exit code.
|
|
94
|
+
* @param {string} [options.errorPrefix] Overrides the `[source] Fatal error` prefix.
|
|
54
95
|
* @param {object|string} [options.usage] Usage spec (or pre-rendered
|
|
55
96
|
* text) printed for `--help`; see `lib/cli-usage.js`.
|
|
56
97
|
*/
|
|
@@ -65,17 +106,11 @@ export function runAsCli(importMetaUrl, main, options = {}) {
|
|
|
65
106
|
usage,
|
|
66
107
|
} = options;
|
|
67
108
|
if (usage && respondToHelp(process.argv.slice(2), usage)) return;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
onError(err);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
const prefix = errorPrefix ?? `[${source}] Fatal error`;
|
|
78
|
-
console.error(`${prefix}: ${formatCliError(err)}`);
|
|
79
|
-
process.exit(exitCode);
|
|
109
|
+
void settleCli(main, {
|
|
110
|
+
source,
|
|
111
|
+
exitCode,
|
|
112
|
+
onError,
|
|
113
|
+
propagateExitCode,
|
|
114
|
+
errorPrefix,
|
|
80
115
|
});
|
|
81
116
|
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// .agents/scripts/lib/close-validation/projections/advisories.js
|
|
2
|
+
/**
|
|
3
|
+
* advisories.js — the projection layer's single call site (Story #4776).
|
|
4
|
+
*
|
|
5
|
+
* `projections/maintainability.js` shipped fully written, fully unit-tested
|
|
6
|
+
* and imported by nothing: the v2 Epic-tier collapse removed its caller and
|
|
7
|
+
* left the module behind. The practical consequence was that the advisory
|
|
8
|
+
* telling an operator to run `npm run maintainability:update` and commit a
|
|
9
|
+
* `baseline-refresh:` subject had never fired in v2 — consumers refreshed
|
|
10
|
+
* their baselines by hand or not at all.
|
|
11
|
+
*
|
|
12
|
+
* This module is that caller, for both projections. It is deliberately the
|
|
13
|
+
* only door: `close-validation/runner.js` invokes `runProjectionAdvisories`
|
|
14
|
+
* once, after the gate chain has passed, and every per-kind concern (gate
|
|
15
|
+
* enablement, baseline path resolution, scorer construction, formatting)
|
|
16
|
+
* lives here rather than being re-derived at the runner boundary.
|
|
17
|
+
*
|
|
18
|
+
* **Advisory, always.** Nothing in here can fail a close. `check-baselines`
|
|
19
|
+
* already fails closed on a real regression; the projections add the refresh
|
|
20
|
+
* half of the loop, not a second gate. Every projection is wrapped so a
|
|
21
|
+
* throw becomes a logged skip.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { getQuality } from '../../config/quality.js';
|
|
26
|
+
import {
|
|
27
|
+
createCrapScorer,
|
|
28
|
+
formatCrapProjection,
|
|
29
|
+
projectCrapBreaches,
|
|
30
|
+
} from './crap.js';
|
|
31
|
+
import {
|
|
32
|
+
formatMaintainabilityProjection,
|
|
33
|
+
projectMaintainabilityRegressions,
|
|
34
|
+
} from './maintainability.js';
|
|
35
|
+
|
|
36
|
+
/** Default on-disk baseline locations, mirroring the per-gate defaults. */
|
|
37
|
+
const DEFAULT_BASELINE_PATHS = Object.freeze({
|
|
38
|
+
maintainability: 'baselines/maintainability.json',
|
|
39
|
+
crap: 'baselines/crap.json',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a gate's baseline file to an absolute path.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} kind
|
|
46
|
+
* @param {object} gate resolved `delivery.quality.gates.<kind>` block
|
|
47
|
+
* @param {string} cwd
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
function resolveBaselinePath(kind, gate, cwd) {
|
|
51
|
+
const rel =
|
|
52
|
+
typeof gate?.baselinePath === 'string' && gate.baselinePath.length > 0
|
|
53
|
+
? gate.baselinePath
|
|
54
|
+
: DEFAULT_BASELINE_PATHS[kind];
|
|
55
|
+
return path.isAbsolute(rel) ? rel : path.resolve(cwd, rel);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A gate is projected unless it is explicitly disabled. An absent gate
|
|
60
|
+
* block means "framework defaults", which enable it — the same reading
|
|
61
|
+
* `buildDefaultGates` applies.
|
|
62
|
+
*
|
|
63
|
+
* @param {object|undefined} gate
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
function isEnabled(gate) {
|
|
67
|
+
return gate?.enabled !== false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run one projection with its formatter, swallowing every failure into a
|
|
72
|
+
* logged skip. Returns the projection result (or `null` when it threw) so
|
|
73
|
+
* callers and tests can inspect what happened without parsing log lines.
|
|
74
|
+
*
|
|
75
|
+
* @param {{ kind: string, log: (m: string) => void, run: () => Promise<object>|object, format: (r: object) => string|null }} opts
|
|
76
|
+
* @returns {Promise<object|null>}
|
|
77
|
+
*/
|
|
78
|
+
async function runOne({ kind, log, run, format }) {
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = await run();
|
|
82
|
+
} catch (err) {
|
|
83
|
+
log(
|
|
84
|
+
`[close-validation] ⚠ ${kind} projection skipped (errored): ${err?.message ?? err}`,
|
|
85
|
+
);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (result?.skipped) {
|
|
89
|
+
log(
|
|
90
|
+
`[close-validation] ⏭ ${kind} projection skipped (${result.skipped}${
|
|
91
|
+
result.detail ? `: ${result.detail}` : ''
|
|
92
|
+
})`,
|
|
93
|
+
);
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
const advisory = format(result);
|
|
97
|
+
if (advisory) log(advisory);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run the maintainability and CRAP pre-merge projections and log their
|
|
103
|
+
* advisories. Never throws; never affects the close verdict.
|
|
104
|
+
*
|
|
105
|
+
* @param {{
|
|
106
|
+
* cwd: string,
|
|
107
|
+
* baseBranch: string,
|
|
108
|
+
* storyBranch: string,
|
|
109
|
+
* config?: object,
|
|
110
|
+
* quality?: object,
|
|
111
|
+
* log?: (m: string) => void,
|
|
112
|
+
* projectMaintainability?: typeof projectMaintainabilityRegressions,
|
|
113
|
+
* formatMaintainability?: typeof formatMaintainabilityProjection,
|
|
114
|
+
* projectCrap?: typeof projectCrapBreaches,
|
|
115
|
+
* formatCrap?: typeof formatCrapProjection,
|
|
116
|
+
* }} opts
|
|
117
|
+
* @returns {Promise<{ maintainability: object|null, crap: object|null }>}
|
|
118
|
+
*/
|
|
119
|
+
export async function runProjectionAdvisories({
|
|
120
|
+
cwd,
|
|
121
|
+
baseBranch,
|
|
122
|
+
storyBranch,
|
|
123
|
+
config,
|
|
124
|
+
quality,
|
|
125
|
+
log = () => {},
|
|
126
|
+
projectMaintainability = projectMaintainabilityRegressions,
|
|
127
|
+
formatMaintainability = formatMaintainabilityProjection,
|
|
128
|
+
projectCrap = projectCrapBreaches,
|
|
129
|
+
formatCrap = formatCrapProjection,
|
|
130
|
+
} = {}) {
|
|
131
|
+
const out = { maintainability: null, crap: null };
|
|
132
|
+
let gates;
|
|
133
|
+
try {
|
|
134
|
+
gates = quality ?? getQuality(config) ?? {};
|
|
135
|
+
} catch {
|
|
136
|
+
gates = {};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const miGate = gates.maintainability;
|
|
140
|
+
if (isEnabled(miGate)) {
|
|
141
|
+
out.maintainability = await runOne({
|
|
142
|
+
kind: 'maintainability',
|
|
143
|
+
log,
|
|
144
|
+
format: formatMaintainability,
|
|
145
|
+
run: () =>
|
|
146
|
+
projectMaintainability({
|
|
147
|
+
cwd,
|
|
148
|
+
baseBranch,
|
|
149
|
+
storyBranch,
|
|
150
|
+
baselinePath: resolveBaselinePath('maintainability', miGate, cwd),
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
log('[close-validation] ⏭ maintainability projection skipped (disabled)');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const crapGate = gates.crap;
|
|
158
|
+
if (isEnabled(crapGate)) {
|
|
159
|
+
out.crap = await runOne({
|
|
160
|
+
kind: 'crap',
|
|
161
|
+
log,
|
|
162
|
+
format: formatCrap,
|
|
163
|
+
run: () =>
|
|
164
|
+
projectCrap({
|
|
165
|
+
cwd,
|
|
166
|
+
baseBranch,
|
|
167
|
+
storyBranch,
|
|
168
|
+
baselinePath: resolveBaselinePath('crap', crapGate, cwd),
|
|
169
|
+
newMethodCeiling: crapGate?.newMethodCeiling,
|
|
170
|
+
scoreFiles: createCrapScorer({
|
|
171
|
+
cwd,
|
|
172
|
+
targetDirs: crapGate?.targetDirs,
|
|
173
|
+
ignoreGlobs: crapGate?.ignoreGlobs,
|
|
174
|
+
requireCoverage: crapGate?.requireCoverage,
|
|
175
|
+
coveragePath: crapGate?.coveragePath,
|
|
176
|
+
}),
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
log('[close-validation] ⏭ crap projection skipped (disabled)');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return out;
|
|
184
|
+
}
|