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.
- package/.agents/docs/configuration.md +2 -2
- package/.agents/docs/quality-gates.md +30 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +2 -2
- package/.agents/schemas/audit-rules.json +44 -0
- package/.agents/schemas/audit-rules.schema.json +1 -1
- package/.agents/scripts/coverage-capture.js +7 -1
- package/.agents/scripts/lib/baselines/kernel.js +20 -7
- package/.agents/scripts/lib/baselines/kinds/crap.js +7 -2
- package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
- package/.agents/scripts/lib/close-validation/projections/crap.js +8 -6
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
- package/.agents/scripts/lib/config/quality.js +7 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +5 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
- package/.agents/scripts/lib/coverage-capture.js +106 -41
- package/.agents/scripts/lib/findings/route-finding.js +98 -35
- package/.agents/scripts/lib/maintainability-utils.js +6 -14
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +24 -7
- package/.agents/scripts/lib/source-extensions.js +76 -0
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +1 -1
- package/.agents/workflows/audit-adrs.md +270 -0
- package/.agents/workflows/audit-documentation.md +17 -0
- package/.agents/workflows/helpers/deliver-light.md +5 -2
- package/.agents/workflows/helpers/deliver-story-reference.md +23 -1
- package/.agents/workflows/helpers/deliver-story.md +4 -2
- package/docs/CHANGELOG.md +25 -0
- 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`,
|
|
214
|
-
| `quality.gates.crap.incrementalCoverage.enabled` | No | `boolean` | — | Master switch for
|
|
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 (
|
|
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`,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
*
|
|
677
|
-
*
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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:
|
|
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:
|
|
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
|
|
159
|
-
*
|
|
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 {
|
|
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`,
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
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`,
|
|
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:
|
|
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 {
|
|
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.
|
|
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:
|
|
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
|
+
}
|