mandrel 2.23.0 → 2.25.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 (101) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  15. package/.agents/scripts/acceptance-eval.js +35 -9
  16. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  17. package/.agents/scripts/audit-to-stories.js +33 -3
  18. package/.agents/scripts/check-context-budget.js +95 -14
  19. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  20. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  21. package/.agents/scripts/deliver-light.js +22 -45
  22. package/.agents/scripts/deliver-recover.js +13 -0
  23. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  24. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  25. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  26. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  27. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  28. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  29. package/.agents/scripts/lib/baseline-loader.js +0 -0
  30. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  31. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  32. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  33. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  34. package/.agents/scripts/lib/baselines/reader.js +10 -0
  35. package/.agents/scripts/lib/baselines/writer.js +7 -3
  36. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  37. package/.agents/scripts/lib/crap-engine.js +97 -19
  38. package/.agents/scripts/lib/crap-utils.js +73 -13
  39. package/.agents/scripts/lib/env-loader.js +46 -16
  40. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  41. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  42. package/.agents/scripts/lib/findings/severity.js +80 -2
  43. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  44. package/.agents/scripts/lib/label-constants.js +17 -0
  45. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  46. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  47. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  48. package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
  49. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  50. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  51. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  52. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  53. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  54. package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
  55. package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
  57. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  58. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  59. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  60. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  61. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  63. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  64. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  65. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  66. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
  67. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  68. package/.agents/scripts/lib/story-plan.js +137 -42
  69. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  70. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  71. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  72. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  73. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  74. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  75. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  76. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  77. package/.agents/scripts/pr-watch-with-update.js +568 -213
  78. package/.agents/scripts/quality-preview.js +25 -0
  79. package/.agents/scripts/run-tests.js +10 -0
  80. package/.agents/scripts/single-story-close.js +6 -0
  81. package/.agents/scripts/single-story-init.js +7 -0
  82. package/.agents/scripts/stories-wave-tick.js +95 -10
  83. package/.agents/scripts/story-plan.js +65 -9
  84. package/.agents/scripts/update-crap-baseline.js +42 -4
  85. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  86. package/.agents/templates/single-story-body.md +16 -8
  87. package/.agents/workflows/audit-architecture.md +69 -13
  88. package/.agents/workflows/audit-quality.md +26 -0
  89. package/.agents/workflows/audit-to-stories.md +30 -4
  90. package/.agents/workflows/deliver.md +81 -95
  91. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  92. package/.agents/workflows/helpers/deliver-light.md +21 -4
  93. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  94. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  95. package/.agents/workflows/helpers/deliver-story.md +104 -119
  96. package/.agents/workflows/helpers/plan-reference.md +34 -0
  97. package/.agents/workflows/plan.md +60 -76
  98. package/docs/CHANGELOG.md +38 -0
  99. package/lib/cli/update.js +14 -11
  100. package/lib/cli/version-check.js +9 -1
  101. package/package.json +1 -1
@@ -0,0 +1,360 @@
1
+ /**
2
+ * escomplex-ast-compat.js — reconcile the `typhonjs-escomplex` code generator
3
+ * with the Babel AST that `typhonjs-escomplex`'s own default parser emits.
4
+ *
5
+ * ## The upstream defect
6
+ *
7
+ * `typhonjs-escomplex` parses with `@typhonjs/babel-parser`, so every AST it
8
+ * analyses is a **Babel** AST. But `typhonjs-escomplex-commons`'
9
+ * `utils/ast/astSyntax.js` — the code generator that `ASTGenerator` drives —
10
+ * was written against **ESTree**. The two disagree on node names
11
+ * (`OptionalMemberExpression` vs a `MemberExpression` with `optional: true`)
12
+ * and on node shapes (Babel's `RegExpLiteral` carries `pattern`/`flags`
13
+ * directly; ESTree wraps them in a `regex` object on a `Literal`).
14
+ *
15
+ * That mismatch is invisible for most code because the metric traversal runs
16
+ * off the syntax plugin's trait tables, not off `astSyntax`. `astSyntax` is
17
+ * only reached where a trait re-serialises a **sub-AST** to synthesise a
18
+ * Halstead operand or a function signature — nine traits do this:
19
+ * `For`/`ForIn`/`ForOf` heads, `Function`/`FunctionExpression`/
20
+ * `ArrowFunctionExpression` parameter lists, `Class`/`ClassExpression`
21
+ * bodies, and `YieldExpression` arguments.
22
+ *
23
+ * Reach one of those with a Babel-only node and `analyzeModule()` throws —
24
+ * aborting the whole module, not just the sub-expression. The constructs that
25
+ * trip it are ordinary modern JavaScript:
26
+ *
27
+ * ```js
28
+ * for (const t of s.split(/[^a-z]+/)) {} // TypeError: …reading 'pattern'
29
+ * for (const t of await xs()) {} // …generator[node.type] is not a function
30
+ * for (const t of a?.b) {} // …generator[node.type] is not a function
31
+ * function f(a = () => import('x')) {} // …this[node.callee.type] is not a function
32
+ * function f(a = { ...b }) {} // TypeError: …reading 'type'
33
+ * function f(a = { m() {} }) {} // TypeError: …reading 'generator'
34
+ * function f(a = class { m() {} }) {} // …this[statement.type] is not a function
35
+ * ```
36
+ *
37
+ * The `{ ...b }` case is upstream issue #24, open and untouched since
38
+ * 2020-12-16 with a byte-identical stack trace. The package last published in
39
+ * June 2022 and the repo that holds the defective file has issues *disabled*,
40
+ * so waiting for an upstream release is not a plan. Consumers of this kernel
41
+ * previously absorbed the breakage as an allowlist of "unscorable" files that
42
+ * grew every time someone wrote a `?.` in a loop head.
43
+ *
44
+ * ## What this module does
45
+ *
46
+ * Installs the missing handlers and repairs the two shape assumptions, on the
47
+ * shared `astSyntax` table, once per process. Every patch is **conditional**:
48
+ * a handler is only installed where the table lacks one, and the two repairs
49
+ * wrap the original rather than replacing its behaviour. If a future kernel
50
+ * bump fixes any of this upstream, the corresponding patch silently stops
51
+ * applying and `install()` reports a shorter list — which
52
+ * `tests/lib/escomplex-ast-compat.test.js` asserts on, so the shrinkage is
53
+ * visible rather than silent.
54
+ *
55
+ * Nothing here changes the score of a file that already parses: the patched
56
+ * paths are exactly the paths that previously threw.
57
+ *
58
+ * @see https://github.com/typhonjs-node-escomplex/typhonjs-escomplex/issues/24
59
+ */
60
+
61
+ import { createRequire } from 'node:module';
62
+
63
+ const require = createRequire(import.meta.url);
64
+
65
+ /** Marker set on every function this module installs, for idempotency. */
66
+ const PATCH_MARKER = Symbol.for('mandrel.escomplexAstCompat');
67
+
68
+ /** Memoised result of the one-time install. */
69
+ let installResult = null;
70
+
71
+ /**
72
+ * Resolve the shared `astSyntax` generator table.
73
+ *
74
+ * This is a deep import into a transitive dependency's `dist/`, which is
75
+ * exactly as fragile as it looks — hence the soft failure. If upstream ever
76
+ * restructures, `install()` reports `available: false` and the engine falls
77
+ * back to today's behaviour (unscorable files, now reported explicitly by
78
+ * the engine rather than silently scored 0).
79
+ *
80
+ * The patch must land on the *same* `typhonjs-escomplex-commons` instance the
81
+ * kernel loads, so `commons` is resolved **through `typhonjs-escomplex`'s own
82
+ * resolution** rather than from here. Resolving it directly would be a coin
83
+ * flip: under a hoisting installer it usually finds the same copy, but under
84
+ * pnpm's isolated layout — or as soon as anything declares `commons` directly —
85
+ * it can find a *different* physical copy, and the patch then lands on a table
86
+ * nobody reads while `install()` cheerfully reports success. Anchoring makes
87
+ * that failure mode unreachable.
88
+ *
89
+ * `requireFn` is the test seam: a cross-checkout verification harness passes
90
+ * its own `createRequire` so the anchor starts from that checkout's escomplex.
91
+ *
92
+ * @param {NodeJS.Require} [requireFn]
93
+ * @returns {Record<string, Function>|null}
94
+ */
95
+ function resolveSyntaxTable(requireFn = require) {
96
+ try {
97
+ const fromKernel = createRequire(requireFn.resolve('typhonjs-escomplex'));
98
+ const mod = fromKernel(
99
+ 'typhonjs-escomplex-commons/dist/utils/ast/astSyntax.js',
100
+ );
101
+ const table = mod?.default ?? mod;
102
+ if (!table || typeof table !== 'object') return null;
103
+ // Sanity-check that this is the table we think it is before mutating it.
104
+ if (typeof table.MemberExpression !== 'function') return null;
105
+ if (Object.isFrozen(table)) return null;
106
+ return table;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Tag a handler as ours so a second `install()` is a no-op.
114
+ *
115
+ * @template {Function} F
116
+ * @param {F} fn
117
+ * @returns {F}
118
+ */
119
+ function mark(fn) {
120
+ fn[PATCH_MARKER] = true;
121
+ return fn;
122
+ }
123
+
124
+ /**
125
+ * Install `name` only if the table has no handler for it. Returns whether the
126
+ * install happened, so the caller can report the applied surface.
127
+ *
128
+ * @param {Record<string, Function>} table
129
+ * @param {string} name
130
+ * @param {Function} handler
131
+ * @returns {boolean}
132
+ */
133
+ function addMissing(table, name, handler) {
134
+ if (typeof table[name] === 'function') return false;
135
+ table[name] = mark(handler);
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Build an ESTree-shaped stand-in for a Babel `ObjectMethod` / `ClassMethod`,
141
+ * whose function bits sit on the node itself rather than under `value`.
142
+ * `MethodDefinition` reads `node.value.{generator,params,body}`, so handing it
143
+ * a synthesized `value` lets the original handler do the work unchanged.
144
+ *
145
+ * @param {object} node
146
+ * @returns {object}
147
+ */
148
+ function asMethodDefinition(node) {
149
+ return {
150
+ ...node,
151
+ kind:
152
+ typeof node.kind === 'string' && node.kind.length > 0
153
+ ? node.kind
154
+ : 'init',
155
+ value: {
156
+ type: 'FunctionExpression',
157
+ generator: Boolean(node.generator),
158
+ async: Boolean(node.async),
159
+ params: node.params ?? [],
160
+ body: node.body,
161
+ },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Patch the shared `astSyntax` table. Idempotent; safe to call from every
167
+ * entry point that is about to run `analyzeModule`.
168
+ *
169
+ * @param {{ requireFn?: NodeJS.Require, memoise?: boolean }} [options] Test
170
+ * seam only — see {@link resolveSyntaxTable}. Production callers pass
171
+ * nothing.
172
+ * @returns {{ available: boolean, applied: string[] }} `available` is false
173
+ * when the upstream internals could not be resolved. `applied` names each
174
+ * patch that was actually needed, so an upstream fix shows up as a
175
+ * shorter list.
176
+ */
177
+ export function install(options = {}) {
178
+ const { requireFn, memoise = true } = options;
179
+ if (memoise && installResult !== null) return installResult;
180
+
181
+ const table = resolveSyntaxTable(requireFn);
182
+ if (table === null) {
183
+ const unavailable = { available: false, applied: [] };
184
+ if (memoise) installResult = unavailable;
185
+ return unavailable;
186
+ }
187
+
188
+ const applied = [];
189
+ const add = (name, handler) => {
190
+ if (addMissing(table, name, handler)) applied.push(name);
191
+ };
192
+
193
+ // ---- Repair 1: Babel's RegExpLiteral shape -------------------------------
194
+ // Upstream reads `node.regex.pattern`, which only exists on an ESTree
195
+ // `Literal`. Babel puts `pattern`/`flags` on the node. Wrapped rather than
196
+ // replaced so the ESTree delegate path (`Literal` → `RegExpLiteral`) keeps
197
+ // its exact original output.
198
+ if (
199
+ typeof table.RegExpLiteral === 'function' &&
200
+ !table.RegExpLiteral[PATCH_MARKER]
201
+ ) {
202
+ const original = table.RegExpLiteral;
203
+ table.RegExpLiteral = mark(function RegExpLiteral(node, state) {
204
+ if (node?.regex === undefined) {
205
+ state.output.write(
206
+ `new RegExp(${JSON.stringify(node?.pattern ?? '')}, ` +
207
+ `${JSON.stringify(node?.flags ?? '')})`,
208
+ );
209
+ return;
210
+ }
211
+ return original.call(this, node, state);
212
+ });
213
+ applied.push('RegExpLiteral');
214
+ }
215
+
216
+ // ---- Repair 2: ObjectExpression's hard `this.Property()` dispatch --------
217
+ // `ObjectExpression` calls `this.Property(el)` on every element regardless
218
+ // of the element's actual type. Two Babel shapes get mis-routed by that:
219
+ //
220
+ // `{ ...b }` → a `SpreadElement` lands in `Property`, which reads
221
+ // `node.key.type` and throws. Upstream issue #24.
222
+ // `{ m() {} }` → an `ObjectMethod` has `kind: 'method'`, so `Property`'s
223
+ // `node.kind[0] !== 'i'` test sends the raw Babel node to
224
+ // `MethodDefinition`, which reads `node.value.generator`
225
+ // and throws.
226
+ //
227
+ // Patching `Property` rather than `ObjectExpression` keeps the patch small
228
+ // and leaves the indentation-sensitive object serialisation untouched:
229
+ // anything arriving here that is not actually a property node gets
230
+ // re-dispatched on its real type.
231
+ //
232
+ // `Property` and `ObjectProperty` are excluded from re-dispatch because the
233
+ // table aliases `ObjectProperty` to `Property` — re-dispatching either would
234
+ // recurse into this wrapper forever.
235
+ if (typeof table.Property === 'function' && !table.Property[PATCH_MARKER]) {
236
+ const original = table.Property;
237
+ const PROPERTY_TYPES = new Set(['Property', 'ObjectProperty']);
238
+ table.Property = mark(function Property(node, state) {
239
+ const type = node?.type;
240
+ if (
241
+ typeof type === 'string' &&
242
+ !PROPERTY_TYPES.has(type) &&
243
+ typeof this[type] === 'function'
244
+ ) {
245
+ return this[type](node, state);
246
+ }
247
+ return original.call(this, node, state);
248
+ });
249
+ applied.push('Property');
250
+ }
251
+
252
+ // ---- Missing handlers: Babel-only node types ----------------------------
253
+
254
+ // `await x` — mirrors upstream's `YieldExpression`.
255
+ add('AwaitExpression', function AwaitExpression(node, state) {
256
+ const output = state.output;
257
+ output.write('await ');
258
+ output.operators.push('await');
259
+ if (node.argument) this[node.argument.type](node.argument, state);
260
+ });
261
+
262
+ // `a?.b` / `a?.[b]` — mirrors upstream's `MemberExpression`, with `?.`
263
+ // recorded as its own operator so optional access is not counted as plain
264
+ // member access.
265
+ add(
266
+ 'OptionalMemberExpression',
267
+ function OptionalMemberExpression(node, state) {
268
+ const output = state.output;
269
+ this[node.object.type](node.object, state);
270
+ if (node.computed) {
271
+ output.write('?.[');
272
+ this[node.property.type](node.property, state);
273
+ output.write(']');
274
+ output.operators.push('?.[]');
275
+ } else {
276
+ output.write('?.');
277
+ output.operators.push('?.');
278
+ this[node.property.type](node.property, state);
279
+ }
280
+ },
281
+ );
282
+
283
+ // `a?.()` — mirrors upstream's `CallExpression`.
284
+ add('OptionalCallExpression', function OptionalCallExpression(node, state) {
285
+ this[node.callee.type](node.callee, state);
286
+ state.output.write('?.');
287
+ state.output.operators.push('?.()');
288
+ ASTUtil().formatSequence(node.arguments ?? [], state, this);
289
+ });
290
+
291
+ // The callee node of a dynamic `import(...)`. Babel models the `import`
292
+ // keyword as its own node type; ESTree has no equivalent.
293
+ add('Import', function Import(_node, state) {
294
+ state.output.write('import');
295
+ state.output.operators.push('import()');
296
+ });
297
+
298
+ // `{ m() {} }` / `{ get m() {} }` — Babel's ObjectMethod.
299
+ add('ObjectMethod', function ObjectMethod(node, state) {
300
+ return this.MethodDefinition(asMethodDefinition(node), state);
301
+ });
302
+
303
+ // `class { m() {} }` — Babel's ClassMethod (ESTree: MethodDefinition).
304
+ add('ClassMethod', function ClassMethod(node, state) {
305
+ return this.MethodDefinition(asMethodDefinition(node), state);
306
+ });
307
+
308
+ // `class { p = 1 }` — Babel's ClassProperty (ESTree: PropertyDefinition).
309
+ const classProperty = function ClassProperty(node, state) {
310
+ const output = state.output;
311
+ if (node.static) {
312
+ output.write('static ');
313
+ output.operators.push('static');
314
+ }
315
+ if (node.computed) {
316
+ output.write('[');
317
+ this[node.key.type](node.key, state);
318
+ output.write(']');
319
+ } else {
320
+ this[node.key.type](node.key, state);
321
+ }
322
+ if (node.value) {
323
+ output.write(' = ');
324
+ output.operators.push('=');
325
+ this[node.value.type](node.value, state);
326
+ }
327
+ output.write(';');
328
+ };
329
+ add('ClassProperty', classProperty);
330
+ add('PropertyDefinition', classProperty);
331
+
332
+ const result = { available: true, applied };
333
+ if (memoise) installResult = result;
334
+ return result;
335
+ }
336
+
337
+ /**
338
+ * `ASTUtil` is only needed by the `OptionalCallExpression` handler, and only at
339
+ * call time — resolving it lazily keeps `install()` free of a second deep
340
+ * import that could fail at module load. Anchored through the kernel for the
341
+ * same reason as {@link resolveSyntaxTable}.
342
+ *
343
+ * `formatSequence` is a pure helper that takes the traveler and state as
344
+ * arguments, so which copy answers is immaterial — but resolving it the same
345
+ * way keeps one rule in this file rather than two.
346
+ *
347
+ * @returns {{ formatSequence: Function }}
348
+ */
349
+ function ASTUtil() {
350
+ const fromKernel = createRequire(require.resolve('typhonjs-escomplex'));
351
+ const mod = fromKernel(
352
+ 'typhonjs-escomplex-commons/dist/utils/ast/ASTUtil.js',
353
+ );
354
+ return mod?.default ?? mod;
355
+ }
356
+
357
+ // A test that needs to re-run the install against the already-patched table
358
+ // passes `{ memoise: false }` rather than resetting module state — the patches
359
+ // are self-detecting via PATCH_MARKER, so a non-memoised re-run is exactly the
360
+ // idempotency assertion worth making.
@@ -30,6 +30,8 @@
30
30
 
31
31
  import crypto from 'node:crypto';
32
32
 
33
+ import { fingerprintSeverity } from './severity.js';
34
+
33
35
  const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
34
36
  const MARKER = 'audit-fingerprints:';
35
37
  const SEMANTIC_MARKER = 'audit-semantic-keys:';
@@ -65,6 +67,20 @@ function normaliseLabels(labels) {
65
67
 
66
68
  /**
67
69
  * Compute the stable identity payload for a finding.
70
+ *
71
+ * **Severity is projected, not raw (Story #4877).** The severity vocabulary was
72
+ * normalised onto the canonical five-level scale in the same change that wrote
73
+ * this comment, and severity is an identity field — so a naive
74
+ * `normaliseField(finding.severity)` here would have re-minted the fingerprint
75
+ * of every finding whose spelling the normalisation touched, silently breaking
76
+ * dedup against every Issue already filed. {@link fingerprintSeverity} is the
77
+ * projection that makes the hash **invariant** under that normalisation: it
78
+ * resolves aliases onto their canonical level, keeps an absent severity as the
79
+ * empty string (what the raw call produced), and passes an unrecognised value
80
+ * through verbatim. Labels are deliberately left on the raw
81
+ * lower-case/trim/sort path for the same reason — order- and case-insensitive
82
+ * already, and any further folding would move existing shas.
83
+ *
68
84
  * @param {object} finding
69
85
  * @returns {{ title: string, area: string, primaryFile: string, severity: string, labels: string }}
70
86
  */
@@ -73,7 +89,7 @@ function fingerprintComponents(finding) {
73
89
  title: normaliseField(finding?.title),
74
90
  area: normaliseField(finding?.area),
75
91
  primaryFile: normaliseField(finding?.primaryFile),
76
- severity: normaliseField(finding?.severity),
92
+ severity: fingerprintSeverity(finding?.severity),
77
93
  labels: normaliseLabels(finding?.labels),
78
94
  };
79
95
  }
@@ -142,20 +158,18 @@ export function semanticKeyFooter(keys) {
142
158
 
143
159
  /**
144
160
  * Extract semantic keys from an Issue body carrying the semantic-key footer.
145
- * Internal — the audit filers stamp the footer via {@link semanticKeyFooter};
146
- * only the confirmation path here reads it back.
161
+ * The audit filers stamp the footer via {@link semanticKeyFooter}; the
162
+ * confirmation path here and {@link carryProvenanceFooters} read it back.
147
163
  *
148
164
  * @param {string} body
149
165
  * @returns {string[]}
150
166
  */
151
167
  function parseSemanticKeyFooter(body) {
152
- if (typeof body !== 'string') return [];
153
- const match = body.match(/<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/);
154
- if (!match) return [];
155
- return match[1]
156
- .split(',')
157
- .map((s) => s.trim())
158
- .filter((s) => s.length > 0);
168
+ return parseAllFooterValues(
169
+ body,
170
+ /<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/g,
171
+ (s) => s.length > 0,
172
+ );
159
173
  }
160
174
 
161
175
  /**
@@ -190,13 +204,104 @@ export function fingerprintFooter(shas) {
190
204
  * @returns {string[]}
191
205
  */
192
206
  export function parseFingerprintFooter(body) {
193
- if (typeof body !== 'string') return [];
194
- const match = body.match(/<!--\s*audit-fingerprints:\s*([^>]+?)\s*-->/);
195
- if (!match) return [];
196
- return match[1]
197
- .split(',')
198
- .map((s) => s.trim())
199
- .filter((s) => SHA1_RE.test(s));
207
+ return parseAllFooterValues(
208
+ body,
209
+ /<!--\s*audit-fingerprints:\s*([^>]+?)\s*-->/g,
210
+ (s) => SHA1_RE.test(s),
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Collect the comma-separated values out of **every** occurrence of a footer
216
+ * marker in `text`, de-duplicated, in first-seen order.
217
+ *
218
+ * Scanning every occurrence rather than only the first matters for the
219
+ * provenance carry (Story #4877): the audit Single-plan seed stamps one footer
220
+ * pair per MVP Scope bullet, so a multi-group seed carries several. A
221
+ * first-match-only parse silently dropped every group but the first, which
222
+ * would have made the carry look wired while leaking most of the provenance.
223
+ * Reading all footers is also strictly more correct for issue-body confirmation
224
+ * — a body that accumulated two footer lines confirms against either.
225
+ *
226
+ * @param {unknown} text
227
+ * @param {RegExp} pattern — a global regex whose first capture group is the
228
+ * comma-separated value list.
229
+ * @param {(value: string) => boolean} isValid
230
+ * @returns {string[]}
231
+ */
232
+ function parseAllFooterValues(text, pattern, isValid) {
233
+ if (typeof text !== 'string') return [];
234
+ const out = [];
235
+ const seen = new Set();
236
+ for (const match of text.matchAll(pattern)) {
237
+ for (const raw of match[1].split(',')) {
238
+ const value = raw.trim();
239
+ if (!isValid(value) || seen.has(value)) continue;
240
+ seen.add(value);
241
+ out.push(value);
242
+ }
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * Carry audit dedup provenance from a source document into a target body
249
+ * (Story #4877).
250
+ *
251
+ * The audit sweep's Single-plan path emits a `/plan` seed whose MVP Scope
252
+ * bullets already carry the `audit-fingerprints` / `audit-semantic-keys`
253
+ * footers (Story #4626). Nothing then copied them into the Story `/plan`
254
+ * actually persisted, so the recommended path filed Stories that the next
255
+ * sweep could not recognise and re-filed as new. It was left to the authoring
256
+ * agent to notice HTML comments in a one-pager and hand-carry them — a
257
+ * remembered step, which is to say no step at all.
258
+ *
259
+ * This is that carry, as a function: harvest both footers out of `from`, and
260
+ * append whichever provenance `into` is missing. It is deliberately:
261
+ *
262
+ * - **Additive.** Shas and keys already present in `into` are never duplicated,
263
+ * and a footer `into` already carries is left exactly as authored.
264
+ * - **Union-preserving.** When both sides carry footers the result carries the
265
+ * union, so a hand-authored fingerprint is not dropped in favour of the seed's.
266
+ * - **Idempotent.** Re-running over its own output is a no-op, so a resumed
267
+ * persist cannot stack footers.
268
+ * - **Silent on nothing-to-do.** No provenance in `from` returns `into`
269
+ * unchanged with `carried: false`, so a non-audit plan run is untouched.
270
+ *
271
+ * @param {{ from?: string, into?: string }} args — `from` is the provenance
272
+ * source (the seed markdown); `into` is the body being persisted.
273
+ * @returns {{ body: string, carried: boolean, fingerprints: string[], semanticKeys: string[] }}
274
+ * `body` is the augmented text; `fingerprints` / `semanticKeys` are the values
275
+ * newly carried (empty when there was nothing to carry).
276
+ */
277
+ export function carryProvenanceFooters({ from = '', into = '' } = {}) {
278
+ const body = typeof into === 'string' ? into : '';
279
+ const source = typeof from === 'string' ? from : '';
280
+
281
+ const have = new Set(parseFingerprintFooter(body));
282
+ const haveKeys = new Set(parseSemanticKeyFooter(body));
283
+ const fingerprints = parseFingerprintFooter(source).filter(
284
+ (sha) => !have.has(sha),
285
+ );
286
+ const semanticKeys = parseSemanticKeyFooter(source).filter(
287
+ (key) => !haveKeys.has(key),
288
+ );
289
+
290
+ if (fingerprints.length === 0 && semanticKeys.length === 0) {
291
+ return { body, carried: false, fingerprints: [], semanticKeys: [] };
292
+ }
293
+
294
+ const appended = [];
295
+ if (fingerprints.length > 0) appended.push(fingerprintFooter(fingerprints));
296
+ if (semanticKeys.length > 0) appended.push(semanticKeyFooter(semanticKeys));
297
+
298
+ const separator = body.length === 0 || body.endsWith('\n') ? '' : '\n';
299
+ return {
300
+ body: `${body}${separator}\n${appended.join('\n')}\n`,
301
+ carried: true,
302
+ fingerprints,
303
+ semanticKeys,
304
+ };
200
305
  }
201
306
 
202
307
  /**
@@ -378,4 +483,5 @@ export const __testing = {
378
483
  confirmCandidates,
379
484
  decideFromConfirmed,
380
485
  issueCarriesSemanticKey,
486
+ parseSemanticKeyFooter,
381
487
  };
@@ -40,10 +40,38 @@ export const SEVERITIES = Object.freeze([
40
40
  * malformed input — and because both the classify and promote paths share it,
41
41
  * malformed input still fingerprints identically across the two paths.
42
42
  */
43
- export const DEFAULT_SEVERITY = 'info';
43
+ const DEFAULT_SEVERITY = 'info';
44
44
 
45
45
  const SEVERITY_SET = new Set(SEVERITIES);
46
46
 
47
+ /**
48
+ * Non-canonical spellings that resolve onto a canonical level (Story #4877).
49
+ *
50
+ * The vocabulary drifted because four modules each carried their own partial
51
+ * copy of it: `audit-to-stories/parse-audit-md.js` recognised
52
+ * `critical|high|medium|mod|moderate|low` and nothing else, `audit-to-stories.js`
53
+ * ranked `critical|high|medium|low`, and `audit-to-stories/seed-from-findings.js`
54
+ * ordered the same four. None of them knew `info` — the canonical floor — so an
55
+ * `Info` / `Informational` finding parsed to `null`, tallied as `unknown`, and
56
+ * was dropped by EVERY severity-filtered run (including `--severity low`).
57
+ * Folding the alias table in here makes this module the only place the
58
+ * vocabulary is written down.
59
+ *
60
+ * Keys are already lower-cased and trimmed by {@link normalizeSeverity}.
61
+ *
62
+ * @type {Readonly<Record<string, string>>}
63
+ */
64
+ const SEVERITY_ALIASES = Object.freeze({
65
+ blocker: 'critical',
66
+ major: 'high',
67
+ mod: 'medium',
68
+ moderate: 'medium',
69
+ minor: 'low',
70
+ informational: 'info',
71
+ nit: 'info',
72
+ trivial: 'info',
73
+ });
74
+
47
75
  /**
48
76
  * Numeric rank for "highest severity wins" comparisons, derived from the
49
77
  * canonical order so the ranking has exactly one source. `critical` is the
@@ -74,7 +102,44 @@ export const SEVERITY_RANK = Object.freeze(
74
102
  export function normalizeSeverity(value, fallback = DEFAULT_SEVERITY) {
75
103
  if (typeof value !== 'string') return fallback;
76
104
  const normalized = value.trim().toLowerCase();
77
- return SEVERITY_SET.has(normalized) ? normalized : fallback;
105
+ if (SEVERITY_SET.has(normalized)) return normalized;
106
+ return SEVERITY_ALIASES[normalized] ?? fallback;
107
+ }
108
+
109
+ /**
110
+ * The **identity projection** of a severity, for use inside a finding
111
+ * fingerprint — and nowhere else (Story #4877).
112
+ *
113
+ * `severity` is a `fingerprintFinding` identity field
114
+ * (`route-finding.js`), so whatever this returns is folded into the sha that
115
+ * deduplicates findings against already-filed Issues. That makes the severity
116
+ * pipeline and the severity *identity* two different jobs with two different
117
+ * failure modes:
118
+ *
119
+ * - {@link normalizeSeverity} resolves an absent or unrecognised severity to
120
+ * `info` so downstream filtering and tallies have a level to work with.
121
+ * - This function must NOT. Folding `info` in where the previous
122
+ * implementation folded the empty string would re-mint the fingerprint of
123
+ * every finding that carries no severity, silently breaking dedup for all of
124
+ * them. An absent severity therefore stays the empty string, exactly as the
125
+ * raw `String(value).toLowerCase().trim()` it replaces produced.
126
+ *
127
+ * What it *does* change is alias resolution: `Informational` and `info` project
128
+ * onto the same `info`, so the fingerprint is **invariant** under the
129
+ * normalization this Story introduces — a finding hashes the same whether it is
130
+ * fingerprinted before or after {@link normalizeSeverity} has run over it. An
131
+ * unrecognised non-empty value is passed through verbatim rather than collapsed,
132
+ * again so no already-filed fingerprint moves.
133
+ *
134
+ * @param {unknown} value — the raw severity field off a finding.
135
+ * @returns {string} a canonical level, the empty string when absent, or the
136
+ * lower-cased raw value when it is neither canonical nor a known alias.
137
+ */
138
+ export function fingerprintSeverity(value) {
139
+ if (typeof value !== 'string') return '';
140
+ const raw = value.trim().toLowerCase();
141
+ if (raw.length === 0) return '';
142
+ return normalizeSeverity(raw, raw);
78
143
  }
79
144
 
80
145
  /**
@@ -99,3 +164,16 @@ export function highestSeverity(values) {
99
164
  }
100
165
  return best;
101
166
  }
167
+
168
+ /**
169
+ * Internals reached by the unit tests only. The floor and the alias table are
170
+ * consumed exclusively by this module's own exported functions, so they are
171
+ * not public API — but the alias table carries a structural invariant (no
172
+ * alias shadows a canonical level, every target IS one) that cannot be proven
173
+ * through `normalizeSeverity` alone, since that function returns a canonical
174
+ * value by construction.
175
+ */
176
+ export const __testing = {
177
+ DEFAULT_SEVERITY,
178
+ SEVERITY_ALIASES,
179
+ };
@@ -111,6 +111,22 @@ export const META_LABELS = {
111
111
  CONSUMER_IMPROVEMENT: 'meta::consumer-improvement',
112
112
  };
113
113
 
114
+ /**
115
+ * Risk-axis labels. Planning/audit metadata only — `risk::high` carries no
116
+ * runtime behaviour (the single runtime pause point is `agent::blocked`, see
117
+ * `.agents/instructions.md` § 1.J); it marks a ticket a human should look at
118
+ * first. Applied by the audit filer to a Story whose merge contains a Critical
119
+ * finding.
120
+ *
121
+ * Named here because it was a bare `'risk::high'` string literal in
122
+ * `audit-to-stories/build-story-body.js` and defined by no taxonomy at all —
123
+ * neither `LABEL_TAXONOMY` nor the audit bootstrap created it, so the audit
124
+ * filer generated a label the repo did not have (Story #4877).
125
+ */
126
+ export const RISK_LABELS = {
127
+ HIGH: 'risk::high',
128
+ };
129
+
114
130
  /**
115
131
  * Planning-axis labels (Epic #2880 F7). Currently scoped to the
116
132
  * `planning::healthcheck-waived` operator-applied waiver — a historical
@@ -147,6 +163,7 @@ export const PLANNING_HEALTHCHECK_WAIVED = 'planning::healthcheck-waived';
147
163
  */
148
164
  export const LABEL_COLORS = {
149
165
  TYPE: '#7057FF',
166
+ RISK_HIGH: '#B60205',
150
167
  AGENT: '#0E8A16',
151
168
  STATUS_BLOCKED: '#D93F0B',
152
169
  ACCEPTANCE: '#FBCA04',