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.
- package/.agents/docs/SDLC.md +8 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +3 -2
- package/.agents/runtime-deps.json +2 -1
- package/.agents/schemas/agentrc.schema.json +8 -2
- package/.agents/scripts/README.md +9 -0
- package/.agents/scripts/audit-to-stories.js +160 -41
- package/.agents/scripts/check-knip-entries.js +47 -24
- package/.agents/scripts/check-lifecycle-lint.js +72 -12
- package/.agents/scripts/coverage-capture.js +7 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
- package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
- package/.agents/scripts/lib/baselines/kernel.js +20 -7
- package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
- 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/config/runners.js +38 -16
- package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -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 +10 -15
- package/.agents/scripts/lib/dependency-parser.js +20 -7
- package/.agents/scripts/lib/findings/provenance-field.js +135 -0
- package/.agents/scripts/lib/findings/route-finding.js +57 -8
- package/.agents/scripts/lib/knip-config-resolver.js +181 -0
- package/.agents/scripts/lib/knip-entry-sync.js +78 -39
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +93 -19
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
- package/.agents/scripts/lib/story-body/footer-block.js +97 -0
- package/.agents/scripts/lib/story-body/story-body.js +6 -22
- package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
- package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
- package/.agents/scripts/resolve-stories.js +21 -5
- package/.agents/scripts/stories-wave-tick.js +192 -9
- package/.agents/workflows/audit-to-stories.md +26 -0
- package/.agents/workflows/helpers/deliver-light.md +5 -2
- package/.agents/workflows/helpers/deliver-reference.md +28 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +80 -1
- package/.agents/workflows/helpers/deliver-story.md +4 -2
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/docs/CHANGELOG.md +26 -0
- 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`.
|
|
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
|
-
*
|
|
139
|
-
*
|
|
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
|
-
*
|
|
148
|
-
*
|
|
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
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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.
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
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 (
|
|
259
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
|
|
@@ -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,
|
|
@@ -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,
|