mandrel 2.33.0 → 2.35.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.
Files changed (29) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/docs/quality-gates.md +30 -0
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/agentrc.schema.json +2 -2
  5. package/.agents/schemas/audit-rules.json +44 -0
  6. package/.agents/schemas/audit-rules.schema.json +1 -1
  7. package/.agents/scripts/coverage-capture.js +7 -1
  8. package/.agents/scripts/lib/baselines/kernel.js +20 -7
  9. package/.agents/scripts/lib/baselines/kinds/crap.js +7 -2
  10. package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
  11. package/.agents/scripts/lib/close-validation/projections/crap.js +8 -6
  12. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
  13. package/.agents/scripts/lib/config/quality.js +7 -0
  14. package/.agents/scripts/lib/coverage-capture-fullscope.js +5 -2
  15. package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
  16. package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
  17. package/.agents/scripts/lib/coverage-capture.js +106 -41
  18. package/.agents/scripts/lib/findings/route-finding.js +98 -35
  19. package/.agents/scripts/lib/maintainability-utils.js +6 -14
  20. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +24 -7
  21. package/.agents/scripts/lib/source-extensions.js +76 -0
  22. package/.agents/skills/core/documentation-and-adrs/SKILL.md +1 -1
  23. package/.agents/workflows/audit-adrs.md +270 -0
  24. package/.agents/workflows/audit-documentation.md +17 -0
  25. package/.agents/workflows/helpers/deliver-light.md +5 -2
  26. package/.agents/workflows/helpers/deliver-story-reference.md +23 -1
  27. package/.agents/workflows/helpers/deliver-story.md +4 -2
  28. package/docs/CHANGELOG.md +25 -0
  29. package/package.json +1 -1
@@ -210,8 +210,8 @@ Everything `/deliver` and `single-story-close` consume: execution timeouts, work
210
210
  | `quality.gates.crap.refreshTag` | No | `string` | `"baseline-refresh:"` | Commit-subject substring that acknowledges a deliberate CRAP baseline refresh in the compared range. A range commit carrying it that also touches the baseline file demotes head-vs-base regressions; floors stay enforced. |
211
211
  | `quality.gates.crap.refreshTimeoutMs` | No | `integer` | `60000` | 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). |
212
212
  | `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). |
213
- | `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. |
214
- | `quality.gates.crap.incrementalCoverage.enabled` | No | `boolean` | — | Master switch for incremental capture + join scoping. |
213
+ | `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`, the changed-file set against `baseRef` (default: the gate’s own `--ref` / `main`) decides WHETHER to capture — no changed file under `crap.targetDirs` means no capture at all — 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. It does NOT narrow the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065). |
214
+ | `quality.gates.crap.incrementalCoverage.enabled` | No | `boolean` | — | Master switch for the capture skip and the baseline-resolved CRAP join. |
215
215
  | `quality.gates.crap.incrementalCoverage.baseRef` | No | `string` | — | Git ref the changed-file set is computed against. Omitted falls back to the gate’s own `--ref` (`main`). |
216
216
  | `quality.gates.maintainability` | No | `object` | — | Maintainability-index ratchet. Scores per file as the average over its methods, so deleting a small high-MI method can legitimately lower a file’s score. |
217
217
  | `quality.gates.maintainability.enabled` | No | `boolean` | `true` | When false, the checker exits 0 with a skip line and the gate is reported as `skipped`, never omitted. |
@@ -424,6 +424,36 @@ refresh-guardrail accepts it on the next PR.
424
424
  If your test runner doesn't produce per-method coverage, see "Disabling the
425
425
  gate" below.
426
426
 
427
+ ### Coverage freshness — what triggers a capture
428
+
429
+ The CRAP scorer treats "no coverage" as "skip the method", so a missing or
430
+ stale `coverage/coverage-final.json` silently weakens the gate.
431
+ `coverage-capture.js` closes that hole by capturing coverage in-band, and
432
+ decides whether it needs to by two rules (Story #5076):
433
+
434
+ - **The source set is derived, not configured.** Freshness is measured over
435
+ exactly the extensions the CRAP scanner walks — `.js`, `.mjs`, `.cjs`,
436
+ `.ts`, `.tsx`, `.mts`, `.cts` — defined once in
437
+ `.agents/scripts/lib/source-extensions.js`. There is deliberately no
438
+ `.agentrc.json` key for this: a consumer-settable list would be a second
439
+ way to mis-scope the same gate. Formats the engines cannot parse
440
+ (`.astro`, `.vue`, `.svelte`) are not part of it — a project written in
441
+ those still has its `.ts`/`.tsx` measured.
442
+ - **Both freshness paths fail closed on an empty source set.** Finding no
443
+ scorable source under `crap.targetDirs` means the check learned nothing,
444
+ so it captures rather than assuming coverage is current, and warns naming
445
+ the configured dirs. If you see that warning, `targetDirs` almost
446
+ certainly does not point at your sources — fix it rather than living with
447
+ a full capture on every run.
448
+
449
+ **Upgrading from a version before this fix:** a TypeScript project's sources
450
+ matched neither path, so the capture was skipped on every run and
451
+ `crap:check` compared the committed baseline against itself. The first run
452
+ after upgrading captures for real and measures your committed floors for the
453
+ first time, which may surface breaches that were always there. That is a
454
+ one-off re-baseline (`npm run crap:update`, committed with a
455
+ `baseline-refresh:` subject), not a regression.
456
+
427
457
  ### Disabling the gate (single-flag opt-out)
428
458
 
429
459
  If your repo doesn't run coverage, set `enabled: false` in your
@@ -32,11 +32,12 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
32
32
  fails when it drifts from the on-disk workflow set. To change a command’s
33
33
  description, edit the workflow file’s front-matter and regenerate.
34
34
 
35
- ## Commands (27)
35
+ ## Commands (28)
36
36
 
37
37
  | Command | Description |
38
38
  | --- | --- |
39
39
  | `/audit-accessibility` | Audit WCAG accessibility conformance (static-first) with an optional runtime verification pass, and produce a structured findings report |
40
+ | `/audit-adrs` | Audit the decisions log as a live contract — Accepted ADRs whose claims the tree has outgrown, broken supersede chains, structural gaps, and directional changes that landed with no decision recorded. A deliberately-global lens over whichever decisions-log layout the consumer ships. |
40
41
  | `/audit-architecture` | Audit architectural boundaries, module coupling, layering violations, and shipped-but-uncalled seams; emit a structured findings report keyed to the canonical severity scale. |
41
42
  | `/audit-baselines` | Audit the committed baseline surface — dead instruments, stale baselines, cross-gate hotspot clusters, trend drift, and floor-tightening headroom — and emit findings whose remediation burns the measured debt down and tightens the ratchet behind it. |
42
43
  | `/audit-clean-code` | Audit code smells, dead code, complexity hotspots, and maintainability-index outliers; emit a structured findings report. |
@@ -978,11 +978,11 @@
978
978
  },
979
979
  "incrementalCoverage": {
980
980
  "type": "object",
981
- "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.",
981
+ "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`, the changed-file set against `baseRef` (default: the gate’s own `--ref` / `main`) decides WHETHER to capture — no changed file under `crap.targetDirs` means no capture at all — 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. It does NOT narrow the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065).",
982
982
  "properties": {
983
983
  "enabled": {
984
984
  "type": "boolean",
985
- "description": "Master switch for incremental capture + join scoping."
985
+ "description": "Master switch for the capture skip and the baseline-resolved CRAP join."
986
986
  },
987
987
  "baseRef": {
988
988
  "type": "string",
@@ -312,6 +312,23 @@
312
312
  "target": "web",
313
313
  "scope": "global",
314
314
  "substitutionKeys": []
315
+ },
316
+ "audit-adrs": {
317
+ "triggers": {
318
+ "gates": ["gate1", "gate3"],
319
+ "keywords": [
320
+ "adr",
321
+ "adrs",
322
+ "decision record",
323
+ "architecture decision",
324
+ "decisions log",
325
+ "supersede",
326
+ "superseded"
327
+ ],
328
+ "filePatterns": ["**/decisions.md", "**/decisions/**"]
329
+ },
330
+ "scope": "global",
331
+ "substitutionKeys": []
315
332
  }
316
333
  },
317
334
  "sensitivePaths": {
@@ -363,6 +380,33 @@
363
380
  "**/openapi/**",
364
381
  "**/graphql/**"
365
382
  ]
383
+ },
384
+ "deploy-runtime": {
385
+ "description": "Deployment and runtime topology: the CI/CD pipeline, infrastructure-as-code, container images, and the server/serverless entrypoint with its platform and build config. A change here alters how the product is built, bound, and run in production without necessarily touching application logic. Build configs are enumerated by name rather than matched with a blanket `**/*.config.*` glob, which would swallow the test and lint configs and flip nearly every change set to high.",
386
+ "filePatterns": [
387
+ ".github/workflows/**",
388
+ ".github/actions/**",
389
+ "**/Dockerfile",
390
+ "**/docker-compose*.yml",
391
+ "**/docker-compose*.yaml",
392
+ "infra/**",
393
+ "**/*.tf",
394
+ "**/*.tfvars",
395
+ "**/wrangler.json",
396
+ "**/wrangler.jsonc",
397
+ "**/wrangler.toml",
398
+ "**/worker-entry.ts",
399
+ "**/worker-entry.js",
400
+ "**/astro.config.*",
401
+ "**/next.config.*",
402
+ "**/nuxt.config.*",
403
+ "**/svelte.config.*",
404
+ "**/fly.toml",
405
+ "**/vercel.json",
406
+ "**/netlify.toml",
407
+ "**/serverless.yml",
408
+ "**/serverless.yaml"
409
+ ]
366
410
  }
367
411
  }
368
412
  }
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "sensitivePaths": {
24
24
  "type": "object",
25
- "description": "Map of sensitive-path class name to the globs that identify it. A change set touching any registered pattern derives a `high` change level, which resolves review depth to `deep` and the acceptance critic to `fresh` regardless of how narrow the diff is (deriveChangeLevel in lib/orchestration/review-depth.js). This is configuration, not code: an operator extends the classes or their globs here without editing a module, and the globs run through the same picomatch machinery as the audit-lens `filePatterns` triggers above. An absent or empty block means no path is sensitive and depth falls back to diff width alone.",
25
+ "description": "Map of sensitive-path class name to the globs that identify it. A change set touching any registered pattern derives a `high` change level, which resolves review depth to `deep` and the acceptance critic to `fresh` regardless of how narrow the diff is (deriveChangeLevel in lib/orchestration/review-depth.js). This is configuration, not code: an operator extends the classes or their globs here without editing a module, and the globs run through the same picomatch machinery as the audit-lens `filePatterns` triggers above. Those lens triggers are a SEPARATE block with a separate job — they select which lens runs, never the change level — so a path registered there is not sensitive here unless it is also registered in this block. An absent or empty block means no path is sensitive and depth falls back to diff width alone.",
26
26
  "patternProperties": {
27
27
  "^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/sensitivePathClass" }
28
28
  },
@@ -33,6 +33,7 @@ import {
33
33
  } from './lib/coverage-capture.js';
34
34
  import { runFullScopeCapture } from './lib/coverage-capture-fullscope.js';
35
35
  import { tryIncrementalCapture } from './lib/coverage-capture-incremental.js';
36
+ import { handleCoverageCaptureHelp } from './lib/coverage-capture-usage.js';
36
37
 
37
38
  import { Logger } from './lib/Logger.js';
38
39
  import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
@@ -160,7 +161,12 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
160
161
  // invoked as a CLI the behaviour — exit code and log lines — is unchanged.
161
162
  if (isDirectInvocation(import.meta.url)) {
162
163
  try {
163
- process.exit(runCoverageCapture());
164
+ // `--help` is answered before the decision core runs: it used to fall
165
+ // through to the capture path, so asking this script to describe itself
166
+ // spawned the whole coverage suite.
167
+ process.exit(
168
+ handleCoverageCaptureHelp(process.argv) ? 0 : runCoverageCapture(),
169
+ );
164
170
  } catch (err) {
165
171
  Logger.error('[coverage-capture] unexpected error:', err);
166
172
  process.exit(1);
@@ -107,6 +107,7 @@ import {
107
107
  } from './kinds/maintainability.js';
108
108
  import {
109
109
  applyEpsilon as mutationApplyEpsilon,
110
+ assertBaselineCompatible as mutationAssertBaselineCompatible,
110
111
  compare as mutationCompare,
111
112
  kernelVersion as mutationKernelVersion,
112
113
  keyField as mutationKeyField,
@@ -211,6 +212,7 @@ const KIND_MODULES = Object.freeze({
211
212
  compare: mutationCompare,
212
213
  applyEpsilon: mutationApplyEpsilon,
213
214
  mergeRows: mutationMergeRows,
215
+ assertBaselineCompatible: mutationAssertBaselineCompatible,
214
216
  }),
215
217
  lighthouse: bindKindModule({
216
218
  name: lighthouseName,
@@ -280,6 +282,22 @@ export function currentKernelVersion(kind) {
280
282
  return getKindModule(kind).kernelVersion();
281
283
  }
282
284
 
285
+ /**
286
+ * Resolve a kind module, or null when the kind is not registered. Lets the
287
+ * optional-hook callers below stay expression-shaped instead of threading a
288
+ * mutable binding through a try/catch.
289
+ *
290
+ * @param {string} kind
291
+ * @returns {object|null}
292
+ */
293
+ function tryGetKindModule(kind) {
294
+ try {
295
+ return getKindModule(kind);
296
+ } catch {
297
+ return null;
298
+ }
299
+ }
300
+
283
301
  /**
284
302
  * Ask a kind whether a loaded baseline is compatible with the running
285
303
  * scorer's SEMANTICS — a dimension `kernelVersion` cannot express, because a
@@ -291,13 +309,8 @@ export function currentKernelVersion(kind) {
291
309
  * @returns {string|null} Operator-facing message, or null when compatible.
292
310
  */
293
311
  export function checkBaselineSemantics(kind, baseline) {
294
- let mod;
295
- try {
296
- mod = getKindModule(kind);
297
- } catch {
298
- return null;
299
- }
300
- if (typeof mod.assertBaselineCompatible !== 'function') return null;
312
+ const mod = tryGetKindModule(kind);
313
+ if (typeof mod?.assertBaselineCompatible !== 'function') return null;
301
314
  return mod.assertBaselineCompatible(baseline);
302
315
  }
303
316
 
@@ -673,8 +673,13 @@ export function assessComparisonBasis(compareResult, opts = {}) {
673
673
  *
674
674
  * Story #791 retired the transitional `bootstrap` exit-0 path: a missing
675
675
  * baseline still fails closed. Story #829 (5.29.0) softened `kernelVersion`
676
- * and `tsTranspilerVersion` drift to **warn**, not fail; `escomplexVersion`
677
- * mismatch continues to fail closed.
676
+ * drift to **warn**, not fail, and did the same for `tsTranspilerVersion`
677
+ * but that second half was re-escalated to **fatal** once Story #4866 made a
678
+ * TS row's `startLine` an original-source coordinate resolved through the
679
+ * transpiler's sourcemap. `startLine` is half the row identity key, so a
680
+ * transpiler change makes the rows incomparable rather than merely stale; see
681
+ * the `ts-transpiler-drift` axis below for the two exemptions that bound it.
682
+ * `escomplexVersion` mismatch has always failed closed.
678
683
  */
679
684
  /**
680
685
  * The one re-seed recipe every coordinate-invalidating axis ends on. Three
@@ -2,13 +2,43 @@
2
2
  * kinds/mutation.js — per-kind module for the mutation-testing baseline
3
3
  * (Story #1891). Row shape: `{ path, score, killed, survived }`. Rollup
4
4
  * carries score/killed/survived/noCoverage. Stryker is the upstream
5
- * kernel; we pin a static `1.0.0` until a Mandrel-side retrofit story
5
+ * kernel; we pin a static version until a Mandrel-side retrofit story
6
6
  * wires the running Stryker version through (#1908).
7
7
  *
8
8
  * Higher score = better. New paths land in the `additions` bucket
9
9
  * (Story #2012 — any real-world score under 100 must never flip to a
10
10
  * regression); removed paths count as improvements when their score was
11
11
  * non-perfect. Scaffold is generated by `makeBaselineKind` (Story #3983).
12
+ *
13
+ * ## Rollup weighting (Story #5058)
14
+ *
15
+ * The rollup score is a **mutant-weighted** mean —
16
+ * `sum(score_i * mutants_i) / sum(mutants_i)`, where
17
+ * `mutants_i = killed_i + survived_i`. It used to be `scoreSum / rows.length`,
18
+ * an unweighted mean over files, under which a 3-mutant file carried the same
19
+ * weight as a 300-mutant one: adding a handful of thinly-mutated new files
20
+ * dragged the whole-repo number down far enough to breach the floor arm while
21
+ * the compare arm reported no regression at all (a newly-scanned file has no
22
+ * baseline row, so it can only move the aggregate `applyFloors` scores).
23
+ *
24
+ * Both weights are already summed in the same loop and are already required
25
+ * by the row schema, which is `additionalProperties: false` — so the weighted
26
+ * score is computable from the existing row shape with no producer change and
27
+ * no schema change.
28
+ *
29
+ * **Limit of the approximation.** Rows carry no timeout or no-coverage counts
30
+ * (`noCoverage` in the rollup is hardcoded `0`), so `killed + survived` is not
31
+ * Stryker's full mutant population wherever timed-out or uncovered mutants
32
+ * exist. The weighted score therefore *approximates* Stryker's published
33
+ * overall score — it does not reproduce it. Closing that gap needs a producer
34
+ * change and its own Story.
35
+ *
36
+ * Because this changes what the stored number MEANS, it is a semantics
37
+ * migration: a baseline measured at 88.51 unweighted is 85.18 weighted over
38
+ * the same rows, so every floor calibrated on the old mean breaches on
39
+ * upgrade. `assertBaselineCompatible` below fails such a baseline closed —
40
+ * a bare `kernelVersion` bump would not, because `kernelMatch` feeds only the
41
+ * reporting-side drift count and reaches no exit code.
12
42
  */
13
43
 
14
44
  import { canonicalise } from '../path-canon.js';
@@ -17,6 +47,24 @@ import { makeBaselineKind } from './kind-factory.js';
17
47
  export const name = 'mutation';
18
48
  export const keyField = 'path';
19
49
 
50
+ /**
51
+ * Kernel version of the mutation scorer. Bumped off the original `1.0.0` by
52
+ * Story #5058: the rollup score changed from an unweighted file mean to a
53
+ * mutant-weighted mean, so rows stamped below this version were aggregated
54
+ * under superseded semantics.
55
+ */
56
+ const KERNEL_VERSION = '2.0.0';
57
+
58
+ /** Major of {@link KERNEL_VERSION} — the weighted-rollup boundary. */
59
+ const WEIGHTED_ROLLUP_MAJOR = 2;
60
+
61
+ const RESEED_REMEDY =
62
+ 'Re-seed the baseline: re-run this project mutation run (Mandrel ships no ' +
63
+ "runner — Stryker is the upstream producer, e.g. 'npx stryker run') so " +
64
+ "'baselines/mutation.json' is rewritten under the weighted rollup, then " +
65
+ "commit it with a 'baseline-refresh:' subject and recalibrate the gate's " +
66
+ 'floors against the new number.';
67
+
20
68
  export function projectRow(row) {
21
69
  return {
22
70
  path: canonicalise(row.path),
@@ -26,20 +74,102 @@ export function projectRow(row) {
26
74
  };
27
75
  }
28
76
 
77
+ /**
78
+ * Parse the major component of a semver-ish stamp.
79
+ *
80
+ * @param {unknown} version
81
+ * @returns {number|null} The major, or null when unparseable/absent.
82
+ */
83
+ function majorOf(version) {
84
+ const match = /^(\d+)\./.exec(String(version ?? ''));
85
+ return match ? Number(match[1]) : null;
86
+ }
87
+
88
+ /**
89
+ * Kind-module hook (Story #4775) — refuse a loaded baseline whose rollup was
90
+ * aggregated by the superseded unweighted mean. `checkBaselineSemantics`
91
+ * dispatches here and `check-baselines`' evaluate phase turns a non-null
92
+ * return into a fail-closed `semantics` schema error, so a pre-weighting
93
+ * baseline can never be silently scored against floors calibrated on a
94
+ * different definition of the number. Follows the `kinds/crap.js` precedent.
95
+ *
96
+ * An absent or unparseable stamp is rejected too: `kernelVersion` is required
97
+ * by the shared envelope schema, so its absence is not evidence of a newer
98
+ * writer.
99
+ *
100
+ * @param {object|null} baseline A loaded v2 baseline envelope.
101
+ * @returns {string|null} Operator-facing message, or null when compatible.
102
+ */
103
+ export function assertBaselineCompatible(baseline) {
104
+ if (!baseline) return null;
105
+ const stamped = baseline.kernelVersion ?? null;
106
+ const major = majorOf(stamped);
107
+ if (major !== null && major >= WEIGHTED_ROLLUP_MAJOR) return null;
108
+ return (
109
+ `[mutation] rollup scoring semantics changed: baseline=${stamped ?? '<unstamped>'} ` +
110
+ `running=${KERNEL_VERSION}. The rollup score is now a mutant-weighted mean ` +
111
+ '(sum(score * mutants) / sum(mutants)) rather than an unweighted mean over ' +
112
+ 'files, so the stored aggregate is a different number for the same rows and ' +
113
+ `the floors calibrated against it no longer mean what they did. ${RESEED_REMEDY}`
114
+ );
115
+ }
116
+
117
+ /**
118
+ * A row's mutant count — the weight it carries in the rollup score.
119
+ *
120
+ * @param {object} row
121
+ * @returns {number}
122
+ */
123
+ function mutantsOf(row) {
124
+ return (row.killed ?? 0) + (row.survived ?? 0);
125
+ }
126
+
127
+ /**
128
+ * Sum one numeric row field across a row set.
129
+ *
130
+ * @param {object[]} rows
131
+ * @param {string} field
132
+ * @returns {number}
133
+ */
134
+ function sumOf(rows, field) {
135
+ let total = 0;
136
+ for (const r of rows) total += r[field] ?? 0;
137
+ return total;
138
+ }
139
+
140
+ /**
141
+ * The mutant-weighted mean score: `sum(score * mutants) / sum(mutants)`.
142
+ *
143
+ * Zero-mutant guard: an empty row set, or one whose rows carry only zeroes,
144
+ * has no weight to divide by. Returning 0 rather than dividing keeps `NaN` and
145
+ * `Infinity` out of the envelope, where they would fail the schema's numeric
146
+ * bounds downstream.
147
+ *
148
+ * @param {object[]} rows
149
+ * @param {number} mutants Total mutant count across `rows`.
150
+ * @returns {number}
151
+ */
152
+ function weightedScore(rows, mutants) {
153
+ if (mutants <= 0) return 0;
154
+ let weighted = 0;
155
+ for (const r of rows) weighted += (r.score ?? 0) * mutantsOf(r);
156
+ return Number((weighted / mutants).toFixed(2));
157
+ }
158
+
159
+ /**
160
+ * Aggregate rows into the rollup shape. `killed` and `survived` stay plain
161
+ * sums and `noCoverage` stays hardcoded 0 — the weighting changes `score`
162
+ * alone.
163
+ *
164
+ * @param {object[]} rows
165
+ * @returns {{score: number, killed: number, survived: number, noCoverage: number}}
166
+ */
29
167
  function aggregate(rows) {
30
- if (!rows || rows.length === 0) {
31
- return { score: 0, killed: 0, survived: 0, noCoverage: 0 };
32
- }
33
- let scoreSum = 0;
34
- let killed = 0;
35
- let survived = 0;
36
- for (const r of rows) {
37
- scoreSum += r.score ?? 0;
38
- killed += r.killed ?? 0;
39
- survived += r.survived ?? 0;
40
- }
168
+ const scored = rows ?? [];
169
+ const killed = sumOf(scored, 'killed');
170
+ const survived = sumOf(scored, 'survived');
41
171
  return {
42
- score: Number((scoreSum / rows.length).toFixed(2)),
172
+ score: weightedScore(scored, killed + survived),
43
173
  killed,
44
174
  survived,
45
175
  noCoverage: 0,
@@ -55,7 +185,7 @@ export const {
55
185
  mergeRows,
56
186
  } = makeBaselineKind({
57
187
  keyField,
58
- kernelVersion: '1.0.0',
188
+ kernelVersion: KERNEL_VERSION,
59
189
  axes: ['score'],
60
190
  betterWhen: 'higher',
61
191
  aggregate,
@@ -36,6 +36,7 @@ import { loadCoverage } from '../../coverage-utils.js';
36
36
  import { scanAndScore } from '../../crap-utils.js';
37
37
  import { cachedGitFetchSync } from '../../git/cached-fetch.js';
38
38
  import { gitSpawn as defaultGitSpawn } from '../../git-utils.js';
39
+ import { SCORABLE_SOURCE_EXT_RE } from '../../source-extensions.js';
39
40
  import { MISSING_ARG_REASONS, validateProjectionInputs } from './inputs.js';
40
41
 
41
42
  /**
@@ -48,9 +49,6 @@ export const DEFAULT_CRAP_TOLERANCE = 0.001;
48
49
  /** Framework default for the new-method ceiling (`gates.crap.newMethodCeiling`). */
49
50
  export const DEFAULT_NEW_METHOD_CEILING = 30;
50
51
 
51
- /** Extensions the CRAP scanner can score. */
52
- const SCORABLE = /\.(?:js|mjs|cjs|ts|tsx)$/;
53
-
54
52
  /**
55
53
  * Map the shared predicate's fine-grained `missing-*` reason onto the
56
54
  * `missing-args` skipped-reason the sibling MI projection reports, so both
@@ -155,8 +153,9 @@ function refreshBaseRef(cwd, baseBranch, git) {
155
153
  }
156
154
 
157
155
  /**
158
- * Enumerate the Story branch's changed files, narrowed to the extensions
159
- * the CRAP scanner can score.
156
+ * Enumerate the Story branch's changed files, narrowed by the shared
157
+ * scorable-source extension set (`source-extensions.js`) so the projection
158
+ * selects exactly the files the CRAP scanner walks.
160
159
  *
161
160
  * @param {{ cwd: string, baseBranch: string, storyBranch: string, git: { gitSpawn: typeof defaultGitSpawn } }} opts
162
161
  * @returns {{ ok: true, files: string[] } | { ok: false, detail: string }}
@@ -168,7 +167,10 @@ function diffScorableFiles({ cwd, baseBranch, storyBranch, git }) {
168
167
  cwd,
169
168
  gitSpawn: git.gitSpawn,
170
169
  });
171
- return { ok: true, files: files.filter((f) => SCORABLE.test(f)) };
170
+ return {
171
+ ok: true,
172
+ files: files.filter((f) => SCORABLE_SOURCE_EXT_RE.test(f)),
173
+ };
172
174
  } catch (err) {
173
175
  return { ok: false, detail: err.message };
174
176
  }
@@ -10,20 +10,23 @@
10
10
  * spread into.
11
11
  *
12
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.
13
+ * When `enabled: true`, the changed-file set against `baseRef` (default: the
14
+ * gate's own `--ref` / `main`) decides **whether** to capture no changed
15
+ * file under `crap.targetDirs` means no capture at all and the CRAP join
16
+ * treats a method in a file the diff did not touch as resolved by its
17
+ * committed baseline row instead of requiring fresh coverage for it. It does
18
+ * not narrow the capture run itself: a capture that does happen is the
19
+ * ordinary full `npm run test:coverage` (Story #5065).
18
20
  */
19
21
  export const INCREMENTAL_COVERAGE_SCHEMA = {
20
22
  type: 'object',
21
23
  description:
22
- '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.',
24
+ '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`, the changed-file set against `baseRef` (default: the gate’s own `--ref` / `main`) decides WHETHER to capture — no changed file under `crap.targetDirs` means no capture at all — 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. It does NOT narrow the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065).',
23
25
  properties: {
24
26
  enabled: {
25
27
  type: 'boolean',
26
- description: 'Master switch for incremental capture + join scoping.',
28
+ description:
29
+ 'Master switch for the capture skip and the baseline-resolved CRAP join.',
27
30
  },
28
31
  baseRef: {
29
32
  type: 'string',
@@ -78,6 +78,13 @@ const DEFAULT_MI_FLOORS = Object.freeze({
78
78
  * `enabled: true`. `baseRef: null` means "use the caller's own ref
79
79
  * resolution" (the gate's `--ref` flag / `main`) rather than a second,
80
80
  * possibly-conflicting default.
81
+ *
82
+ * Story #5065 — what `enabled: true` actually buys, measured: the capture is
83
+ * **skipped entirely** when no changed file lives under `crap.targetDirs`,
84
+ * and the CRAP join resolves methods in untouched files from the committed
85
+ * baseline row instead of requiring fresh coverage. It does **not** shorten
86
+ * the capture run — when a capture does happen it is the ordinary full
87
+ * `npm run test:coverage`.
81
88
  */
82
89
  const DEFAULT_INCREMENTAL_COVERAGE = Object.freeze({
83
90
  enabled: false,
@@ -8,7 +8,10 @@
8
8
  * logic; behaviour is byte-for-byte the pre-#4981 body.
9
9
  */
10
10
  import path from 'node:path';
11
- import { anyChangedUnderTargets } from './coverage-capture.js';
11
+ import {
12
+ anyChangedUnderTargets,
13
+ describeFreshness,
14
+ } from './coverage-capture.js';
12
15
 
13
16
  /**
14
17
  * Run the `--skip-when-no-crap-files` check (when requested), the
@@ -72,7 +75,7 @@ export function runFullScopeCapture({
72
75
  }
73
76
 
74
77
  logger.info(
75
- `[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.reason}; running npm run test:coverage…`,
78
+ `[coverage-capture] Coverage at ${crap.coveragePath} is ${describeFreshness(freshness, crap.targetDirs)}; running npm run test:coverage…`,
76
79
  );
77
80
  const code = runCaptureImpl({
78
81
  cwd: args.cwd,
@@ -14,6 +14,14 @@ import path from 'node:path';
14
14
  * Run the incremental capture path when
15
15
  * `delivery.quality.gates.crap.incrementalCoverage.enabled` is true.
16
16
  *
17
+ * **This does not shorten the capture run.** The changed-file set decides
18
+ * *whether* to capture, never *what* the capture executes: when nothing under
19
+ * `crap.targetDirs` changed there is no capture at all, and otherwise the
20
+ * ordinary full `npm run test:coverage` runs. The saving that makes the mode
21
+ * worth having is the skip; the other half is the CRAP join, which resolves
22
+ * methods in untouched files from the committed baseline row
23
+ * (`crap-baseline-join.js`) instead of demanding fresh coverage for them.
24
+ *
17
25
  * Returns the process exit code when incremental mode handled the run
18
26
  * (skip, capture, or a capture failure), or `null` when the caller should
19
27
  * fall through to the full-scope path — either incremental mode is
@@ -81,13 +89,12 @@ export function tryIncrementalCapture({
81
89
  }
82
90
 
83
91
  logger.info(
84
- `[coverage-capture] Incremental mode: capturing coverage scoped to ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}]…`,
92
+ `[coverage-capture] Incremental mode: ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}] — capturing…`,
85
93
  );
86
94
  const code = runCaptureImpl({
87
95
  cwd: args.cwd,
88
96
  timeoutMs: coverage?.timeoutMs,
89
97
  log: (m) => logger.info(m),
90
- files: scopedFiles,
91
98
  });
92
99
  if (code !== 0) {
93
100
  logger.error(
@@ -0,0 +1,55 @@
1
+ /**
2
+ * coverage-capture-usage.js — the `--help` spec for `coverage-capture.js`
3
+ * (Story #5063).
4
+ *
5
+ * The delivery workflow invokes `coverage-capture.js` by name
6
+ * (`helpers/deliver-story-reference.md` § Step 1), which brings it under the
7
+ * workflow-invoked self-description contract enforced by
8
+ * `tests/enforcement/workflow-script-help.test.js`. It failed that contract:
9
+ * `--help` fell through to the capture path and spawned the whole coverage
10
+ * suite instead of describing the script.
11
+ *
12
+ * The spec lives here rather than inline for the same reason
13
+ * `coverage-capture-incremental.js` does — a same-file expansion of the CLI
14
+ * shell costs maintainability index on a file already near its floor, and a
15
+ * usage table is data, not decision logic.
16
+ */
17
+
18
+ import { respondToHelp } from './cli-usage.js';
19
+
20
+ /**
21
+ * Usage spec consumed by `cli-usage.js#respondToHelp`. `coverage-capture.js`
22
+ * does not route through `runAsCli` (its synchronous main returns an exit
23
+ * code that `process.exit` forwards), so the help short-circuit is wired by
24
+ * hand rather than declared on a `runAsCli` call.
25
+ *
26
+ * @type {{ invocation: string, summary: string, flags: Array<[string, string]> }}
27
+ */
28
+ const COVERAGE_CAPTURE_USAGE = {
29
+ invocation:
30
+ 'node .agents/scripts/coverage-capture.js [--skip-when-no-crap-files] [--ref <git-ref>] [--cwd <path>]',
31
+ summary:
32
+ 'Ensure coverage/coverage-final.json is present and fresh before the CRAP gate fires, spawning `npm run test:coverage` only when it is stale. Writes a content-digest capture stamp that close-validation reads to skip a redundant re-run.',
33
+ flags: [
34
+ [
35
+ '--skip-when-no-crap-files',
36
+ 'Exit 0 without capturing when no changed file under the CRAP target dirs differs from --ref.',
37
+ ],
38
+ ['--ref <git-ref>', 'Git ref the changed-file set is computed against.'],
39
+ ['--cwd <path>', 'Repository root the capture runs in.'],
40
+ ],
41
+ };
42
+
43
+ /**
44
+ * Answer `--help` / `-h` on stdout, returning whether the caller should stop.
45
+ * Takes the full `process.argv`-shaped array so the CLI shell hands over its
46
+ * own argv unchanged and the index arithmetic lives here rather than at the
47
+ * call site.
48
+ *
49
+ * @param {string[]} argv Full `process.argv`-shaped array.
50
+ * @param {{ write: (s: string) => void }} [out] Defaults to `process.stdout`.
51
+ * @returns {boolean} `true` when help was printed and the run must not proceed.
52
+ */
53
+ export function handleCoverageCaptureHelp(argv = [], out = process.stdout) {
54
+ return respondToHelp(argv.slice(2), COVERAGE_CAPTURE_USAGE, out);
55
+ }