mandrel 2.28.0 → 2.30.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 {
@@ -98,6 +106,73 @@ function resolveBaselinePath({ cwd = process.cwd(), baselinePath } = {}) {
98
106
  * rows: Array<{file: string, method: string, startLine: number, crap: number}>,
99
107
  * }|null}
100
108
  */
109
+ /**
110
+ * The envelope-level fields the CRAP compat axes read off a *loaded* baseline
111
+ * — the set every read path owes `assertBaselineCompatible`.
112
+ *
113
+ * It exists because there are TWO read paths and they have now diverged three
114
+ * times. `check-baselines` loads through `baselines/reader.js`; the
115
+ * `quality-preview` pre-commit arm loads through `projectCrapEnvelopeToLegacy`
116
+ * below. Both are ALLOW-LISTS, both feed the same axes, and a stamp added to
117
+ * one and not the other yields two opposite verdicts on one file: Story #4866
118
+ * (`scoringSemantics`, `tsTranspilerVersion`), Story #4969 (`rows[].anonymous`)
119
+ * and Story #4986 (`provenanceStamped`, half-fixed by #4973) were each that
120
+ * same half-landing.
121
+ *
122
+ * Every one of those axes keys on a POSITIVE marker, so a dropped field reads
123
+ * `undefined` and fails the baseline closed with a remedy that cannot work —
124
+ * re-deriving it writes the stamp the read path then discards. The projection
125
+ * below is DERIVED from this list rather than repeating it, so a new stamp is
126
+ * carried here the moment it is named; a parity test holds the reader to the
127
+ * same set and names whichever path forgot one.
128
+ *
129
+ * Row-level markers are deliberately out: they are projected per-row, not as
130
+ * envelope stamps.
131
+ *
132
+ * Deliberately module-local, mirroring `SCORING_SEMANTICS` in
133
+ * `baselines/kinds/crap.js`: the writer's `envelopeExtras()` is the single
134
+ * production door to the stamp set, and exporting this list would add a second
135
+ * one that only a test reaches. The parity test holds this projection to
136
+ * `Object.keys(envelopeExtras())` instead, so a stamp the writer starts
137
+ * emitting fails the test here until it is named — which is the enforcement
138
+ * this list needs, not an export.
139
+ */
140
+ const COMPAT_STAMP_FIELDS = Object.freeze([
141
+ 'scoringSemantics',
142
+ 'tsTranspilerVersion',
143
+ 'provenanceStamped',
144
+ ]);
145
+
146
+ /**
147
+ * Per-stamp coercion applied on the way through the legacy projection. A field
148
+ * with no entry is carried VERBATIM, which is the correct default: the axes
149
+ * distinguish "stamped" from "absent", so inventing a value for a stamp the
150
+ * envelope never wrote is the one thing a read path must not do.
151
+ */
152
+ const COMPAT_STAMP_NORMALIZERS = {
153
+ // `null` (not the running value) when unstamped, so `ts-transpiler-drift`
154
+ // can tell "written by a different transpiler" from "written before the
155
+ // stamp existed" instead of comparing a value against itself.
156
+ tsTranspilerVersion: (value) => (typeof value === 'string' ? value : null),
157
+ scoringSemantics: (value) => value ?? null,
158
+ };
159
+
160
+ /**
161
+ * Project the compat stamps off a v2 envelope, driven by
162
+ * `COMPAT_STAMP_FIELDS`.
163
+ *
164
+ * @param {Record<string, unknown>} parsed
165
+ * @returns {Record<string, unknown>}
166
+ */
167
+ function projectCompatStamps(parsed) {
168
+ const stamps = {};
169
+ for (const field of COMPAT_STAMP_FIELDS) {
170
+ const normalize = COMPAT_STAMP_NORMALIZERS[field];
171
+ stamps[field] = normalize ? normalize(parsed[field]) : parsed[field];
172
+ }
173
+ return stamps;
174
+ }
175
+
101
176
  /**
102
177
  * Story #1895: shipped baseline switched to the canonical envelope shape
103
178
  * (`$schema`, `kernelVersion`, `generatedAt`, `rollup`, `rows` keyed on
@@ -114,6 +189,16 @@ function resolveBaselinePath({ cwd = process.cwd(), baselinePath } = {}) {
114
189
  * They are carried verbatim now, `null` when the envelope never stamped them,
115
190
  * so an axis can tell "written by a different transpiler" apart from "written
116
191
  * before the stamp existed" instead of guessing.
192
+ *
193
+ * **This projection is one of TWO (Story #4986).** `check-baselines` reads a
194
+ * baseline through `baselines/reader.js`; `quality-preview` reads the same file
195
+ * through here. Both feed `assertBaselineCompatible`, so a stamp added to one
196
+ * allow-list and not the other produces two opposite verdicts on one envelope —
197
+ * which is what happened to `provenanceStamped`: #4973 added it to the reader
198
+ * and left this projection dropping it, so the pre-commit CRAP arm rejected
199
+ * every stamped baseline with an un-satisfiable "re-seed" remedy while the
200
+ * authoritative gate passed. The stamp block is derived from
201
+ * `COMPAT_STAMP_FIELDS` above so this projection cannot fall behind again.
117
202
  */
118
203
  function projectCrapEnvelopeToLegacy(parsed) {
119
204
  if (
@@ -126,11 +211,7 @@ function projectCrapEnvelopeToLegacy(parsed) {
126
211
  return {
127
212
  kernelVersion: parsed.kernelVersion,
128
213
  escomplexVersion: resolveEscomplexVersion(),
129
- tsTranspilerVersion:
130
- typeof parsed.tsTranspilerVersion === 'string'
131
- ? parsed.tsTranspilerVersion
132
- : null,
133
- scoringSemantics: parsed.scoringSemantics ?? null,
214
+ ...projectCompatStamps(parsed),
134
215
  rows: parsed.rows.map((row) => ({
135
216
  crap: row.crap,
136
217
  file: row.path,
@@ -409,6 +490,10 @@ export function analyzeOnce(source, coverageForFile, mapLine = null) {
409
490
  * `regenerateMainFromTree`) SHOULD pass the MI scan's file list here so the
410
491
  * tree is walked only once per run.
411
492
  *
493
+ * `incremental` (Story #4981) resolves an untouched file's methods from
494
+ * `crap-baseline-join.js#finalizeMethodRowsWithBaseline` instead of
495
+ * requiring fresh coverage; omitted (the default), behaviour is unchanged.
496
+ *
412
497
  * @param {{
413
498
  * targetDirs: string[],
414
499
  * coverage: object|null,
@@ -416,6 +501,7 @@ export function analyzeOnce(source, coverageForFile, mapLine = null) {
416
501
  * cwd?: string,
417
502
  * scopeFiles?: Set<string>|string[]|null,
418
503
  * preScannedFiles?: string[]|null,
504
+ * incremental?: { touchedFiles: Set<string>|string[], baselineRows: Array<object> } | null,
419
505
  * }} params
420
506
  * @returns {{
421
507
  * rows: Array<{
@@ -439,6 +525,7 @@ export async function scanAndScore({
439
525
  scopeFiles = null,
440
526
  ignoreGlobs = [],
441
527
  preScannedFiles = null,
528
+ incremental = null,
442
529
  }) {
443
530
  if (!Array.isArray(targetDirs)) {
444
531
  throw new TypeError('scanAndScore: targetDirs must be an array');
@@ -460,6 +547,8 @@ export async function scanAndScore({
460
547
  }
461
548
  files.sort();
462
549
 
550
+ const incrementalCtx = resolveIncrementalContext(incremental);
551
+
463
552
  // Build the work-queue first so scopeFile filtering happens before
464
553
  // any I/O / IPC. `scannedFiles` is the in-scope count.
465
554
  // Story #2079: route every relPath through path-canon so a scan from
@@ -472,14 +561,18 @@ export async function scanAndScore({
472
561
  const rawRel = path.relative(cwd, abs).replace(/\\/g, '/');
473
562
  const relPath = canonicalisePath(rawRel);
474
563
  if (scopeSet && !scopeSet.has(relPath)) continue;
475
- queue.push({ abs, relPath, requireCoverage, coverageAvailable });
564
+ queue.push(
565
+ resolveQueueIncrementalFields(
566
+ { abs, relPath, requireCoverage, coverageAvailable },
567
+ incrementalCtx,
568
+ ),
569
+ );
476
570
  }
477
571
  const scannedFiles = queue.length;
478
572
 
479
- const perFile =
480
- queue.length < SERIAL_THRESHOLD
481
- ? queue.map((item) => ({ item, result: scoreFileSerial(item, coverage) }))
482
- : await scoreFilesViaPool(queue, coverage);
573
+ const perFile = shouldRunSerial(queue.length, incremental, SERIAL_THRESHOLD)
574
+ ? queue.map((item) => ({ item, result: scoreFileSerial(item, coverage) }))
575
+ : await scoreFilesViaPool(queue, coverage);
483
576
 
484
577
  const rows = [];
485
578
  let skippedFilesNoCoverage = 0;
@@ -516,6 +609,7 @@ export async function scanAndScore({
516
609
  coverage: mr.coverage,
517
610
  crap: mr.crap,
518
611
  coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
612
+ ...resolvedFromBaselineFlag(mr),
519
613
  });
520
614
  }
521
615
  }
@@ -545,11 +639,20 @@ export async function scanAndScore({
545
639
  * CRAP rows and the MI score are derived from the same escomplex report.
546
640
  */
547
641
  function scoreFileSerial(
548
- { abs, relPath, requireCoverage, coverageAvailable = true },
642
+ {
643
+ abs,
644
+ relPath,
645
+ requireCoverage,
646
+ coverageAvailable = true,
647
+ touched = true,
648
+ baselineByKey = null,
649
+ },
549
650
  coverage,
550
651
  ) {
551
652
  const entry = findCoverageEntry(coverage, relPath);
552
- if (requireCoverage && entry === null) {
653
+ if (
654
+ shouldSkipFileForNoCoverage(requireCoverage, entry, touched, baselineByKey)
655
+ ) {
553
656
  return {
554
657
  skippedFileNoCoverage: true,
555
658
  rows: [],
@@ -575,9 +678,11 @@ function scoreFileSerial(
575
678
  prepared.mapLine,
576
679
  );
577
680
  if (parseError) return dropped;
578
- const finalized = finalizeMethodRows(crapRows, {
681
+ const finalized = finalizeMethodRowsWithBaseline(crapRows, {
579
682
  requireCoverage,
580
683
  coverageAvailable,
684
+ touched,
685
+ baselineByKey,
581
686
  });
582
687
  return {
583
688
  skippedFileNoCoverage: false,
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,20 @@ 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.30.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.29.0...mandrel-v2.30.0) (2026-08-04)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * **baselines:** carry provenanceStamped through the legacy CRAP projection ([#4986](https://github.com/dsj1984/mandrel/issues/4986)) ([#4987](https://github.com/dsj1984/mandrel/issues/4987)) ([921b521](https://github.com/dsj1984/mandrel/commit/921b521f2094c961fec84acca1f90e068cb77798))
24
+
25
+ ## [2.29.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.28.0...mandrel-v2.29.0) (2026-08-03)
26
+
27
+
28
+ ### Added
29
+
30
+ * 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))
31
+
18
32
  ## [2.28.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.27.0...mandrel-v2.28.0) (2026-08-03)
19
33
 
20
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.28.0",
3
+ "version": "2.30.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/",