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.
@@ -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 unresolved = mr.crap === null || mr.coverage === null;
174
- if (!unresolved) resolvedMethods += 1;
175
- // Unjoinable is not untested (Story #4901) — see the block comment above.
176
- if (
177
- mr.coordinateSystem === COORDINATE_TRANSPILED ||
178
- (unresolved && (requireCoverage || !coverageAvailable))
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
- const coverage = unresolved ? 0 : mr.coverage;
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({ abs, relPath, requireCoverage, coverageAvailable });
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.length < SERIAL_THRESHOLD
481
- ? queue.map((item) => ({ item, result: scoreFileSerial(item, coverage) }))
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
- { abs, relPath, requireCoverage, coverageAvailable = true },
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 (requireCoverage && entry === null) {
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 = finalizeMethodRows(crapRows, {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.28.0",
3
+ "version": "2.29.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",