mandrel 2.28.0 → 2.29.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 +3 -0
- package/.agents/schemas/agentrc.schema.json +14 -0
- package/.agents/scripts/coverage-capture.js +31 -62
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +41 -0
- package/.agents/scripts/lib/baselines/crap-preview-scan.js +135 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +13 -0
- package/.agents/scripts/lib/baselines/preview-gates.js +7 -87
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +26 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +2 -0
- package/.agents/scripts/lib/config/quality.js +41 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +105 -0
- package/.agents/scripts/lib/coverage-capture-incremental.js +116 -0
- package/.agents/scripts/lib/coverage-capture.js +101 -17
- package/.agents/scripts/lib/crap-baseline-index.js +46 -0
- package/.agents/scripts/lib/crap-baseline-join.js +153 -0
- package/.agents/scripts/lib/crap-coordinates.js +43 -0
- package/.agents/scripts/lib/crap-engine.js +26 -51
- package/.agents/scripts/lib/crap-utils-incremental.js +113 -0
- package/.agents/scripts/lib/crap-utils.js +40 -8
- package/docs/CHANGELOG.md +7 -0
- package/package.json +1 -1
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
import escomplex from 'typhonjs-escomplex';
|
|
2
2
|
import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
3
|
+
// `finalizeMethodRowsWithBaseline` (Story #4981) lives in
|
|
4
|
+
// crap-baseline-join.js — `resolveRawRow`, the per-row policy it shares with
|
|
5
|
+
// `finalizeMethodRows` below, is imported from there so the two stay a
|
|
6
|
+
// single implementation.
|
|
7
|
+
import { resolveRawRow } from './crap-baseline-join.js';
|
|
8
|
+
// `COORDINATE_ORIGINAL` / `COORDINATE_TRANSPILED` / `crapFormula` (Story
|
|
9
|
+
// #4866 / this module's original home) now live in crap-coordinates.js —
|
|
10
|
+
// re-exported here so every existing importer of this module is unaffected.
|
|
11
|
+
import {
|
|
12
|
+
COORDINATE_ORIGINAL,
|
|
13
|
+
COORDINATE_TRANSPILED,
|
|
14
|
+
crapFormula,
|
|
15
|
+
} from './crap-coordinates.js';
|
|
3
16
|
import { deriveMethodIdentities } from './crap-method-identity.js';
|
|
4
17
|
|
|
5
|
-
|
|
6
|
-
* The two line coordinate systems a CRAP row's `startLine` can be expressed
|
|
7
|
-
* in (Story #4866).
|
|
8
|
-
*
|
|
9
|
-
* `original` — the coordinates of the file a reader can open, and the ones
|
|
10
|
-
* istanbul's `fnMap` is keyed against. A JavaScript source is already in this
|
|
11
|
-
* system; a TS/TSX source reaches it only through a successful sourcemap
|
|
12
|
-
* lookup.
|
|
13
|
-
*
|
|
14
|
-
* `transpiled` — escomplex's own coordinates over the emitted JavaScript,
|
|
15
|
-
* kept only when the sourcemap has no entry originating on the method's
|
|
16
|
-
* generated line. Such a row is NOT an original-source coordinate and must
|
|
17
|
-
* never be presented as one: it cannot be joined to coverage, and it cannot
|
|
18
|
-
* be compared against a baseline row carrying the other provenance.
|
|
19
|
-
*/
|
|
20
|
-
export const COORDINATE_ORIGINAL = 'original';
|
|
21
|
-
export const COORDINATE_TRANSPILED = 'transpiled';
|
|
18
|
+
export { COORDINATE_ORIGINAL, COORDINATE_TRANSPILED, crapFormula };
|
|
22
19
|
|
|
23
20
|
/**
|
|
24
21
|
* Derive the raw per-method CRAP rows from an escomplex report.
|
|
@@ -170,33 +167,25 @@ export function finalizeMethodRows(
|
|
|
170
167
|
let totalMethods = 0;
|
|
171
168
|
for (const mr of rawRows ?? []) {
|
|
172
169
|
totalMethods += 1;
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
) {
|
|
170
|
+
const resolution = resolveRawRow(mr, {
|
|
171
|
+
requireCoverage,
|
|
172
|
+
coverageAvailable,
|
|
173
|
+
});
|
|
174
|
+
if (resolution.resolved) resolvedMethods += 1;
|
|
175
|
+
if (resolution.row === null) {
|
|
180
176
|
skippedMethodsNoCoverage += 1;
|
|
181
177
|
continue;
|
|
182
178
|
}
|
|
183
|
-
|
|
184
|
-
const crap = unresolved ? crapFormula(mr.cyclomatic, 0) : mr.crap;
|
|
185
|
-
// Everything the scan decided is carried forward; this step overrides only
|
|
186
|
-
// what its own policy resolves. Spreading rather than re-listing each field
|
|
187
|
-
// is why the row's identity marker (Story #4969) and its provenance
|
|
188
|
-
// (Story #4866) survive the step without a line each to remember them —
|
|
189
|
-
// a hand-rebuilt row is how a marker silently stops reaching the baseline.
|
|
190
|
-
rows.push({
|
|
191
|
-
...mr,
|
|
192
|
-
coverage,
|
|
193
|
-
crap,
|
|
194
|
-
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
195
|
-
});
|
|
179
|
+
rows.push(resolution.row);
|
|
196
180
|
}
|
|
197
181
|
return { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods };
|
|
198
182
|
}
|
|
199
183
|
|
|
184
|
+
// The incremental-mode join (Story #4981) lives in crap-baseline-join.js;
|
|
185
|
+
// re-exported here so it stays reachable from the scoring kernel's existing
|
|
186
|
+
// public surface.
|
|
187
|
+
export { finalizeMethodRowsWithBaseline } from './crap-baseline-join.js';
|
|
188
|
+
|
|
200
189
|
/**
|
|
201
190
|
* Score each method in a JavaScript source for Change Risk Anti-Patterns
|
|
202
191
|
* (CRAP): `c² · (1 − cov)³ + c`, where `c` is cyclomatic complexity and `cov`
|
|
@@ -243,20 +232,6 @@ export function calculateCrapForSource(
|
|
|
243
232
|
return methodRowsFromReport(report, coverageForFile, mapLine);
|
|
244
233
|
}
|
|
245
234
|
|
|
246
|
-
/**
|
|
247
|
-
* CRAP formula, exported for callers that need to derive target scores or
|
|
248
|
-
* `fixGuidance` values without re-scoring source.
|
|
249
|
-
*
|
|
250
|
-
* @param {number} cyclomatic
|
|
251
|
-
* @param {number} coverage In [0, 1].
|
|
252
|
-
* @returns {number}
|
|
253
|
-
*/
|
|
254
|
-
export function crapFormula(cyclomatic, coverage) {
|
|
255
|
-
const c = Number(cyclomatic) || 0;
|
|
256
|
-
const cov = Math.max(0, Math.min(1, Number(coverage) || 0));
|
|
257
|
-
return c * c * (1 - cov) ** 3 + c;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
235
|
/**
|
|
261
236
|
* Derive the deterministic single-axis fixes that would bring a method at
|
|
262
237
|
* cyclomatic complexity `c` at or under the `target` CRAP score.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crap-utils-incremental.js — small pure helpers that wire the incremental
|
|
3
|
+
* CRAP join (Story #4981) into `crap-utils.js#scanAndScore`'s per-file work
|
|
4
|
+
* queue. Split into their own file so the wiring lands as new code rather
|
|
5
|
+
* than a same-file expansion of `scanAndScore` / `scoreFileSerial`.
|
|
6
|
+
*/
|
|
7
|
+
import { indexBaselineRowsByFile } from './crap-baseline-index.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve `scanAndScore`'s `incremental` option into the two lookup
|
|
11
|
+
* structures the per-file queue build needs. Both are `null` when
|
|
12
|
+
* `incremental` is absent (full-scope, the default) — every downstream
|
|
13
|
+
* consumer treats a `null` context as "not incremental".
|
|
14
|
+
*
|
|
15
|
+
* @param {{ touchedFiles?: Set<string>|string[], baselineRows?: Array<object> } | null} incremental
|
|
16
|
+
* @returns {{ touchedFiles: Set<string>|null, baselineByFile: Map<string, Map<string, object>>|null }}
|
|
17
|
+
*/
|
|
18
|
+
export function resolveIncrementalContext(incremental) {
|
|
19
|
+
const touchedFiles = incremental?.touchedFiles
|
|
20
|
+
? incremental.touchedFiles instanceof Set
|
|
21
|
+
? incremental.touchedFiles
|
|
22
|
+
: new Set(incremental.touchedFiles)
|
|
23
|
+
: null;
|
|
24
|
+
const baselineByFile = incremental
|
|
25
|
+
? indexBaselineRowsByFile(incremental.baselineRows)
|
|
26
|
+
: null;
|
|
27
|
+
return { touchedFiles, baselineByFile };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Merge one queued file's `touched` flag and per-file `baselineByKey` map
|
|
32
|
+
* (resolved from the `resolveIncrementalContext` output) onto its base queue
|
|
33
|
+
* item. `touched` defaults to `true` (every file is "touched" outside
|
|
34
|
+
* incremental mode, matching
|
|
35
|
+
* `crap-baseline-join.js#finalizeMethodRowsWithBaseline`'s own default).
|
|
36
|
+
*
|
|
37
|
+
* @param {object} item Base queue item (`{ abs, relPath, requireCoverage, coverageAvailable }`).
|
|
38
|
+
* @param {{ touchedFiles: Set<string>|null, baselineByFile: Map<string, Map<string, object>>|null }} ctx
|
|
39
|
+
* @returns {object} `item` plus `{ touched, baselineByKey }`.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveQueueIncrementalFields(
|
|
42
|
+
item,
|
|
43
|
+
{ touchedFiles, baselineByFile },
|
|
44
|
+
) {
|
|
45
|
+
const touched = touchedFiles ? touchedFiles.has(item.relPath) : true;
|
|
46
|
+
const baselineByKey = baselineByFile
|
|
47
|
+
? (baselineByFile.get(item.relPath) ?? new Map())
|
|
48
|
+
: null;
|
|
49
|
+
return { ...item, touched, baselineByKey };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when `scoreFileSerial` should resolve a file's methods from the
|
|
54
|
+
* baseline rather than requiring a fresh coverage entry — an untouched file
|
|
55
|
+
* with at least one indexed baseline row.
|
|
56
|
+
*
|
|
57
|
+
* @param {boolean} touched
|
|
58
|
+
* @param {Map<string, object>|null} baselineByKey
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function isIncrementalJoinActive(touched, baselineByKey) {
|
|
62
|
+
return !touched && baselineByKey != null && baselineByKey.size > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `scoreFileSerial`'s file-level skip decision, factored out whole so the
|
|
67
|
+
* incremental exception lives with the rest of this Story's branching
|
|
68
|
+
* rather than inflating the cyclomatic complexity of the pre-#4981 caller.
|
|
69
|
+
*
|
|
70
|
+
* @param {boolean} requireCoverage
|
|
71
|
+
* @param {object|null} entry Istanbul coverage entry for this file.
|
|
72
|
+
* @param {boolean} touched
|
|
73
|
+
* @param {Map<string, object>|null} baselineByKey
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function shouldSkipFileForNoCoverage(
|
|
77
|
+
requireCoverage,
|
|
78
|
+
entry,
|
|
79
|
+
touched,
|
|
80
|
+
baselineByKey,
|
|
81
|
+
) {
|
|
82
|
+
return (
|
|
83
|
+
requireCoverage &&
|
|
84
|
+
entry === null &&
|
|
85
|
+
!isIncrementalJoinActive(touched, baselineByKey)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `scanAndScore`'s serial-vs-pool routing decision. Incremental mode always
|
|
91
|
+
* routes serial (see `isIncrementalJoinActive`'s doc for why the baseline
|
|
92
|
+
* lookup Maps don't cross the worker boundary); otherwise unchanged from the
|
|
93
|
+
* pre-#4981 queue-length cutover.
|
|
94
|
+
*
|
|
95
|
+
* @param {number} queueLength
|
|
96
|
+
* @param {unknown} incremental
|
|
97
|
+
* @param {number} serialThreshold
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
export function shouldRunSerial(queueLength, incremental, serialThreshold) {
|
|
101
|
+
return queueLength < serialThreshold || Boolean(incremental);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Project the `resolvedFromBaseline` marker onto a `scanAndScore` result row
|
|
106
|
+
* — present only when true, so a full-scope scan's rows are unaffected.
|
|
107
|
+
*
|
|
108
|
+
* @param {{ resolvedFromBaseline?: boolean }} mr
|
|
109
|
+
* @returns {{ resolvedFromBaseline: true } | {}}
|
|
110
|
+
*/
|
|
111
|
+
export function resolvedFromBaselineFlag(mr) {
|
|
112
|
+
return mr.resolvedFromBaseline === true ? { resolvedFromBaseline: true } : {};
|
|
113
|
+
}
|
|
@@ -4,11 +4,19 @@ import escomplex from 'typhonjs-escomplex';
|
|
|
4
4
|
import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
|
|
5
5
|
import { findCoverageEntry } from './coverage-utils.js';
|
|
6
6
|
import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
|
|
7
|
+
import { finalizeMethodRowsWithBaseline } from './crap-baseline-join.js';
|
|
7
8
|
import {
|
|
8
9
|
COORDINATE_ORIGINAL,
|
|
9
10
|
finalizeMethodRows,
|
|
10
11
|
methodRowsFromReport,
|
|
11
12
|
} from './crap-engine.js';
|
|
13
|
+
import {
|
|
14
|
+
resolvedFromBaselineFlag,
|
|
15
|
+
resolveIncrementalContext,
|
|
16
|
+
resolveQueueIncrementalFields,
|
|
17
|
+
shouldRunSerial,
|
|
18
|
+
shouldSkipFileForNoCoverage,
|
|
19
|
+
} from './crap-utils-incremental.js';
|
|
12
20
|
import { Logger } from './Logger.js';
|
|
13
21
|
import { scanDirectory } from './maintainability-utils.js';
|
|
14
22
|
import {
|
|
@@ -409,6 +417,10 @@ export function analyzeOnce(source, coverageForFile, mapLine = null) {
|
|
|
409
417
|
* `regenerateMainFromTree`) SHOULD pass the MI scan's file list here so the
|
|
410
418
|
* tree is walked only once per run.
|
|
411
419
|
*
|
|
420
|
+
* `incremental` (Story #4981) resolves an untouched file's methods from
|
|
421
|
+
* `crap-baseline-join.js#finalizeMethodRowsWithBaseline` instead of
|
|
422
|
+
* requiring fresh coverage; omitted (the default), behaviour is unchanged.
|
|
423
|
+
*
|
|
412
424
|
* @param {{
|
|
413
425
|
* targetDirs: string[],
|
|
414
426
|
* coverage: object|null,
|
|
@@ -416,6 +428,7 @@ export function analyzeOnce(source, coverageForFile, mapLine = null) {
|
|
|
416
428
|
* cwd?: string,
|
|
417
429
|
* scopeFiles?: Set<string>|string[]|null,
|
|
418
430
|
* preScannedFiles?: string[]|null,
|
|
431
|
+
* incremental?: { touchedFiles: Set<string>|string[], baselineRows: Array<object> } | null,
|
|
419
432
|
* }} params
|
|
420
433
|
* @returns {{
|
|
421
434
|
* rows: Array<{
|
|
@@ -439,6 +452,7 @@ export async function scanAndScore({
|
|
|
439
452
|
scopeFiles = null,
|
|
440
453
|
ignoreGlobs = [],
|
|
441
454
|
preScannedFiles = null,
|
|
455
|
+
incremental = null,
|
|
442
456
|
}) {
|
|
443
457
|
if (!Array.isArray(targetDirs)) {
|
|
444
458
|
throw new TypeError('scanAndScore: targetDirs must be an array');
|
|
@@ -460,6 +474,8 @@ export async function scanAndScore({
|
|
|
460
474
|
}
|
|
461
475
|
files.sort();
|
|
462
476
|
|
|
477
|
+
const incrementalCtx = resolveIncrementalContext(incremental);
|
|
478
|
+
|
|
463
479
|
// Build the work-queue first so scopeFile filtering happens before
|
|
464
480
|
// any I/O / IPC. `scannedFiles` is the in-scope count.
|
|
465
481
|
// Story #2079: route every relPath through path-canon so a scan from
|
|
@@ -472,14 +488,18 @@ export async function scanAndScore({
|
|
|
472
488
|
const rawRel = path.relative(cwd, abs).replace(/\\/g, '/');
|
|
473
489
|
const relPath = canonicalisePath(rawRel);
|
|
474
490
|
if (scopeSet && !scopeSet.has(relPath)) continue;
|
|
475
|
-
queue.push(
|
|
491
|
+
queue.push(
|
|
492
|
+
resolveQueueIncrementalFields(
|
|
493
|
+
{ abs, relPath, requireCoverage, coverageAvailable },
|
|
494
|
+
incrementalCtx,
|
|
495
|
+
),
|
|
496
|
+
);
|
|
476
497
|
}
|
|
477
498
|
const scannedFiles = queue.length;
|
|
478
499
|
|
|
479
|
-
const perFile =
|
|
480
|
-
queue.
|
|
481
|
-
|
|
482
|
-
: await scoreFilesViaPool(queue, coverage);
|
|
500
|
+
const perFile = shouldRunSerial(queue.length, incremental, SERIAL_THRESHOLD)
|
|
501
|
+
? queue.map((item) => ({ item, result: scoreFileSerial(item, coverage) }))
|
|
502
|
+
: await scoreFilesViaPool(queue, coverage);
|
|
483
503
|
|
|
484
504
|
const rows = [];
|
|
485
505
|
let skippedFilesNoCoverage = 0;
|
|
@@ -516,6 +536,7 @@ export async function scanAndScore({
|
|
|
516
536
|
coverage: mr.coverage,
|
|
517
537
|
crap: mr.crap,
|
|
518
538
|
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
539
|
+
...resolvedFromBaselineFlag(mr),
|
|
519
540
|
});
|
|
520
541
|
}
|
|
521
542
|
}
|
|
@@ -545,11 +566,20 @@ export async function scanAndScore({
|
|
|
545
566
|
* CRAP rows and the MI score are derived from the same escomplex report.
|
|
546
567
|
*/
|
|
547
568
|
function scoreFileSerial(
|
|
548
|
-
{
|
|
569
|
+
{
|
|
570
|
+
abs,
|
|
571
|
+
relPath,
|
|
572
|
+
requireCoverage,
|
|
573
|
+
coverageAvailable = true,
|
|
574
|
+
touched = true,
|
|
575
|
+
baselineByKey = null,
|
|
576
|
+
},
|
|
549
577
|
coverage,
|
|
550
578
|
) {
|
|
551
579
|
const entry = findCoverageEntry(coverage, relPath);
|
|
552
|
-
if (
|
|
580
|
+
if (
|
|
581
|
+
shouldSkipFileForNoCoverage(requireCoverage, entry, touched, baselineByKey)
|
|
582
|
+
) {
|
|
553
583
|
return {
|
|
554
584
|
skippedFileNoCoverage: true,
|
|
555
585
|
rows: [],
|
|
@@ -575,9 +605,11 @@ function scoreFileSerial(
|
|
|
575
605
|
prepared.mapLine,
|
|
576
606
|
);
|
|
577
607
|
if (parseError) return dropped;
|
|
578
|
-
const finalized =
|
|
608
|
+
const finalized = finalizeMethodRowsWithBaseline(crapRows, {
|
|
579
609
|
requireCoverage,
|
|
580
610
|
coverageAvailable,
|
|
611
|
+
touched,
|
|
612
|
+
baselineByKey,
|
|
581
613
|
});
|
|
582
614
|
return {
|
|
583
615
|
skippedFileNoCoverage: false,
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,13 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.29.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.28.0...mandrel-v2.29.0) (2026-08-03)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
* incremental coverage-capture: scope the close-time coverage run to the Story diff and join unchanged files from the committed CRAP baseline ([#4981](https://github.com/dsj1984/mandrel/issues/4981)) ([#4982](https://github.com/dsj1984/mandrel/issues/4982)) ([3d73875](https://github.com/dsj1984/mandrel/commit/3d73875deb170edf69f6f480d5d1abfffebcb47d))
|
|
24
|
+
|
|
18
25
|
## [2.28.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.27.0...mandrel-v2.28.0) (2026-08-03)
|
|
19
26
|
|
|
20
27
|
|
package/package.json
CHANGED