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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * coverage-capture-fullscope.js — the full-scope (pre-#4981, and still the
3
+ * default) capture path for `coverage-capture.js`.
4
+ *
5
+ * Hoisted out of the CLI shell's `runCoverageCapture` verbatim (Story
6
+ * #4981) so that function's cyclomatic complexity does not grow with the
7
+ * incremental-mode branch alongside it — this is a relocation, not new
8
+ * logic; behaviour is byte-for-byte the pre-#4981 body.
9
+ */
10
+ import path from 'node:path';
11
+ import { anyChangedUnderTargets } from './coverage-capture.js';
12
+
13
+ /**
14
+ * Run the `--skip-when-no-crap-files` check (when requested), the
15
+ * content-digest freshness probe, and — when stale — the full-repo
16
+ * `npm run test:coverage` capture + stamp write.
17
+ *
18
+ * @param {{
19
+ * crap: object,
20
+ * coverage: object,
21
+ * args: { skipWhenNoCrapFiles: boolean, ref: string, cwd: string },
22
+ * getChangedFilesImpl: Function,
23
+ * isCoverageFreshImpl: Function,
24
+ * runCaptureImpl: Function,
25
+ * computeContentDigestImpl: Function,
26
+ * writeCaptureStampImpl: Function,
27
+ * logger: { info: Function, warn: Function, error: Function },
28
+ * }} opts
29
+ * @returns {number} process exit code
30
+ */
31
+ export function runFullScopeCapture({
32
+ crap,
33
+ coverage,
34
+ args,
35
+ getChangedFilesImpl,
36
+ isCoverageFreshImpl,
37
+ runCaptureImpl,
38
+ computeContentDigestImpl,
39
+ writeCaptureStampImpl,
40
+ logger,
41
+ }) {
42
+ if (args.skipWhenNoCrapFiles) {
43
+ let changed;
44
+ try {
45
+ changed = getChangedFilesImpl({ ref: args.ref, cwd: args.cwd });
46
+ } catch (err) {
47
+ // A bad ref must not silently relax the gate. Fall through to the
48
+ // freshness check so coverage still gets captured if needed.
49
+ logger.warn(
50
+ `[coverage-capture] ⚠ ${err?.message ?? err} — falling back to freshness check.`,
51
+ );
52
+ changed = null;
53
+ }
54
+ if (changed && !anyChangedUnderTargets(changed, crap.targetDirs)) {
55
+ logger.info(
56
+ `[coverage-capture] No changed files under [${crap.targetDirs.join(', ')}] — skipping capture.`,
57
+ );
58
+ return 0;
59
+ }
60
+ }
61
+
62
+ const freshness = isCoverageFreshImpl({
63
+ coveragePath: crap.coveragePath,
64
+ targetDirs: crap.targetDirs,
65
+ cwd: args.cwd,
66
+ });
67
+ if (freshness.fresh) {
68
+ logger.info(
69
+ `[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} — skipping capture.`,
70
+ );
71
+ return 0;
72
+ }
73
+
74
+ logger.info(
75
+ `[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.reason}; running npm run test:coverage…`,
76
+ );
77
+ const code = runCaptureImpl({
78
+ cwd: args.cwd,
79
+ timeoutMs: coverage?.timeoutMs,
80
+ log: (m) => logger.info(m),
81
+ });
82
+ if (code !== 0) {
83
+ logger.error(
84
+ `[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
85
+ );
86
+ return code;
87
+ }
88
+
89
+ // Persist the content digest next to the fresh artifact so subsequent
90
+ // freshness checks are content-aware (mtime churn from branch switches no
91
+ // longer invalidates). Best-effort — a missing stamp just means the next
92
+ // check falls back to the mtime heuristic.
93
+ const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
94
+ if (
95
+ digest &&
96
+ writeCaptureStampImpl({
97
+ cwd: args.cwd,
98
+ coveragePath: crap.coveragePath,
99
+ digest,
100
+ })
101
+ ) {
102
+ logger.info('[coverage-capture] Wrote content-digest capture stamp.');
103
+ }
104
+ return code;
105
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * coverage-capture-incremental.js — the incremental-mode capture path for
3
+ * `coverage-capture.js` (Story #4981).
4
+ *
5
+ * Split into its own module (rather than added inline to the CLI shell) so
6
+ * the Story's opt-in branch lands as new code, not a same-file expansion of
7
+ * `runCoverageCapture`. Every collaborator is injected — no seam differs
8
+ * from the ones `coverage-capture.js` already exposes on its `deps`
9
+ * parameter (`.agents/rules/test-seams.md` rules 1-2, 4).
10
+ */
11
+ import path from 'node:path';
12
+
13
+ /**
14
+ * Run the incremental capture path when
15
+ * `delivery.quality.gates.crap.incrementalCoverage.enabled` is true.
16
+ *
17
+ * Returns the process exit code when incremental mode handled the run
18
+ * (skip, capture, or a capture failure), or `null` when the caller should
19
+ * fall through to the full-scope path — either incremental mode is
20
+ * disabled, or the changed-files ref could not be resolved (a
21
+ * misconfiguration must not silently relax the gate).
22
+ *
23
+ * @param {{
24
+ * crap: object,
25
+ * coverage: object,
26
+ * args: { ref: string, cwd: string },
27
+ * getChangedFilesImpl: Function,
28
+ * filterFilesUnderTargetsImpl: Function,
29
+ * isCoverageFreshImpl: Function,
30
+ * runCaptureImpl: Function,
31
+ * computeContentDigestImpl: Function,
32
+ * writeCaptureStampImpl: Function,
33
+ * logger: { info: Function, warn: Function, error: Function },
34
+ * }} opts
35
+ * @returns {number | null}
36
+ */
37
+ export function tryIncrementalCapture({
38
+ crap,
39
+ coverage,
40
+ args,
41
+ getChangedFilesImpl,
42
+ filterFilesUnderTargetsImpl,
43
+ isCoverageFreshImpl,
44
+ runCaptureImpl,
45
+ computeContentDigestImpl,
46
+ writeCaptureStampImpl,
47
+ logger,
48
+ }) {
49
+ if (crap.incrementalCoverage?.enabled !== true) return null;
50
+
51
+ const ref = crap.incrementalCoverage.baseRef || args.ref;
52
+ let changed = null;
53
+ try {
54
+ changed = getChangedFilesImpl({ ref, cwd: args.cwd });
55
+ } catch (err) {
56
+ logger.warn(
57
+ `[coverage-capture] ⚠ incremental mode: ${err?.message ?? err} — falling back to full-scope capture.`,
58
+ );
59
+ return null;
60
+ }
61
+
62
+ const scopedFiles = filterFilesUnderTargetsImpl(changed, crap.targetDirs);
63
+ if (scopedFiles.length === 0) {
64
+ logger.info(
65
+ `[coverage-capture] Incremental mode: no changed files under [${crap.targetDirs.join(', ')}] vs ${ref} — skipping capture.`,
66
+ );
67
+ return 0;
68
+ }
69
+
70
+ const freshness = isCoverageFreshImpl({
71
+ coveragePath: crap.coveragePath,
72
+ targetDirs: crap.targetDirs,
73
+ cwd: args.cwd,
74
+ requireScope: 'incremental',
75
+ });
76
+ if (freshness.fresh) {
77
+ logger.info(
78
+ `[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} (incremental) — skipping capture.`,
79
+ );
80
+ return 0;
81
+ }
82
+
83
+ logger.info(
84
+ `[coverage-capture] Incremental mode: capturing coverage scoped to ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}]…`,
85
+ );
86
+ const code = runCaptureImpl({
87
+ cwd: args.cwd,
88
+ timeoutMs: coverage?.timeoutMs,
89
+ log: (m) => logger.info(m),
90
+ files: scopedFiles,
91
+ });
92
+ if (code !== 0) {
93
+ logger.error(
94
+ `[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
95
+ );
96
+ return code;
97
+ }
98
+
99
+ const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
100
+ if (
101
+ digest &&
102
+ writeCaptureStampImpl({
103
+ cwd: args.cwd,
104
+ coveragePath: crap.coveragePath,
105
+ digest,
106
+ scope: 'incremental',
107
+ files: scopedFiles,
108
+ ref,
109
+ })
110
+ ) {
111
+ logger.info(
112
+ '[coverage-capture] Wrote content-digest capture stamp (incremental scope).',
113
+ );
114
+ }
115
+ return code;
116
+ }
@@ -151,10 +151,20 @@ export function computeContentDigest(cwd, targetDirs, io = {}) {
151
151
  * write failure returns `false` rather than throwing — the worst case is a
152
152
  * fall back to the mtime heuristic on the next freshness check.
153
153
  *
154
+ * `scope` / `files` / `ref` are Story #4981 additions for incremental-mode
155
+ * capture. They are written to the stamp **only when the caller supplies
156
+ * `scope`** — the default (full-scope) call sites never pass it, so the
157
+ * emitted JSON stays the exact `{ digest, capturedAt }` shape byte-for-byte
158
+ * (AC-5). `isCoverageFresh` reads `scope` back to refuse letting a scoped
159
+ * stamp satisfy a full-scope freshness probe (AC-4).
160
+ *
154
161
  * @param {{
155
162
  * cwd: string,
156
163
  * coveragePath: string,
157
164
  * digest: string,
165
+ * scope?: 'full' | 'incremental',
166
+ * files?: string[],
167
+ * ref?: string,
158
168
  * writeFileSync?: typeof fs.writeFileSync,
159
169
  * }} opts
160
170
  * @returns {boolean} True when the stamp was written.
@@ -163,13 +173,20 @@ export function writeCaptureStamp({
163
173
  cwd,
164
174
  coveragePath,
165
175
  digest,
176
+ scope,
177
+ files,
178
+ ref,
166
179
  writeFileSync = fs.writeFileSync,
167
180
  }) {
168
181
  if (typeof digest !== 'string' || digest.length === 0) return false;
182
+ const payload = { digest, capturedAt: new Date().toISOString() };
183
+ if (scope !== undefined) payload.scope = scope;
184
+ if (Array.isArray(files)) payload.files = [...files].sort();
185
+ if (typeof ref === 'string' && ref.length > 0) payload.ref = ref;
169
186
  try {
170
187
  writeFileSync(
171
188
  captureStampPath(cwd, coveragePath),
172
- `${JSON.stringify({ digest, capturedAt: new Date().toISOString() }, null, 2)}\n`,
189
+ `${JSON.stringify(payload, null, 2)}\n`,
173
190
  );
174
191
  return true;
175
192
  } catch {
@@ -177,6 +194,29 @@ export function writeCaptureStamp({
177
194
  }
178
195
  }
179
196
 
197
+ /**
198
+ * Read a persisted capture stamp's digest and scope tag back into the shape
199
+ * `isCoverageFresh` needs, applying the Story #4981 scope-asymmetry rule
200
+ * (AC-4) in one place. Extracted so the parent function's own branching
201
+ * stays under the cyclomatic ceiling.
202
+ *
203
+ * @param {{digest?: unknown, scope?: unknown} | null} stamp
204
+ * @param {'full' | 'incremental'} requireScope
205
+ * @returns {{ digest: string } | { scopeMismatch: true } | null} `null`
206
+ * means the stamp is missing/unreadable/digest-less — fall through to the
207
+ * mtime heuristic.
208
+ */
209
+ function readStampForScope(stamp, requireScope) {
210
+ if (typeof stamp?.digest !== 'string' || stamp.digest.length === 0) {
211
+ return null;
212
+ }
213
+ const stampScope = stamp.scope === 'incremental' ? 'incremental' : 'full';
214
+ if (stampScope === 'incremental' && requireScope !== 'incremental') {
215
+ return { scopeMismatch: true };
216
+ }
217
+ return { digest: stamp.digest };
218
+ }
219
+
180
220
  /**
181
221
  * Decide whether the existing coverage artifact is "fresh".
182
222
  *
@@ -191,22 +231,32 @@ export function writeCaptureStamp({
191
231
  * under `targetDirs`. Missing files, missing target dirs, or any IO error
192
232
  * resolve to `false` so the caller captures rather than trusting stale data.
193
233
  *
234
+ * **Scope asymmetry (Story #4981, AC-4).** A stamp written by an incremental
235
+ * capture (`scope: 'incremental'`) only covers the files the diff touched —
236
+ * it must never satisfy a caller that requires the full-scope guarantee
237
+ * (`requireScope` defaults to `'full'`, matching every pre-existing caller
238
+ * byte-for-byte). A full-scope stamp (or a legacy stamp with no `scope`
239
+ * field, which predates this Story and is therefore full-scope by
240
+ * construction) satisfies either probe.
241
+ *
194
242
  * @param {{
195
243
  * coveragePath: string,
196
244
  * targetDirs: string[],
197
245
  * cwd: string,
246
+ * requireScope?: 'full' | 'incremental',
198
247
  * statSync?: typeof fs.statSync,
199
248
  * readdirSync?: typeof fs.readdirSync,
200
249
  * existsSync?: typeof fs.existsSync,
201
250
  * readFileSync?: typeof fs.readFileSync,
202
251
  * computeDigest?: typeof computeContentDigest,
203
252
  * }} opts
204
- * @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' }}
253
+ * @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' | 'scope-mismatch' }}
205
254
  */
206
255
  export function isCoverageFresh({
207
256
  coveragePath,
208
257
  targetDirs,
209
258
  cwd,
259
+ requireScope = 'full',
210
260
  statSync = fs.statSync,
211
261
  readdirSync = fs.readdirSync,
212
262
  existsSync = fs.existsSync,
@@ -224,10 +274,14 @@ export function isCoverageFresh({
224
274
  } catch {
225
275
  // Corrupt/unreadable stamp → fall through to the mtime heuristic.
226
276
  }
227
- if (typeof stamp?.digest === 'string' && stamp.digest.length > 0) {
277
+ const resolved = readStampForScope(stamp, requireScope);
278
+ if (resolved?.scopeMismatch) {
279
+ return { fresh: false, reason: 'scope-mismatch' };
280
+ }
281
+ if (resolved) {
228
282
  const current = computeDigest(cwd, targetDirs);
229
283
  if (typeof current === 'string' && current.length > 0) {
230
- return current === stamp.digest
284
+ return current === resolved.digest
231
285
  ? { fresh: true, reason: 'fresh' }
232
286
  : { fresh: false, reason: 'stale' };
233
287
  }
@@ -251,28 +305,42 @@ export function isCoverageFresh({
251
305
  : { fresh: false, reason: 'stale' };
252
306
  }
253
307
 
308
+ /**
309
+ * Narrow `changedFiles` to the subset that lives under one of `targetDirs`.
310
+ *
311
+ * Both inputs are forward-slash-normalised; `targetDirs` are matched as path
312
+ * prefixes followed by `/`. Shared by `anyChangedUnderTargets` (the pre-push
313
+ * fast-path boolean) and the incremental-coverage scope resolver (Story
314
+ * #4981), which needs the actual file list rather than a yes/no.
315
+ *
316
+ * @param {string[]} changedFiles
317
+ * @param {string[]} targetDirs
318
+ * @returns {string[]} Forward-slash-normalised matches, in `changedFiles` order.
319
+ */
320
+ export function filterFilesUnderTargets(changedFiles, targetDirs) {
321
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) return [];
322
+ if (!Array.isArray(targetDirs) || targetDirs.length === 0) return [];
323
+ const norms = targetDirs
324
+ .filter((d) => typeof d === 'string' && d.length > 0)
325
+ .map((d) => d.replace(/\\/g, '/').replace(/\/+$/, ''));
326
+ return changedFiles
327
+ .map((file) => String(file).replace(/\\/g, '/'))
328
+ .filter((f) => norms.some((dir) => f === dir || f.startsWith(`${dir}/`)));
329
+ }
330
+
254
331
  /**
255
332
  * Decide whether any of `changedFiles` lives under one of `targetDirs`.
256
333
  * Used by the pre-push fast-path so we can skip the (slow) coverage capture
257
334
  * when the push touches only files outside the CRAP scoring scope.
258
335
  *
259
- * Both inputs are forward-slash-normalised; `targetDirs` are matched as path
260
- * prefixes followed by `/`. An empty changed-file list returns `false`.
336
+ * An empty changed-file list returns `false`.
261
337
  *
262
338
  * @param {string[]} changedFiles
263
339
  * @param {string[]} targetDirs
264
340
  * @returns {boolean}
265
341
  */
266
342
  export function anyChangedUnderTargets(changedFiles, targetDirs) {
267
- if (!Array.isArray(changedFiles) || changedFiles.length === 0) return false;
268
- if (!Array.isArray(targetDirs) || targetDirs.length === 0) return false;
269
- const norms = targetDirs
270
- .filter((d) => typeof d === 'string' && d.length > 0)
271
- .map((d) => d.replace(/\\/g, '/').replace(/\/+$/, ''));
272
- return changedFiles.some((file) => {
273
- const f = String(file).replace(/\\/g, '/');
274
- return norms.some((dir) => f === dir || f.startsWith(`${dir}/`));
275
- });
343
+ return filterFilesUnderTargets(changedFiles, targetDirs).length > 0;
276
344
  }
277
345
 
278
346
  /**
@@ -295,11 +363,20 @@ export const COVERAGE_TIMEOUT_EXIT_CODE = 124;
295
363
  * `timeout(1)` convention exit code 124 so callers can pattern-match a
296
364
  * runaway runner without inspecting signal names.
297
365
  *
366
+ * `files` (Story #4981) scopes the spawn to a file list — `npm run
367
+ * test:coverage -- <files...>`, the standard npm convention for forwarding
368
+ * argv to the underlying script (which most test runners, including Node's
369
+ * own, treat as positional file filters). Omitted or empty means the
370
+ * default full-scope invocation, byte-identical to the pre-#4981 argv
371
+ * (AC-5); a non-empty list makes the scope observable on the emitted
372
+ * command line (AC-1).
373
+ *
298
374
  * @param {{
299
375
  * cwd: string,
300
376
  * timeoutMs?: number,
301
377
  * runner?: typeof spawnSync,
302
378
  * log?: (m: string) => void,
379
+ * files?: string[] | null,
303
380
  * }} opts
304
381
  * @returns {number}
305
382
  */
@@ -308,8 +385,15 @@ export function runCapture({
308
385
  timeoutMs,
309
386
  runner = spawnSync,
310
387
  log = () => {},
388
+ files = null,
311
389
  } = {}) {
312
- log('[coverage-capture] npm run test:coverage');
390
+ const scopedFiles = Array.isArray(files) && files.length > 0 ? files : null;
391
+ const args = [
392
+ 'run',
393
+ 'test:coverage',
394
+ ...(scopedFiles ? ['--', ...scopedFiles] : []),
395
+ ];
396
+ log(`[coverage-capture] ▶ npm ${args.join(' ')}`);
313
397
  const spawnOpts = {
314
398
  cwd,
315
399
  stdio: 'inherit',
@@ -323,7 +407,7 @@ export function runCapture({
323
407
  ) {
324
408
  spawnOpts.timeout = timeoutMs;
325
409
  }
326
- const res = runner('npm', ['run', 'test:coverage'], spawnOpts);
410
+ const res = runner('npm', args, spawnOpts);
327
411
  // A timeout-induced kill surfaces as `signal: 'SIGKILL'` (or, on some
328
412
  // platforms, as a non-numeric status). Either signal indicates the
329
413
  // watchdog tripped — surface the GNU `timeout` convention 124 so the
@@ -0,0 +1,46 @@
1
+ /**
2
+ * crap-baseline-index.js — per-file method-identity indexing over a CRAP
3
+ * baseline's rows (Story #4981).
4
+ *
5
+ * Split out of `crap-engine.js` (rather than added inline) so the join
6
+ * support lands as a new file, not a same-file expansion of the module the
7
+ * scoring kernel already lives in. Deliberately dependency-free: both
8
+ * `crap-engine.js` (which needs `methodIdentityKey` inside
9
+ * `finalizeMethodRowsWithBaseline`) and `baselines/kinds/crap.js` (which
10
+ * needs the file-scoped index) import FROM here, and this module imports
11
+ * from neither — the only shape that keeps the edge one-directional.
12
+ */
13
+
14
+ /**
15
+ * Per-file half of the method-identity key `baselines/kinds/crap.js`'s
16
+ * `crapRowKey` composes with the file path (`${path}::${method}@${startLine}`).
17
+ * The path component is redundant once a row set is already narrowed to one
18
+ * file, which is exactly what `indexBaselineRowsByFile` does below.
19
+ *
20
+ * @param {{method: string, startLine: number}} row
21
+ * @returns {string}
22
+ */
23
+ export function methodIdentityKey(row) {
24
+ return `${row.method}@${row.startLine}`;
25
+ }
26
+
27
+ /**
28
+ * Index baseline rows (accepts either the `{file, method, startLine, crap}`
29
+ * legacy shape `compareCrap`/`scanAndScore` use, or the on-disk `{path, ...}`
30
+ * shape) by file, then by `methodIdentityKey`, for O(1) per-method lookup —
31
+ * exactly the shape `crap-engine.js#finalizeMethodRowsWithBaseline`'s
32
+ * `baselineByKey` expects.
33
+ *
34
+ * @param {Array<{file?: string, path?: string, method: string, startLine: number, crap: number}>} baselineRows
35
+ * @returns {Map<string, Map<string, {crap: number}>>} file → (method@startLine → row)
36
+ */
37
+ export function indexBaselineRowsByFile(baselineRows) {
38
+ const byFile = new Map();
39
+ for (const row of baselineRows ?? []) {
40
+ const file = row?.file ?? row?.path;
41
+ if (typeof file !== 'string' || file.length === 0) continue;
42
+ if (!byFile.has(file)) byFile.set(file, new Map());
43
+ byFile.get(file).set(methodIdentityKey(row), row);
44
+ }
45
+ return byFile;
46
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * crap-baseline-join.js — the incremental-coverage CRAP join (Story #4981).
3
+ *
4
+ * Split out of `crap-engine.js` into its own file (rather than added inline)
5
+ * so the Story's join logic lands as new code, not a same-file expansion of
6
+ * the pre-existing scoring kernel. Imports its coordinate/formula
7
+ * primitives from `crap-coordinates.js` (not `crap-engine.js`) and its
8
+ * identity key from `crap-baseline-index.js` — both leaf modules — so this
9
+ * file stays a one-directional consumer with no edge back into
10
+ * `crap-engine.js`.
11
+ */
12
+ import { methodIdentityKey } from './crap-baseline-index.js';
13
+ import {
14
+ COORDINATE_ORIGINAL,
15
+ COORDINATE_TRANSPILED,
16
+ crapFormula,
17
+ } from './crap-coordinates.js';
18
+
19
+ /**
20
+ * Apply the standard `requireCoverage` policy to a single raw method row.
21
+ * The per-row half of `crap-engine.js#finalizeMethodRows`'s loop body,
22
+ * extracted (Story #4981) so `finalizeMethodRowsWithBaseline`, below, can
23
+ * apply the exact same per-row policy to a method whose file was NOT in the
24
+ * diff scope but whose baseline row could not be found — the fail-closed
25
+ * path AC-3 requires.
26
+ *
27
+ * @param {object} mr A raw row from `methodRowsFromReport`.
28
+ * @param {{requireCoverage: boolean, coverageAvailable: boolean}} opts
29
+ * @returns {{ resolved: boolean, row: object | null }} `row: null` means the
30
+ * method is skipped-and-counted; `resolved` tracks the join outcome
31
+ * (independent of whether the row survives the skip policy).
32
+ */
33
+ export function resolveRawRow(mr, { requireCoverage, coverageAvailable }) {
34
+ const unresolved = mr.crap === null || mr.coverage === null;
35
+ const resolved = !unresolved;
36
+ // Unjoinable is not untested (Story #4901).
37
+ if (
38
+ mr.coordinateSystem === COORDINATE_TRANSPILED ||
39
+ (unresolved && (requireCoverage || !coverageAvailable))
40
+ ) {
41
+ return { resolved, row: null };
42
+ }
43
+ const coverage = unresolved ? 0 : mr.coverage;
44
+ const crap = unresolved ? crapFormula(mr.cyclomatic, 0) : mr.crap;
45
+ // Everything the scan decided is carried forward; this step overrides only
46
+ // what its own policy resolves. Spreading rather than re-listing each field
47
+ // is why the row's identity marker (Story #4969) and its provenance
48
+ // (Story #4866) survive the step without a line each to remember them —
49
+ // a hand-rebuilt row is how a marker silently stops reaching the baseline.
50
+ return {
51
+ resolved,
52
+ row: {
53
+ ...mr,
54
+ coverage,
55
+ crap,
56
+ coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Incremental-mode join (Story #4981): resolve a file's raw method rows
63
+ * against its committed CRAP-baseline rows instead of requiring fresh
64
+ * coverage, for a file the diff did NOT touch.
65
+ *
66
+ * Rationale: `coverage-capture`'s incremental mode only runs the consumer's
67
+ * test suite scoped to the diff, so an untouched file's coverage entry may
68
+ * legitimately be absent even though nothing about that file's methods
69
+ * changed. Requiring a fresh join for it would either (a) skip-and-count
70
+ * every one of its methods under `requireCoverage: true`, weakening the
71
+ * gate's signal for the vast majority of the tree on every run, or (b) score
72
+ * them at an invented 0% under `requireCoverage: false`, manufacturing a
73
+ * maximal CRAP for code the diff never touched. Neither is a measurement.
74
+ *
75
+ * The join key is `${method}@${startLine}` — the per-file half of the
76
+ * composite identity `kinds/crap.js#crapRowKey` uses for the full baseline
77
+ * compare (`${path}::${method}@${startLine}`); callers pass in a
78
+ * per-file-scoped `baselineByKey` map so this function stays path-agnostic.
79
+ *
80
+ * **Fail-closed (AC-3).** `touched: true` (the file WAS in the diff) or a
81
+ * missing/empty `baselineByKey` reproduces `crap-engine.js#finalizeMethodRows`
82
+ * exactly — this is the pre-#4981 per-row policy, so a caller that never
83
+ * opts in sees byte-identical behaviour (AC-5). For an untouched file, a
84
+ * method whose baseline row cannot be found (new method, moved line, or a
85
+ * baseline that simply never carried it) is NOT invented — it falls back to
86
+ * the same per-row `requireCoverage` skip-and-count policy, via the shared
87
+ * `resolveRawRow`. A method whose coordinate system is transpiled is never
88
+ * resolved from the baseline either, for the same un-joinable reason
89
+ * `resolveRawRow` excludes it (Story #4901).
90
+ *
91
+ * @param {Array<object>} rawRows Rows from `methodRowsFromReport`, all for
92
+ * the SAME file.
93
+ * @param {{
94
+ * requireCoverage?: boolean,
95
+ * coverageAvailable?: boolean,
96
+ * touched?: boolean,
97
+ * baselineByKey?: Map<string, {crap: number}> | null,
98
+ * }} [opts]
99
+ * @returns {{
100
+ * rows: Array<object>,
101
+ * skippedMethodsNoCoverage: number,
102
+ * resolvedMethods: number,
103
+ * totalMethods: number,
104
+ * }}
105
+ */
106
+ export function finalizeMethodRowsWithBaseline(
107
+ rawRows,
108
+ {
109
+ requireCoverage = true,
110
+ coverageAvailable = true,
111
+ touched = true,
112
+ baselineByKey = null,
113
+ } = {},
114
+ ) {
115
+ const useBaseline = !touched && baselineByKey && baselineByKey.size > 0;
116
+ const rows = [];
117
+ let skippedMethodsNoCoverage = 0;
118
+ let resolvedMethods = 0;
119
+ let totalMethods = 0;
120
+ for (const mr of rawRows ?? []) {
121
+ totalMethods += 1;
122
+ if (useBaseline) {
123
+ const base =
124
+ mr.coordinateSystem === COORDINATE_TRANSPILED
125
+ ? undefined
126
+ : baselineByKey.get(methodIdentityKey(mr));
127
+ if (base && typeof base.crap === 'number') {
128
+ resolvedMethods += 1;
129
+ rows.push({
130
+ ...mr,
131
+ coverage: null,
132
+ crap: base.crap,
133
+ resolvedFromBaseline: true,
134
+ coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
135
+ });
136
+ continue;
137
+ }
138
+ }
139
+ // Touched file, no baseline scope, or no baseline row for this method —
140
+ // fail closed to the standard policy rather than inventing a verdict.
141
+ const resolution = resolveRawRow(mr, {
142
+ requireCoverage,
143
+ coverageAvailable,
144
+ });
145
+ if (resolution.resolved) resolvedMethods += 1;
146
+ if (resolution.row === null) {
147
+ skippedMethodsNoCoverage += 1;
148
+ continue;
149
+ }
150
+ rows.push(resolution.row);
151
+ }
152
+ return { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods };
153
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * crap-coordinates.js — the CRAP kernel's two line-coordinate-system
3
+ * constants and the CRAP formula itself.
4
+ *
5
+ * Deliberately dependency-free (Story #4981 split, pulled out of
6
+ * `crap-engine.js`): both `crap-engine.js` and `crap-baseline-join.js`
7
+ * need these, and having either import them from the other would close a
8
+ * cycle. `crap-engine.js` re-exports the two constants so its existing
9
+ * importers (`baselines/kinds/crap.js`, `crap-utils.js`, tests) are
10
+ * unaffected.
11
+ */
12
+
13
+ /**
14
+ * The two line coordinate systems a CRAP row's `startLine` can be expressed
15
+ * in (Story #4866).
16
+ *
17
+ * `original` — the coordinates of the file a reader can open, and the ones
18
+ * istanbul's `fnMap` is keyed against. A JavaScript source is already in this
19
+ * system; a TS/TSX source reaches it only through a successful sourcemap
20
+ * lookup.
21
+ *
22
+ * `transpiled` — escomplex's own coordinates over the emitted JavaScript,
23
+ * kept only when the sourcemap has no entry originating on the method's
24
+ * generated line. Such a row is NOT an original-source coordinate and must
25
+ * never be presented as one: it cannot be joined to coverage, and it cannot
26
+ * be compared against a baseline row carrying the other provenance.
27
+ */
28
+ export const COORDINATE_ORIGINAL = 'original';
29
+ export const COORDINATE_TRANSPILED = 'transpiled';
30
+
31
+ /**
32
+ * CRAP formula, exported for callers that need to derive target scores or
33
+ * `fixGuidance` values without re-scoring source.
34
+ *
35
+ * @param {number} cyclomatic
36
+ * @param {number} coverage In [0, 1].
37
+ * @returns {number}
38
+ */
39
+ export function crapFormula(cyclomatic, coverage) {
40
+ const c = Number(cyclomatic) || 0;
41
+ const cov = Math.max(0, Math.min(1, Number(coverage) || 0));
42
+ return c * c * (1 - cov) ** 3 + c;
43
+ }