mandrel 2.32.0 → 2.34.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 (48) hide show
  1. package/.agents/docs/SDLC.md +8 -5
  2. package/.agents/docs/agentrc-reference.json +2 -1
  3. package/.agents/docs/configuration.md +3 -2
  4. package/.agents/runtime-deps.json +2 -1
  5. package/.agents/schemas/agentrc.schema.json +8 -2
  6. package/.agents/scripts/README.md +9 -0
  7. package/.agents/scripts/audit-to-stories.js +160 -41
  8. package/.agents/scripts/check-knip-entries.js +47 -24
  9. package/.agents/scripts/check-lifecycle-lint.js +72 -12
  10. package/.agents/scripts/coverage-capture.js +7 -1
  11. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
  12. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
  13. package/.agents/scripts/lib/baselines/kernel.js +20 -7
  14. package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
  15. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
  16. package/.agents/scripts/lib/config/quality.js +7 -0
  17. package/.agents/scripts/lib/config/runners.js +38 -16
  18. package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
  19. package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
  20. package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
  21. package/.agents/scripts/lib/coverage-capture.js +10 -15
  22. package/.agents/scripts/lib/dependency-parser.js +20 -7
  23. package/.agents/scripts/lib/findings/provenance-field.js +135 -0
  24. package/.agents/scripts/lib/findings/route-finding.js +57 -8
  25. package/.agents/scripts/lib/knip-config-resolver.js +181 -0
  26. package/.agents/scripts/lib/knip-entry-sync.js +78 -39
  27. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
  28. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
  29. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +93 -19
  30. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
  31. package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
  32. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
  33. package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
  34. package/.agents/scripts/lib/story-body/footer-block.js +97 -0
  35. package/.agents/scripts/lib/story-body/story-body.js +6 -22
  36. package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
  37. package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
  38. package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
  39. package/.agents/scripts/resolve-stories.js +21 -5
  40. package/.agents/scripts/stories-wave-tick.js +192 -9
  41. package/.agents/workflows/audit-to-stories.md +26 -0
  42. package/.agents/workflows/helpers/deliver-light.md +5 -2
  43. package/.agents/workflows/helpers/deliver-reference.md +28 -1
  44. package/.agents/workflows/helpers/deliver-story-reference.md +80 -1
  45. package/.agents/workflows/helpers/deliver-story.md +4 -2
  46. package/.agents/workflows/helpers/plan-reference.md +76 -0
  47. package/docs/CHANGELOG.md +26 -0
  48. package/package.json +3 -3
@@ -22,6 +22,7 @@
22
22
  * structured contract.
23
23
  */
24
24
 
25
+ import path from 'node:path';
25
26
  import { AGENT_LABELS, RISK_LABELS, TYPE_LABELS } from '../label-constants.js';
26
27
  import { serialize } from '../story-body/story-body.js';
27
28
  import { definesAuditLabel } from './audit-label-taxonomy.js';
@@ -116,10 +117,7 @@ function acceptanceCriteriaFromGroup(group) {
116
117
 
117
118
  /**
118
119
  * Resolve the `edges[]` sequencing anchored on this group. Each edge whose
119
- * `fromGroupKey` matches this group's key contributes its `toGroupKey`. Group
120
- * keys are the only stable identifier available at emit time — issues are not
121
- * numbered yet — so the relationship is preserved as machine-readable keys the
122
- * operator can resolve.
120
+ * `fromGroupKey` matches this group's key contributes its `toGroupKey`.
123
121
  *
124
122
  * @param {object} group
125
123
  * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} edges
@@ -135,22 +133,38 @@ function sequencingDepsForGroup(group, edges) {
135
133
  }
136
134
 
137
135
  /**
138
- * Render the carried-through `edges[]` sequencing as a dedicated extended
139
- * markdown block. The canonical `depends_on[]` footer only round-trips `#N`
140
- * issue refs (`blocked by #123`), which do not exist before the issues are
141
- * opened; rendering the group-key sequencing as its own informational section
142
- * keeps the signal in the body (not discarded — Story #4270) and survives
143
- * `parse()` / `serialize()` round-tripping (it is preamble/extended content,
144
- * not a structured section). Returns the empty string when there is no
145
- * sequencing to surface.
136
+ * Resolve this group's sequencing to canonical `#N` issue refs, or `[]` when
137
+ * the caller has no issue numbers yet (Story #5044).
146
138
  *
147
- * @param {string[]} deps
148
- * @returns {string}
139
+ * Group keys are the only identifier that exists at *emit* time — the issues
140
+ * are not numbered — which is why this used to render as a prose
141
+ * `## Sequencing` block that nothing could act on. Standalone audit Stories
142
+ * therefore hardcoded `depends_on: []`, and their only actual serializer was
143
+ * an accident: their shared provenance footers collided under the delivery
144
+ * footprint guard. Narrowing that scrape removes the accident, so the ordering
145
+ * has to become real in the same change.
146
+ *
147
+ * The resolution is the two-pass shape `plan-persist` already uses: create every
148
+ * issue first, then re-render each body with the now-known numbers and mirror
149
+ * the same edges as native `blocked_by` relations. An edge whose target was not
150
+ * created (deduped against an existing Issue, suppressed by the ledger) simply
151
+ * drops — a `blocked by #undefined` would be worse than an absent edge.
152
+ *
153
+ * A **plain object**, deliberately, not a `Map`: the same map is handed to
154
+ * `applyBlockedByDependencies`, which indexes it with property access, so a
155
+ * `Map` there would silently resolve every lookup to `undefined`, skip every
156
+ * edge, and report success having written nothing. One shape, both halves.
157
+ *
158
+ * @param {string[]} deps Group keys this group depends on.
159
+ * @param {Record<string, number>|null} issueByGroupKey
160
+ * @returns {string[]} `#N` refs, in `deps` order.
149
161
  */
150
- function sequencingSection(deps) {
151
- if (deps.length === 0) return '';
152
- const lines = deps.map((k) => `- depends on group \`${k}\``);
153
- return ['## Sequencing', '', lines.join('\n'), ''].join('\n');
162
+ function dependencyRefs(deps, issueByGroupKey) {
163
+ if (!issueByGroupKey) return [];
164
+ return deps
165
+ .map((key) => issueByGroupKey[key])
166
+ .filter((n) => Number.isInteger(n) && n > 0)
167
+ .map((n) => `#${n}`);
154
168
  }
155
169
 
156
170
  function agentPromptsSection(group) {
@@ -162,6 +176,21 @@ function agentPromptsSection(group) {
162
176
  return blocks.join('\n\n') || '_(no copy-pasteable prompts captured)_';
163
177
  }
164
178
 
179
+ /**
180
+ * Link each source audit report **once**.
181
+ *
182
+ * This used to render `- [\`path\`](path)` — the same
183
+ * `temp/audits/audit-<lens>-results.md` in the link text and again in the URL,
184
+ * byte-identical across every Story of a same-lens sweep. That doubled a token
185
+ * the delivery footprint guard scraped as edit intent, so a lens's whole cohort
186
+ * serialized on a report none of them would ever write to (Story #5044). The
187
+ * guard now ignores markdown-link URLs and temp-root paths, but rendering the
188
+ * path twice was never useful to a reader either: the file name is the label,
189
+ * the path is the target.
190
+ *
191
+ * @param {object} group
192
+ * @returns {string}
193
+ */
165
194
  function contextLinksFromGroup(group) {
166
195
  const reports = uniq(
167
196
  (group.findings ?? [])
@@ -169,7 +198,11 @@ function contextLinksFromGroup(group) {
169
198
  .filter((s) => typeof s === 'string'),
170
199
  );
171
200
  if (reports.length === 0) return '_(no source audit reports captured)_';
172
- return reports.map((r) => `- [\`${r}\`](${r})`).join('\n');
201
+ // `path.basename` rather than `split('/')`: on win32 it splits on both
202
+ // separators, so an absolute Windows path yields the file name instead of
203
+ // the whole path — which would render the path twice in one link and
204
+ // re-create the very duplication this function exists to remove.
205
+ return reports.map((r) => `- [${path.basename(r)}](${r})`).join('\n');
173
206
  }
174
207
 
175
208
  function labelsForGroup(group) {
@@ -226,20 +259,27 @@ function assertLabelsInTaxonomy(labels) {
226
259
  * — the dependency `edges[]` emitted by `groupFindings`. Edges anchored on
227
260
  * this group are carried through to `depends_on[]`; omit when no sequencing
228
261
  * is known.
229
- * @returns {{ title: string, body: string, labels: string[] }}
262
+ * @param {Record<string, number>|null} [params.issueByGroupKey]
263
+ * — group key → opened issue number. Supplied on the **second** pass, once
264
+ * the issues exist, so this group's edges render as canonical
265
+ * `blocked by #N` footers (Story #5044). Omit on the first pass.
266
+ * @returns {{ title: string, body: string, labels: string[], groupKey: string, dependsOn: string[] }}
267
+ * `groupKey` and `dependsOn` are the caller's handle on the second pass:
268
+ * they name this Story and the groups it must follow, so the caller can map
269
+ * both onto issue numbers without re-deriving the grouping.
230
270
  */
231
- export function buildStoryBody({ group, edges = [] }) {
271
+ export function buildStoryBody({ group, edges = [], issueByGroupKey = null }) {
232
272
  if (!group || !Array.isArray(group.findings)) {
233
273
  throw new Error('buildStoryBody: group with findings[] is required');
234
274
  }
235
275
  const title = group.title;
276
+ const dependsOn = sequencingDepsForGroup(group, edges);
236
277
 
237
278
  // Build the canonical StoryBody object from the audit group data. The
238
279
  // acceptance + verify arrays are populated so the body clears the
239
- // inline-contract bar; changes[] carries the file footprint. The edges[]
240
- // sequencing is carried through as an extended `## Sequencing` block (see
241
- // sequencingSection) group keys are not `#N` refs, so they cannot ride the
242
- // canonical depends_on footer.
280
+ // inline-contract bar; changes[] carries the file footprint. `depends_on`
281
+ // is empty on the first pass (the blockers have no issue numbers yet) and
282
+ // carries real `#N` refs on the second see dependencyRefs.
243
283
  const storyBody = {
244
284
  goal: goalFromGroup(group),
245
285
  changes: changesFromGroup(group),
@@ -248,20 +288,21 @@ export function buildStoryBody({ group, edges = [] }) {
248
288
  references: [],
249
289
  wide: null,
250
290
  reason_to_exist: null,
251
- depends_on: [],
291
+ depends_on: dependencyRefs(dependsOn, issueByGroupKey),
252
292
  };
253
293
 
254
- // Serialize via the canonical serializer (no footer depends_on is empty).
255
- const canonicalSections = serialize(storyBody);
256
- const sequencing = sequencingSection(sequencingDepsForGroup(group, edges));
294
+ // The `---` / `blocked by #N` footer is the canonical serializer's own, so
295
+ // the body round-trips through `parse()` and `/deliver`'s resolver reads the
296
+ // ordering from the same place it reads every other Story's.
297
+ const canonicalSections = serialize(storyBody, {
298
+ includeFooter: storyBody.depends_on.length > 0,
299
+ });
257
300
 
258
- // Append audit-specific extended sections (sequencing, agent prompts,
259
- // context links, fingerprint footer) that are not part of the canonical
260
- // shape.
301
+ // Append audit-specific extended sections (agent prompts, context links,
302
+ // provenance footers) that are not part of the canonical shape.
261
303
  const body = [
262
304
  canonicalSections,
263
305
  '',
264
- ...(sequencing ? [sequencing] : []),
265
306
  '## Agent Prompts',
266
307
  '',
267
308
  agentPromptsSection(group),
@@ -276,5 +317,11 @@ export function buildStoryBody({ group, edges = [] }) {
276
317
  renderSemanticKeyFooter(group.findings),
277
318
  ].join('\n');
278
319
 
279
- return { title, body, labels: labelsForGroup(group) };
320
+ return {
321
+ title,
322
+ body,
323
+ labels: labelsForGroup(group),
324
+ groupKey: group.groupKey,
325
+ dependsOn,
326
+ };
280
327
  }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * lib/audit-to-stories/wire-dependencies.js — turn a standalone audit cohort's
3
+ * detected group edges into declared ordering, once the issues exist.
4
+ *
5
+ * `groupFindings` detects `edges[]` between finding groups, but at emit time
6
+ * the groups have no issue numbers, so the ordering could only be rendered as
7
+ * prose. Every standalone audit Story therefore shipped with `depends_on: []`,
8
+ * and what actually kept a cohort from being co-dispatched onto colliding
9
+ * branches was an **accident**: siblings shared the sweep-wide audit provenance
10
+ * footers `plan-persist` stamps, and the delivery footprint guard scraped
11
+ * path-shaped tokens out of them. Story #5044 narrows that scrape, which is why
12
+ * this module lands with it — removing the accidental serializer without giving
13
+ * the cohort a real one would leave it less ordered than before.
14
+ *
15
+ * The shape is `plan-persist`'s two-pass crossing (`plan-persist/story-ops.js`),
16
+ * because it is the same problem: **create every issue first, then mirror the
17
+ * edges**. Both halves are written:
18
+ *
19
+ * 1. The **body footer** (`---` / `blocked by #N`) — canonical, parsed by
20
+ * `/deliver`'s resolver, and the fallback when the dependencies API is
21
+ * unavailable.
22
+ * 2. The **native `blocked_by` relation** — visible in the GitHub UI,
23
+ * readable without parsing markdown, and settable by an operator later.
24
+ *
25
+ * Native mirroring is **non-fatal by design**, matching `plan-persist`: the
26
+ * footer has already been written by the time it runs, so a dependencies API
27
+ * that says no costs visibility, not ordering.
28
+ *
29
+ * @module lib/audit-to-stories/wire-dependencies
30
+ */
31
+
32
+ import { applyBlockedByDependencies } from '../../providers/github/blocked-by-add.js';
33
+ import { Logger } from '../Logger.js';
34
+ import { buildStoryBody } from './build-story-body.js';
35
+
36
+ /**
37
+ * Re-render each created Story's body with its blockers resolved to `#N`, and
38
+ * mirror the same edges as native `blocked_by` relations.
39
+ *
40
+ * Groups whose issue was not created — deduped against an existing Issue,
41
+ * suppressed by the ledger, or simply not in `issueByGroupKey` — are skipped
42
+ * rather than guessed at, and an edge pointing at one drops with them
43
+ * (`dependencyRefs` filters it). A `blocked by #undefined` would gate a Story
44
+ * on nothing forever, which is strictly worse than the un-ordered cohort this
45
+ * replaces.
46
+ *
47
+ * @param {object} args
48
+ * @param {Array<object>} args.groups The `create`-eligible groups, in
49
+ * the order their issues were opened.
50
+ * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} [args.edges]
51
+ * @param {Record<string, number>} args.issueByGroupKey Group key → issue number.
52
+ * @param {(issueNumber: number, body: string) => Promise<unknown>} args.updateBody
53
+ * Persist a re-rendered body. Injected so the caller owns the provider call.
54
+ * @param {object|null} [args.provider] Provider for native edge mirroring.
55
+ * Omit (or pass one without the dependency ports) to write footers only.
56
+ * @returns {Promise<{
57
+ * storiesWired: number,
58
+ * bodiesUpdated: number,
59
+ * edgesDeclared: number,
60
+ * native: { edgesAdded: number, edgesSkipped: number, edgesFailed: number }|null
61
+ * }>}
62
+ */
63
+ export async function wireAuditStoryEdges({
64
+ groups,
65
+ edges = [],
66
+ issueByGroupKey,
67
+ updateBody,
68
+ provider = null,
69
+ }) {
70
+ const wired = collectWiredStories({ groups, edges, issueByGroupKey });
71
+ if (wired.length === 0) {
72
+ return {
73
+ storiesWired: 0,
74
+ bodiesUpdated: 0,
75
+ edgesDeclared: 0,
76
+ native: null,
77
+ };
78
+ }
79
+
80
+ let bodiesUpdated = 0;
81
+ for (const story of wired) {
82
+ await updateBody(story.issueNumber, story.body);
83
+ bodiesUpdated++;
84
+ }
85
+
86
+ return {
87
+ storiesWired: wired.length,
88
+ bodiesUpdated,
89
+ edgesDeclared: wired.reduce((n, s) => n + s.blockerKeys.length, 0),
90
+ native: await mirrorNativeEdges({ provider, wired, issueByGroupKey }),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Re-render every group that both has an issue **and** has at least one blocker
96
+ * whose issue also exists.
97
+ *
98
+ * A group with no resolvable blocker is deliberately left alone rather than
99
+ * rewritten to an identical body: an issue-body update is a mutation and a
100
+ * notification, and doing it for a no-op edit is noise on every Story of every
101
+ * sweep.
102
+ *
103
+ * @param {object} args
104
+ * @returns {Array<{ groupKey: string, issueNumber: number, body: string, blockerKeys: string[] }>}
105
+ */
106
+ function collectWiredStories({ groups, edges, issueByGroupKey }) {
107
+ const wired = [];
108
+ for (const group of groups ?? []) {
109
+ const issueNumber = issueByGroupKey?.[group?.groupKey];
110
+ if (!Number.isInteger(issueNumber)) continue;
111
+ const rendered = buildStoryBody({ group, edges, issueByGroupKey });
112
+ const blockerKeys = rendered.dependsOn.filter((key) =>
113
+ Number.isInteger(issueByGroupKey[key]),
114
+ );
115
+ if (blockerKeys.length === 0) continue;
116
+ wired.push({
117
+ groupKey: rendered.groupKey,
118
+ issueNumber,
119
+ body: rendered.body,
120
+ blockerKeys,
121
+ });
122
+ }
123
+ return wired;
124
+ }
125
+
126
+ /**
127
+ * Mirror the declared edges as native GitHub `blocked_by` relations.
128
+ *
129
+ * Two shape hazards this crossing inherits from `plan-persist`, both silent if
130
+ * missed: `applyBlockedByDependencies` indexes `slugToIssueNumber` with plain
131
+ * property access (so it must be a plain object, never a `Map` — a `Map` yields
132
+ * `undefined` for every lookup, skips every edge, and reports success having
133
+ * written nothing), and it reads `dependsOn`, not `depends_on`.
134
+ *
135
+ * @param {object} args
136
+ * @returns {Promise<{ edgesAdded: number, edgesSkipped: number, edgesFailed: number }|null>}
137
+ * `null` when there is no interface to mirror through.
138
+ */
139
+ async function mirrorNativeEdges({ provider, wired, issueByGroupKey }) {
140
+ if (
141
+ typeof provider?.getDependencyWriteContext !== 'function' ||
142
+ typeof provider?.getTicket !== 'function'
143
+ ) {
144
+ Logger.warn(
145
+ '[audit-to-stories] provider exposes no getDependencyWriteContext/getTicket — ' +
146
+ 'skipping native blocked_by edges. Ordering survives in the ' +
147
+ '`blocked by #N` body footers just written.',
148
+ );
149
+ return null;
150
+ }
151
+ try {
152
+ const { gh, owner, repo } = provider.getDependencyWriteContext();
153
+ const summary = await applyBlockedByDependencies({
154
+ // The group key IS the slug here — it is the stable identifier both
155
+ // sides of an edge are keyed by — so `issueByGroupKey` is already the
156
+ // slug→number map the helper wants.
157
+ stories: wired.map((s) => ({
158
+ slug: s.groupKey,
159
+ dependsOn: s.blockerKeys,
160
+ })),
161
+ slugToIssueNumber: issueByGroupKey,
162
+ getTicket: (issueNumber) => provider.getTicket(issueNumber),
163
+ owner,
164
+ repo,
165
+ gh,
166
+ });
167
+ if (summary.edgesFailed > 0) {
168
+ Logger.warn(
169
+ `[audit-to-stories] ${summary.edgesFailed} native blocked_by edge(s) could ` +
170
+ 'not be written. Ordering survives in the `blocked by #N` body footers.',
171
+ );
172
+ }
173
+ return {
174
+ edgesAdded: summary.edgesAdded,
175
+ edgesSkipped: summary.edgesSkipped,
176
+ edgesFailed: summary.edgesFailed,
177
+ };
178
+ } catch (err) {
179
+ Logger.warn(
180
+ `[audit-to-stories] native blocked_by mirroring failed (${err.message}) — ` +
181
+ 'ordering survives in the `blocked by #N` body footers.',
182
+ );
183
+ return null;
184
+ }
185
+ }
@@ -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
 
@@ -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,
@@ -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,