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.
@@ -183,6 +183,9 @@ top-level keys are validation errors.
183
183
  | `quality.gates.crap.refreshTag` | No | `string` | — | — |
184
184
  | `quality.gates.crap.refreshTimeoutMs` | No | `integer` | — | Bounded timeout (ms) for `npm run crap:update` spawned by the baseline-attribution refresh path. Mirrors `coverage.timeoutMs`: a SIGKILL fired at the budget boundary maps to exit 124 so the close orchestrator can flip the Story to `agent::blocked`. Default 60000 (Story #2165). |
185
185
  | `quality.gates.crap.ignoreGlobs` | No | `array<string>` | — | Minimatch glob patterns matched against the canonicalised repo-relative path of each discovered file. Files matching any pattern are excluded from CRAP discovery before scoring. Orthogonal to `components` (grouping) — a file excluded here never appears in any component bucket. Absent or empty preserves the existing IGNORED_DIRS-only behaviour (Story #3217). |
186
+ | `quality.gates.crap.incrementalCoverage` | No | `object` | — | Story #4981 — opt-in incremental coverage-capture + CRAP-join scoping. Default (key absent) preserves today's full-repo behaviour byte-for-byte. When `enabled: true`, `coverage-capture.js` scopes `npm run test:coverage` to the files changed against `baseRef` (default: the gate's own `--ref` / `main`), and the CRAP join treats a method in a file the diff did not touch as resolved by its committed baseline row instead of requiring fresh coverage for it. |
187
+ | `quality.gates.crap.incrementalCoverage.enabled` | No | `boolean` | — | — |
188
+ | `quality.gates.crap.incrementalCoverage.baseRef` | No | `string` | — | — |
186
189
  | `quality.gates.maintainability` | No | `object` | — | Nested configuration block. |
187
190
  | `quality.gates.maintainability.enabled` | No | `boolean` | — | — |
188
191
  | `quality.gates.maintainability.baselinePath` | No | `string` | — | — |
@@ -820,6 +820,20 @@
820
820
  "minLength": 1
821
821
  },
822
822
  "description": "Minimatch glob patterns matched against the canonicalised repo-relative path of each discovered file. Files matching any pattern are excluded from CRAP discovery before scoring. Orthogonal to `components` (grouping) — a file excluded here never appears in any component bucket. Absent or empty preserves the existing IGNORED_DIRS-only behaviour (Story #3217)."
823
+ },
824
+ "incrementalCoverage": {
825
+ "type": "object",
826
+ "properties": {
827
+ "enabled": {
828
+ "type": "boolean"
829
+ },
830
+ "baseRef": {
831
+ "type": "string",
832
+ "minLength": 1
833
+ }
834
+ },
835
+ "additionalProperties": false,
836
+ "description": "Story #4981 — opt-in incremental coverage-capture + CRAP-join scoping. Default (key absent) preserves today's full-repo behaviour byte-for-byte. When `enabled: true`, `coverage-capture.js` scopes `npm run test:coverage` to the files changed against `baseRef` (default: the gate's own `--ref` / `main`), and the CRAP join treats a method in a file the diff did not touch as resolved by its committed baseline row instead of requiring fresh coverage for it."
823
837
  }
824
838
  },
825
839
  "additionalProperties": false
@@ -21,17 +21,18 @@
21
21
  * caller MUST surface this — silently passing here would defeat the
22
22
  * CRAP gate's `requireCoverage: true` policy.
23
23
  */
24
- import path from 'node:path';
25
24
  import { getChangedFiles } from './lib/changed-files.js';
26
25
  import { isDirectInvocation } from './lib/cli-utils.js';
27
26
  import { getQuality, resolveConfig } from './lib/config-resolver.js';
28
27
  import {
29
- anyChangedUnderTargets,
30
28
  computeContentDigest,
29
+ filterFilesUnderTargets,
31
30
  isCoverageFresh,
32
31
  runCapture,
33
32
  writeCaptureStamp,
34
33
  } from './lib/coverage-capture.js';
34
+ import { runFullScopeCapture } from './lib/coverage-capture-fullscope.js';
35
+ import { tryIncrementalCapture } from './lib/coverage-capture-incremental.js';
35
36
 
36
37
  import { Logger } from './lib/Logger.js';
37
38
  import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
@@ -78,6 +79,7 @@ export function parseArgs(argv) {
78
79
  * runCaptureImpl?: typeof runCapture,
79
80
  * computeContentDigestImpl?: typeof computeContentDigest,
80
81
  * writeCaptureStampImpl?: typeof writeCaptureStamp,
82
+ * filterFilesUnderTargetsImpl?: typeof filterFilesUnderTargets,
81
83
  * logger?: { info: Function, warn: Function, error: Function },
82
84
  * }} [deps]
83
85
  * @returns {number} process exit code
@@ -93,6 +95,7 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
93
95
  runCaptureImpl = runCapture,
94
96
  computeContentDigestImpl = computeContentDigest,
95
97
  writeCaptureStampImpl = writeCaptureStamp,
98
+ filterFilesUnderTargetsImpl = filterFilesUnderTargets,
96
99
  logger = Logger,
97
100
  } = deps;
98
101
  const args = parseArgs(argv);
@@ -120,69 +123,35 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
120
123
  return 1;
121
124
  }
122
125
 
123
- if (args.skipWhenNoCrapFiles) {
124
- let changed;
125
- try {
126
- changed = getChangedFilesImpl({ ref: args.ref, cwd: args.cwd });
127
- } catch (err) {
128
- // A bad ref must not silently relax the gate. Fall through to the
129
- // freshness check so coverage still gets captured if needed.
130
- logger.warn(
131
- `[coverage-capture] ⚠ ${err?.message ?? err} — falling back to freshness check.`,
132
- );
133
- changed = null;
134
- }
135
- if (changed && !anyChangedUnderTargets(changed, crap.targetDirs)) {
136
- logger.info(
137
- `[coverage-capture] No changed files under [${crap.targetDirs.join(', ')}] — skipping capture.`,
138
- );
139
- return 0;
140
- }
141
- }
142
-
143
- const freshness = isCoverageFreshImpl({
144
- coveragePath: crap.coveragePath,
145
- targetDirs: crap.targetDirs,
146
- cwd: args.cwd,
126
+ // Story #4981 — incremental mode, opt-in via
127
+ // `delivery.quality.gates.crap.incrementalCoverage.enabled`. `null` means
128
+ // "not applicable" (disabled, or a ref-resolution error) — fall through to
129
+ // the full-scope path below rather than silently skipping capture.
130
+ const incrementalResult = tryIncrementalCapture({
131
+ crap,
132
+ coverage,
133
+ args,
134
+ getChangedFilesImpl,
135
+ filterFilesUnderTargetsImpl,
136
+ isCoverageFreshImpl,
137
+ runCaptureImpl,
138
+ computeContentDigestImpl,
139
+ writeCaptureStampImpl,
140
+ logger,
147
141
  });
148
- if (freshness.fresh) {
149
- logger.info(
150
- `[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} — skipping capture.`,
151
- );
152
- return 0;
153
- }
142
+ if (incrementalResult !== null) return incrementalResult;
154
143
 
155
- logger.info(
156
- `[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.reason}; running npm run test:coverage…`,
157
- );
158
- const code = runCaptureImpl({
159
- cwd: args.cwd,
160
- timeoutMs: coverage?.timeoutMs,
161
- log: (m) => logger.info(m),
144
+ return runFullScopeCapture({
145
+ crap,
146
+ coverage,
147
+ args,
148
+ getChangedFilesImpl,
149
+ isCoverageFreshImpl,
150
+ runCaptureImpl,
151
+ computeContentDigestImpl,
152
+ writeCaptureStampImpl,
153
+ logger,
162
154
  });
163
- if (code !== 0) {
164
- logger.error(
165
- `[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
166
- );
167
- return code;
168
- }
169
-
170
- // Persist the content digest next to the fresh artifact so subsequent
171
- // freshness checks are content-aware (mtime churn from branch switches no
172
- // longer invalidates). Best-effort — a missing stamp just means the next
173
- // check falls back to the mtime heuristic.
174
- const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
175
- if (
176
- digest &&
177
- writeCaptureStampImpl({
178
- cwd: args.cwd,
179
- coveragePath: crap.coveragePath,
180
- digest,
181
- })
182
- ) {
183
- logger.info('[coverage-capture] Wrote content-digest capture stamp.');
184
- }
185
- return code;
186
155
  }
187
156
 
188
157
  // cli-opt-out: synchronous main returns an exit code that is forwarded via process.exit(code); runAsCli's async-main signature does not preserve the result code.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * crap-preview-incremental.js — resolve `runCrapPreview`'s incremental-join
3
+ * `scanAndScore` input (Story #4981).
4
+ *
5
+ * Split into its own file (rather than added inline to `preview-gates.js`)
6
+ * so the Story's opt-in wiring lands as new code, not a same-file expansion
7
+ * of the pre-existing preview runner.
8
+ */
9
+ import { getChangedFiles } from '../changed-files.js';
10
+
11
+ /**
12
+ * Resolve the `incremental` option `scanAndScore` (`crap-utils.js`) expects,
13
+ * or `null` when incremental mode is disabled or the changed-files ref could
14
+ * not be resolved — a resolution failure falls back to full-scope rather
15
+ * than silently relaxing the gate.
16
+ *
17
+ * @param {{
18
+ * crap: { incrementalCoverage?: { enabled?: boolean, baseRef?: string } },
19
+ * diffRef: string | null,
20
+ * cwd: string,
21
+ * baselineRows: Array<object>,
22
+ * getChangedFilesImpl?: typeof getChangedFiles,
23
+ * }} opts
24
+ * @returns {{ touchedFiles: Set<string>, baselineRows: Array<object> } | null}
25
+ */
26
+ export function resolveCrapPreviewIncremental({
27
+ crap,
28
+ diffRef,
29
+ cwd,
30
+ baselineRows,
31
+ getChangedFilesImpl = getChangedFiles,
32
+ }) {
33
+ if (crap.incrementalCoverage?.enabled !== true) return null;
34
+ const baseRef = crap.incrementalCoverage.baseRef || diffRef || 'main';
35
+ try {
36
+ const touchedFiles = new Set(getChangedFilesImpl({ ref: baseRef, cwd }));
37
+ return { touchedFiles, baselineRows };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * crap-preview-scan.js — the scan → compare → report tail of
3
+ * `preview-gates.js#runCrapPreview`, once its baseline is loaded and judged
4
+ * compatible.
5
+ *
6
+ * Hoisted out of `runCrapPreview` verbatim (Story #4981) so that function's
7
+ * cyclomatic complexity does not grow with the incremental-mode wiring
8
+ * alongside it — this is a relocation of pre-existing logic, not new
9
+ * behaviour; the incremental resolution itself is the only Story #4981
10
+ * addition (see `resolveCrapPreviewIncremental`).
11
+ */
12
+ import path from 'node:path';
13
+ import { loadCoverage } from '../coverage-utils.js';
14
+ import {
15
+ KERNEL_VERSION,
16
+ resolveEscomplexVersion,
17
+ scanAndScore,
18
+ } from '../crap-utils.js';
19
+ import { resolveCrapPreviewIncremental } from './crap-preview-incremental.js';
20
+ import { resolveCrapEnvOverrides } from './env-overrides.js';
21
+ import {
22
+ assessComparisonBasis,
23
+ buildCrapReport,
24
+ compareCrap,
25
+ filterRowsByFileScope,
26
+ suppressVerdicts,
27
+ } from './kinds/crap.js';
28
+
29
+ /**
30
+ * Narrow a CRAP baseline to the rows whose file path is in `scopeSet`,
31
+ * or return all rows when no diff-scope filter is active.
32
+ *
33
+ * @param {{ rows: object[] }} baseline
34
+ * @param {Set<string>|null|undefined} scopeSet
35
+ * @returns {object[]}
36
+ */
37
+ function resolveBaselineRows(baseline, scopeSet) {
38
+ return scopeSet
39
+ ? filterRowsByFileScope(baseline.rows, scopeSet)
40
+ : baseline.rows;
41
+ }
42
+
43
+ /**
44
+ * Return true when the CRAP compare result contains regressions or new
45
+ * violations — i.e. when the preview gate should exit non-zero.
46
+ *
47
+ * @param {{ regressions: number, newViolations: number }} result
48
+ * @returns {boolean}
49
+ */
50
+ function hasCrapRegressions(result) {
51
+ return result.regressions > 0 || result.newViolations > 0;
52
+ }
53
+
54
+ /**
55
+ * Scan `crap.targetDirs`, compare against the (already compatibility-judged)
56
+ * baseline, and build the `--json` envelope — the exact pre-#4981 tail of
57
+ * `runCrapPreview`, now including the Story #4981 incremental-join opt-in.
58
+ *
59
+ * @param {{
60
+ * crap: object,
61
+ * cwd: string,
62
+ * scopeSet: Set<string>|null,
63
+ * scope: string,
64
+ * diffRef: string|null,
65
+ * baseline: { rows: object[] },
66
+ * }} opts
67
+ * @returns {Promise<{ exitCode: number, envelope: object }>}
68
+ */
69
+ export async function computeCrapPreviewScan({
70
+ crap,
71
+ cwd,
72
+ scopeSet,
73
+ scope,
74
+ diffRef,
75
+ baseline,
76
+ }) {
77
+ const targetDirs = Array.isArray(crap.targetDirs) ? crap.targetDirs : [];
78
+ const crapIgnoreGlobs = Array.isArray(crap.ignoreGlobs)
79
+ ? crap.ignoreGlobs
80
+ : [];
81
+ const requireCoverage = crap.requireCoverage !== false;
82
+ const coveragePath = crap.coveragePath ?? 'coverage/coverage-final.json';
83
+ const coverage = loadCoverage(path.resolve(cwd, coveragePath));
84
+ // Story #4731 (AC-3) — feed the CRAP regression compare the *configured*
85
+ // crap tolerance (env override → `gates.crap.tolerance` → framework default)
86
+ // so `compareCrap` demotes positive deltas at or under tolerance rather than
87
+ // failing on any positive delta; over-tolerance deltas still fail. This keeps
88
+ // the pre-commit/pre-push preview aligned with the authoritative gate.
89
+ const { newMethodCeiling, tolerance } = resolveCrapEnvOverrides(
90
+ crap,
91
+ process.env,
92
+ );
93
+ const incremental = resolveCrapPreviewIncremental({
94
+ crap,
95
+ diffRef,
96
+ cwd,
97
+ baselineRows: baseline.rows,
98
+ });
99
+ const scan = await scanAndScore({
100
+ targetDirs,
101
+ coverage,
102
+ requireCoverage,
103
+ cwd,
104
+ scopeFiles: scopeSet,
105
+ ignoreGlobs: crapIgnoreGlobs,
106
+ incremental,
107
+ });
108
+ const baselineRows = resolveBaselineRows(baseline, scopeSet);
109
+ const result = compareCrap({
110
+ currentRows: scan.rows,
111
+ baselineRows,
112
+ newMethodCeiling,
113
+ tolerance,
114
+ });
115
+ const envelope = buildCrapReport({
116
+ compareResult: result,
117
+ scanSummary: scan,
118
+ kernelVersion: KERNEL_VERSION,
119
+ escomplexVersion: resolveEscomplexVersion(),
120
+ newMethodCeiling,
121
+ scopeInfo: { scope, diffRef },
122
+ });
123
+ // Story #4866 (AC-5): above the drifted-row ratio the basis is self-
124
+ // evidently unsound and every per-method verdict below it is an artefact of
125
+ // a mis-keyed join. Say so once, by name, and fail open.
126
+ const basis = assessComparisonBasis(result);
127
+ if (!basis.sound) {
128
+ return {
129
+ exitCode: 0,
130
+ envelope: suppressVerdicts(envelope, basis.diagnostic),
131
+ };
132
+ }
133
+ const exitCode = hasCrapRegressions(result) ? 1 : 0;
134
+ return { exitCode, envelope };
135
+ }
@@ -245,6 +245,19 @@ function crapRowKey(row) {
245
245
  return `${row.path}::${row.method}@${row.startLine}`;
246
246
  }
247
247
 
248
+ // `methodIdentityKey` / `indexBaselineRowsByFile` (Story #4981) live in
249
+ // crap-baseline-index.js, not here — `crap-utils.js#scanAndScore` needs them
250
+ // to build the incremental join's per-file baseline lookup, and crap-utils.js
251
+ // already imports `getCrapBaseline` FROM this module. Defining them here and
252
+ // importing them into crap-utils.js would close that edge into a cycle
253
+ // (kinds/crap.js → crap-utils.js → kinds/crap.js). Re-exported here so
254
+ // existing importers of this module keep a single door to the identity key
255
+ // `crapRowKey` composes with the file path.
256
+ export {
257
+ indexBaselineRowsByFile,
258
+ methodIdentityKey,
259
+ } from '../../crap-baseline-index.js';
260
+
248
261
  /**
249
262
  * Pure stabilizer for s-stability-epsilon (Story #1964). CRAP rows match
250
263
  * by the composite `path::method@startLine` identity. Sub-epsilon CRAP
@@ -21,20 +21,11 @@ import path from 'node:path';
21
21
 
22
22
  import { resolvePreviewScope } from '../changed-files.js';
23
23
  import { getBaselines, getQuality, resolveConfig } from '../config-resolver.js';
24
- import { loadCoverage } from '../coverage-utils.js';
25
- import {
26
- KERNEL_VERSION,
27
- resolveEscomplexVersion,
28
- scanAndScore,
29
- } from '../crap-utils.js';
24
+ import { KERNEL_VERSION, resolveEscomplexVersion } from '../crap-utils.js';
30
25
  import { calculateAll, scanDirectory } from '../maintainability-utils.js';
31
- import { resolveCrapEnvOverrides } from './env-overrides.js';
26
+ import { computeCrapPreviewScan } from './crap-preview-scan.js';
32
27
  import {
33
28
  assertBaselineCompatible,
34
- assessComparisonBasis,
35
- buildCrapReport,
36
- compareCrap,
37
- filterRowsByFileScope,
38
29
  INCOMPATIBLE_BASELINE_DIAGNOSTIC,
39
30
  loadCrapBaseline,
40
31
  suppressVerdicts,
@@ -110,31 +101,6 @@ function compareScores(scores, baseline, tolerance) {
110
101
  return { regressions, newFiles, improvements, regressedFiles };
111
102
  }
112
103
 
113
- /**
114
- * Narrow a CRAP baseline to the rows whose file path is in `scopeSet`,
115
- * or return all rows when no diff-scope filter is active.
116
- *
117
- * @param {{ rows: object[] }} baseline
118
- * @param {Set<string>|null|undefined} scopeSet
119
- * @returns {object[]}
120
- */
121
- function resolveBaselineRows(baseline, scopeSet) {
122
- return scopeSet
123
- ? filterRowsByFileScope(baseline.rows, scopeSet)
124
- : baseline.rows;
125
- }
126
-
127
- /**
128
- * Return true when the CRAP compare result contains regressions or new
129
- * violations — i.e. when the preview gate should exit non-zero.
130
- *
131
- * @param {{ regressions: number, newViolations: number }} result
132
- * @returns {boolean}
133
- */
134
- function hasCrapRegressions(result) {
135
- return result.regressions > 0 || result.newViolations > 0;
136
- }
137
-
138
104
  /**
139
105
  * Build the zero-row CRAP envelope the preview returns when it has nothing to
140
106
  * compare against — no baseline, or the gate disabled.
@@ -289,58 +255,12 @@ export async function runCrapPreview({
289
255
  };
290
256
  }
291
257
 
292
- const targetDirs = Array.isArray(crap.targetDirs) ? crap.targetDirs : [];
293
- const crapIgnoreGlobs = Array.isArray(crap.ignoreGlobs)
294
- ? crap.ignoreGlobs
295
- : [];
296
- const requireCoverage = crap.requireCoverage !== false;
297
- const coveragePath = crap.coveragePath ?? 'coverage/coverage-final.json';
298
- const coverage = loadCoverage(path.resolve(cwd, coveragePath));
299
- // Story #4731 (AC-3) — feed the CRAP regression compare the *configured*
300
- // crap tolerance (env override → `gates.crap.tolerance` → framework default)
301
- // so `compareCrap` demotes positive deltas at or under tolerance rather than
302
- // failing on any positive delta; over-tolerance deltas still fail. This keeps
303
- // the pre-commit/pre-push preview aligned with the authoritative gate.
304
- const { newMethodCeiling, tolerance } = resolveCrapEnvOverrides(
258
+ return computeCrapPreviewScan({
305
259
  crap,
306
- process.env,
307
- );
308
- const scan = await scanAndScore({
309
- targetDirs,
310
- coverage,
311
- requireCoverage,
312
260
  cwd,
313
- scopeFiles: scopeSet,
314
- ignoreGlobs: crapIgnoreGlobs,
315
- });
316
- const baselineRows = resolveBaselineRows(baseline, scopeSet);
317
- const result = compareCrap({
318
- currentRows: scan.rows,
319
- baselineRows,
320
- newMethodCeiling,
321
- tolerance,
322
- });
323
- const envelope = buildCrapReport({
324
- compareResult: result,
325
- scanSummary: scan,
326
- kernelVersion: KERNEL_VERSION,
327
- escomplexVersion: resolveEscomplexVersion(),
328
- newMethodCeiling,
329
- scopeInfo: {
330
- scope,
331
- diffRef,
332
- },
261
+ scopeSet,
262
+ scope,
263
+ diffRef,
264
+ baseline,
333
265
  });
334
- // Story #4866 (AC-5): above the drifted-row ratio the basis is self-
335
- // evidently unsound and every per-method verdict below it is an artefact of
336
- // a mis-keyed join. Say so once, by name, and fail open.
337
- const basis = assessComparisonBasis(result);
338
- if (!basis.sound) {
339
- return {
340
- exitCode: 0,
341
- envelope: suppressVerdicts(envelope, basis.diagnostic),
342
- };
343
- }
344
- const exitCode = hasCrapRegressions(result) ? 1 : 0;
345
- return { exitCode, envelope };
346
266
  }
@@ -0,0 +1,26 @@
1
+ /* node:coverage ignore file -- AJV schema declaration (data-as-code) */
2
+
3
+ /**
4
+ * `delivery.quality.gates.crap.incrementalCoverage` — opt-in incremental
5
+ * coverage-capture + CRAP-join scoping (Story #4981).
6
+ *
7
+ * Split into its own module (rather than an inline property literal on
8
+ * `CRAP_GATE`) so the schema addition lands as a new file, not a same-file
9
+ * expansion of `crap.schema.js` — the file this module's sole export is
10
+ * spread into.
11
+ *
12
+ * Default (key absent) preserves today's full-repo behaviour byte-for-byte.
13
+ * When `enabled: true`, `coverage-capture.js` scopes `npm run test:coverage`
14
+ * to the files changed against `baseRef` (default: the gate's own `--ref` /
15
+ * `main`), and the CRAP join treats a method in a file the diff did not
16
+ * touch as resolved by its committed baseline row instead of requiring
17
+ * fresh coverage for it.
18
+ */
19
+ export const INCREMENTAL_COVERAGE_SCHEMA = {
20
+ type: 'object',
21
+ properties: {
22
+ enabled: { type: 'boolean' },
23
+ baseRef: { type: 'string', minLength: 1 },
24
+ },
25
+ additionalProperties: false,
26
+ };
@@ -1,5 +1,6 @@
1
1
  /* node:coverage ignore file -- AJV schema declaration (data-as-code) */
2
2
 
3
+ import { INCREMENTAL_COVERAGE_SCHEMA } from './crap-incremental-coverage.schema.js';
3
4
  import {
4
5
  GATE_BASE,
5
6
  LIST_OR_EXTENDER_OF_STRINGS,
@@ -35,6 +36,7 @@ export const CRAP_GATE = {
35
36
  // paths to exclude files from CRAP discovery before scoring. Orthogonal
36
37
  // to `components` (grouping). Absent/empty preserves existing behaviour.
37
38
  ignoreGlobs: { type: 'array', items: { type: 'string', minLength: 1 } },
39
+ incrementalCoverage: INCREMENTAL_COVERAGE_SCHEMA,
38
40
  },
39
41
  additionalProperties: false,
40
42
  };
@@ -71,6 +71,19 @@ const DEFAULT_MI_FLOORS = Object.freeze({
71
71
  '*': Object.freeze({ min: 70 }),
72
72
  });
73
73
 
74
+ /**
75
+ * Story #4981 — opt-in incremental coverage-capture + CRAP-join scoping.
76
+ * Disabled by default: `coverage-capture.js` and the CRAP join keep their
77
+ * pre-#4981 full-repo behaviour byte-for-byte until a consumer sets
78
+ * `enabled: true`. `baseRef: null` means "use the caller's own ref
79
+ * resolution" (the gate's `--ref` flag / `main`) rather than a second,
80
+ * possibly-conflicting default.
81
+ */
82
+ const DEFAULT_INCREMENTAL_COVERAGE = Object.freeze({
83
+ enabled: false,
84
+ baseRef: null,
85
+ });
86
+
74
87
  /** Framework defaults for the CRAP gate (post-1737 uniform shape). */
75
88
  export const CRAP_GATE_DEFAULTS = Object.freeze({
76
89
  enabled: true,
@@ -97,6 +110,7 @@ export const CRAP_GATE_DEFAULTS = Object.freeze({
97
110
  // run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
98
111
  // signature of a coordinate-system mismatch.
99
112
  minMethodResolutionRate: 0.75,
113
+ incrementalCoverage: DEFAULT_INCREMENTAL_COVERAGE,
100
114
  });
101
115
 
102
116
  /** Framework defaults for the coverage gate. */
@@ -163,6 +177,7 @@ const CRAP_GATE_KEYS = new Set([
163
177
  'refreshTimeoutMs',
164
178
  'ignoreGlobs',
165
179
  'minMethodResolutionRate',
180
+ 'incrementalCoverage',
166
181
  ]);
167
182
 
168
183
  const COVERAGE_GATE_KEYS = new Set([
@@ -240,6 +255,27 @@ function resolveResolutionRate(value, fallback) {
240
255
  return value;
241
256
  }
242
257
 
258
+ /**
259
+ * Resolve `gates.crap.incrementalCoverage` (Story #4981). A malformed or
260
+ * absent user block resolves to the framework default (disabled), so a
261
+ * consumer that never sets the key gets the exact pre-#4981 shape back.
262
+ *
263
+ * @param {{ enabled?: boolean, baseRef?: string } | undefined} user
264
+ * @param {{ enabled: boolean, baseRef: string | null }} defaults
265
+ * @returns {{ enabled: boolean, baseRef: string | null }}
266
+ */
267
+ function resolveIncrementalCoverage(user, defaults) {
268
+ if (user == null || typeof user !== 'object') return { ...defaults };
269
+ return {
270
+ enabled:
271
+ typeof user.enabled === 'boolean' ? user.enabled : defaults.enabled,
272
+ baseRef:
273
+ typeof user.baseRef === 'string' && user.baseRef.length > 0
274
+ ? user.baseRef
275
+ : defaults.baseRef,
276
+ };
277
+ }
278
+
243
279
  export function resolveMaintainabilityCrap(
244
280
  userCrap,
245
281
  gateScoping,
@@ -267,6 +303,7 @@ export function resolveMaintainabilityCrap(
267
303
  refreshTag: defaults.refreshTag,
268
304
  refreshTimeoutMs: defaults.refreshTimeoutMs,
269
305
  ignoreGlobs: [...defaults.ignoreGlobs],
306
+ incrementalCoverage: { ...defaults.incrementalCoverage },
270
307
  defaultScope: scoping.defaultScope,
271
308
  diffRef: scoping.diffRef,
272
309
  };
@@ -297,6 +334,10 @@ export function resolveMaintainabilityCrap(
297
334
  ignoreGlobs: Array.isArray(userCrap.ignoreGlobs)
298
335
  ? userCrap.ignoreGlobs.slice()
299
336
  : [...defaults.ignoreGlobs],
337
+ incrementalCoverage: resolveIncrementalCoverage(
338
+ userCrap.incrementalCoverage,
339
+ defaults.incrementalCoverage,
340
+ ),
300
341
  defaultScope: scoping.defaultScope,
301
342
  diffRef: scoping.diffRef,
302
343
  };
@@ -443,6 +443,12 @@ export const DELIVERY_SCHEMA = {
443
443
  deliverRunner: DELIVER_RUNNER_SCHEMA,
444
444
  worktreeIsolation: WORKTREE_ISOLATION_SCHEMA,
445
445
  signals: SIGNALS_SCHEMA,
446
+ // `quality.gates.crap.incrementalCoverage` (Story #4981) is declared in
447
+ // `config/gates/crap.schema.js` and reaches AJV validation through this
448
+ // property — QUALITY_SCHEMA → GATES_SCHEMA → CRAP_GATE. No separate
449
+ // declaration lives here; this is the composition point that makes the
450
+ // gate-level schema authoritative for the top-level `.agentrc.json`
451
+ // surface this module validates.
446
452
  quality: QUALITY_SCHEMA,
447
453
  mergeWatch: MERGE_WATCH_SCHEMA,
448
454
  codeReview: CODE_REVIEW_SCHEMA,