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
@@ -30,9 +30,19 @@ export const DEFAULT_DECOMPOSER = Object.freeze({
30
30
  * `verifyWaveResults` loop it claimed to bound never existed in the tree, and
31
31
  * its only reader was the retired execution-analysis CLI, which echoed the
32
32
  * number into a report rather than bounding anything.
33
+ *
34
+ * **Serialization tradeoff — `footprintGuard`.** `enforce` is the default and
35
+ * stays it: the file-overlap guard encodes delivery-time-only knowledge (which
36
+ * implementation windows are open, which Stories a foreign lease holds) that no
37
+ * plan-time `depends_on` edge can carry, so demoting it by default would trade
38
+ * a real merge-conflict class for throughput nobody asked for. `advisory`
39
+ * detects collisions and reports every would-be withhold but lets dispatch
40
+ * follow the declared edges alone — for runs whose ordering is fully declared
41
+ * (Story #5044).
33
42
  */
34
43
  const DEFAULT_DELIVER_RUNNER = Object.freeze({
35
44
  concurrencyCap: 3,
45
+ footprintGuard: 'enforce',
36
46
  });
37
47
 
38
48
  /**
@@ -51,28 +61,40 @@ export const DEFAULT_CODE_REVIEW = Object.freeze({
51
61
  *
52
62
  * @param {object | null | undefined} config
53
63
  * @returns {{
54
- * deliverRunner: { concurrencyCap: number },
64
+ * deliverRunner: { concurrencyCap: number, footprintGuard: 'enforce'|'advisory' },
55
65
  * codeReview: { maxFixAttempts: number, maxFixScopeFiles: number, autoFixSeverity: 'high'|'medium' },
56
66
  * decomposer: { concurrencyCap: number },
57
67
  * }}
58
68
  */
59
69
  export function getRunners(config) {
60
- const deliverRunnerUser = config?.delivery?.deliverRunner ?? {};
61
- const codeReviewUser = config?.delivery?.codeReview ?? {};
62
70
  return {
63
- deliverRunner: {
64
- concurrencyCap:
65
- deliverRunnerUser.concurrencyCap ??
66
- DEFAULT_DELIVER_RUNNER.concurrencyCap,
67
- },
68
- codeReview: {
69
- maxFixAttempts:
70
- codeReviewUser.maxFixAttempts ?? DEFAULT_CODE_REVIEW.maxFixAttempts,
71
- maxFixScopeFiles:
72
- codeReviewUser.maxFixScopeFiles ?? DEFAULT_CODE_REVIEW.maxFixScopeFiles,
73
- autoFixSeverity:
74
- codeReviewUser.autoFixSeverity ?? DEFAULT_CODE_REVIEW.autoFixSeverity,
75
- },
71
+ deliverRunner: withDefaults(
72
+ DEFAULT_DELIVER_RUNNER,
73
+ config?.delivery?.deliverRunner,
74
+ ),
75
+ codeReview: withDefaults(DEFAULT_CODE_REVIEW, config?.delivery?.codeReview),
76
76
  decomposer: DEFAULT_DECOMPOSER,
77
77
  };
78
78
  }
79
+
80
+ /**
81
+ * Overlay an operator's block onto the framework defaults — the per-key `??`
82
+ * fallback these accessors have always applied, written once.
83
+ *
84
+ * Iterating the **defaults'** keys rather than the user's is what keeps the
85
+ * returned shape closed: a key the framework does not define cannot reach a
86
+ * consumer through here even if one somehow survived AJV, so a typo degrades to
87
+ * the default rather than to an undefined a caller would read as configuration.
88
+ *
89
+ * @template {Record<string, unknown>} T
90
+ * @param {T} defaults Frozen framework defaults.
91
+ * @param {object|null|undefined} user Operator block from `.agentrc`.
92
+ * @returns {T} A fresh object; the frozen defaults are never mutated.
93
+ */
94
+ function withDefaults(defaults, user) {
95
+ const out = { ...defaults };
96
+ for (const key of Object.keys(defaults)) {
97
+ if (user?.[key] != null) out[key] = user[key];
98
+ }
99
+ return out;
100
+ }
@@ -73,10 +73,18 @@ const DELIVER_RUNNER_SCHEMA = {
73
73
  minimum: 1,
74
74
  description:
75
75
  'Maximum ready Stories dispatched by /deliver at once. Default 3. Moderate by design — keeps host-quota consumption predictable while allowing a small ready-set fan-out. Set 1 for strictly sequential delivery; raise further on hosts with adequate parallel-agent quota. See deliver.md for the sequencing model and throughput tradeoff.',
76
- // getRunners() resolves this inline rather than from an exported
77
- // constant; the rewritten parity suite asserts the two agree.
76
+ // getRunners() resolves this from its own DEFAULT_DELIVER_RUNNER
77
+ // constant, not from this annotation; the parity suite asserts the two
78
+ // agree.
78
79
  default: 3,
79
80
  },
81
+ footprintGuard: {
82
+ type: 'string',
83
+ enum: ['enforce', 'advisory'],
84
+ description:
85
+ "How a file-footprint collision affects dispatch. 'enforce' (default, and the behaviour to keep unless you have a reason) withholds a Story whose footprint races a peer admitted this beat or one still in flight — the guard encodes delivery-time-only knowledge (open implementation windows, foreign leases, ground that moved since planning) that no depends_on edge can carry. 'advisory' still DETECTS every collision and reports each would-be withhold in the tick envelope, but lets dispatch follow the declared depends_on edges alone — a deliberate throughput trade for a run whose ordering is fully declared. See stories-wave-tick.js and helpers/deliver-reference.md.",
86
+ default: 'enforce',
87
+ },
80
88
  },
81
89
  additionalProperties: false,
82
90
  };
@@ -14,6 +14,14 @@ import path from 'node:path';
14
14
  * Run the incremental capture path when
15
15
  * `delivery.quality.gates.crap.incrementalCoverage.enabled` is true.
16
16
  *
17
+ * **This does not shorten the capture run.** The changed-file set decides
18
+ * *whether* to capture, never *what* the capture executes: when nothing under
19
+ * `crap.targetDirs` changed there is no capture at all, and otherwise the
20
+ * ordinary full `npm run test:coverage` runs. The saving that makes the mode
21
+ * worth having is the skip; the other half is the CRAP join, which resolves
22
+ * methods in untouched files from the committed baseline row
23
+ * (`crap-baseline-join.js`) instead of demanding fresh coverage for them.
24
+ *
17
25
  * Returns the process exit code when incremental mode handled the run
18
26
  * (skip, capture, or a capture failure), or `null` when the caller should
19
27
  * fall through to the full-scope path — either incremental mode is
@@ -81,13 +89,12 @@ export function tryIncrementalCapture({
81
89
  }
82
90
 
83
91
  logger.info(
84
- `[coverage-capture] Incremental mode: capturing coverage scoped to ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}]…`,
92
+ `[coverage-capture] Incremental mode: ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}] — capturing…`,
85
93
  );
86
94
  const code = runCaptureImpl({
87
95
  cwd: args.cwd,
88
96
  timeoutMs: coverage?.timeoutMs,
89
97
  log: (m) => logger.info(m),
90
- files: scopedFiles,
91
98
  });
92
99
  if (code !== 0) {
93
100
  logger.error(
@@ -0,0 +1,55 @@
1
+ /**
2
+ * coverage-capture-usage.js — the `--help` spec for `coverage-capture.js`
3
+ * (Story #5063).
4
+ *
5
+ * The delivery workflow invokes `coverage-capture.js` by name
6
+ * (`helpers/deliver-story-reference.md` § Step 1), which brings it under the
7
+ * workflow-invoked self-description contract enforced by
8
+ * `tests/enforcement/workflow-script-help.test.js`. It failed that contract:
9
+ * `--help` fell through to the capture path and spawned the whole coverage
10
+ * suite instead of describing the script.
11
+ *
12
+ * The spec lives here rather than inline for the same reason
13
+ * `coverage-capture-incremental.js` does — a same-file expansion of the CLI
14
+ * shell costs maintainability index on a file already near its floor, and a
15
+ * usage table is data, not decision logic.
16
+ */
17
+
18
+ import { respondToHelp } from './cli-usage.js';
19
+
20
+ /**
21
+ * Usage spec consumed by `cli-usage.js#respondToHelp`. `coverage-capture.js`
22
+ * does not route through `runAsCli` (its synchronous main returns an exit
23
+ * code that `process.exit` forwards), so the help short-circuit is wired by
24
+ * hand rather than declared on a `runAsCli` call.
25
+ *
26
+ * @type {{ invocation: string, summary: string, flags: Array<[string, string]> }}
27
+ */
28
+ const COVERAGE_CAPTURE_USAGE = {
29
+ invocation:
30
+ 'node .agents/scripts/coverage-capture.js [--skip-when-no-crap-files] [--ref <git-ref>] [--cwd <path>]',
31
+ summary:
32
+ 'Ensure coverage/coverage-final.json is present and fresh before the CRAP gate fires, spawning `npm run test:coverage` only when it is stale. Writes a content-digest capture stamp that close-validation reads to skip a redundant re-run.',
33
+ flags: [
34
+ [
35
+ '--skip-when-no-crap-files',
36
+ 'Exit 0 without capturing when no changed file under the CRAP target dirs differs from --ref.',
37
+ ],
38
+ ['--ref <git-ref>', 'Git ref the changed-file set is computed against.'],
39
+ ['--cwd <path>', 'Repository root the capture runs in.'],
40
+ ],
41
+ };
42
+
43
+ /**
44
+ * Answer `--help` / `-h` on stdout, returning whether the caller should stop.
45
+ * Takes the full `process.argv`-shaped array so the CLI shell hands over its
46
+ * own argv unchanged and the index arithmetic lives here rather than at the
47
+ * call site.
48
+ *
49
+ * @param {string[]} argv Full `process.argv`-shaped array.
50
+ * @param {{ write: (s: string) => void }} [out] Defaults to `process.stdout`.
51
+ * @returns {boolean} `true` when help was printed and the run must not proceed.
52
+ */
53
+ export function handleCoverageCaptureHelp(argv = [], out = process.stdout) {
54
+ return respondToHelp(argv.slice(2), COVERAGE_CAPTURE_USAGE, out);
55
+ }
@@ -363,20 +363,21 @@ export const COVERAGE_TIMEOUT_EXIT_CODE = 124;
363
363
  * `timeout(1)` convention exit code 124 so callers can pattern-match a
364
364
  * runaway runner without inspecting signal names.
365
365
  *
366
- * `files` (Story #4981) scopes the spawn to a file list — `npm run
367
- * test:coverage -- <files...>`, the standard npm convention for forwarding
368
- * argv to the underlying script (which most test runners, including Node's
369
- * own, treat as positional file filters). Omitted or empty means the
370
- * default full-scope invocation, byte-identical to the pre-#4981 argv
371
- * (AC-5); a non-empty list makes the scope observable on the emitted
372
- * command line (AC-1).
366
+ * The spawn takes **no positional file arguments**. Story #4981 forwarded the
367
+ * changed-file list as `npm run test:coverage -- <files...>` on the premise
368
+ * that a test runner treats trailing positionals as filters over the suite.
369
+ * Node's runner does not: it treats each path as a test file to execute, so a
370
+ * forwarded *source* file runs as a trivially-passing test and the real suite
371
+ * never runs. `run-coverage.js` discarded the list, which is the only reason
372
+ * that never bit; Story #5063 measured it and Story #5065 removed the
373
+ * plumbing rather than leave a parameter whose obvious "fix" empties the
374
+ * coverage artifact.
373
375
  *
374
376
  * @param {{
375
377
  * cwd: string,
376
378
  * timeoutMs?: number,
377
379
  * runner?: typeof spawnSync,
378
380
  * log?: (m: string) => void,
379
- * files?: string[] | null,
380
381
  * }} opts
381
382
  * @returns {number}
382
383
  */
@@ -385,14 +386,8 @@ export function runCapture({
385
386
  timeoutMs,
386
387
  runner = spawnSync,
387
388
  log = () => {},
388
- files = null,
389
389
  } = {}) {
390
- const scopedFiles = Array.isArray(files) && files.length > 0 ? files : null;
391
- const args = [
392
- 'run',
393
- 'test:coverage',
394
- ...(scopedFiles ? ['--', ...scopedFiles] : []),
395
- ];
390
+ const args = ['run', 'test:coverage'];
396
391
  log(`[coverage-capture] ▶ npm ${args.join(' ')}`);
397
392
  const spawnOpts = {
398
393
  cwd,
@@ -7,18 +7,31 @@
7
7
  * lib/story-adjacency.js, lib/branch-name-guard.js).
8
8
  */
9
9
 
10
+ import { parseFooterBlockedByIds } from './story-body/footer-block.js';
11
+
10
12
  /**
11
- * Parse `blocked by #NNN` and `depends on #NNN` references from text.
12
- * Handles case-insensitive variations.
13
+ * Parse a body's declared blocker issue numbers **footer-scoped and
14
+ * strict**.
15
+ *
16
+ * Only a `blocked by #N` line standing alone inside the `---` footer block
17
+ * declares an edge. The unanchored predecessor scanned the whole body for
18
+ * `blocked by|depends on #N` anywhere, so a Story whose prose merely mentioned
19
+ * a blocker — an example, a changelog note, an acceptance criterion describing
20
+ * this very defect — minted a real dispatch gate that withheld the Story until
21
+ * an unrelated issue closed.
22
+ *
23
+ * The behaviour change is deliberate and user-visible: prose-only mentions
24
+ * outside the footer no longer gate. Every machine-authored body already
25
+ * carries the canonical footer form (`plan-persist` has always serialized it),
26
+ * so only hand-written prose edges are affected — those must be moved into the
27
+ * footer block to keep gating. The grammar itself lives in
28
+ * `lib/story-body/footer-block.js`, shared with the body parser.
13
29
  *
14
30
  * @param {string} body - Issue body or freeform text.
15
- * @returns {number[]} Array of issue numbers this text declares as blockers.
31
+ * @returns {number[]} Array of issue numbers this body declares as blockers.
16
32
  */
17
33
  export function parseBlockedBy(body) {
18
- if (!body) return [];
19
- const re = /(?:blocked\s+by|depends\s+on):?\s+#(\d+)/gi;
20
- const ids = [...body.matchAll(re)].map((m) => Number.parseInt(m[1], 10));
21
- return [...new Set(ids)];
34
+ return parseFooterBlockedByIds(body);
22
35
  }
23
36
 
24
37
  /**
@@ -0,0 +1,135 @@
1
+ /**
2
+ * lib/findings/provenance-field.js — the per-Story `provenance` field.
3
+ *
4
+ * An audit-seeded plan carries dedup identities forward so the next sweep
5
+ * recognises what it already planned. The optional top-level `provenance`
6
+ * field on a `stories.json` entry says **which of them that Story owns**:
7
+ *
8
+ * ```jsonc
9
+ * { "fingerprints": ["<40-char sha1>"], "semanticKeys": ["architecture␟lib/a.js"] }
10
+ * ```
11
+ *
12
+ * Two callers, deliberately split from
13
+ * [`route-finding.js`](route-finding.js): the ticket validator shape-checks
14
+ * the authored field, and plan-persist's assembly renders the owned identities
15
+ * into the footer source it stamps. Neither is dedup *routing*, which is what
16
+ * `route-finding.js` is for — this module reads its identity vocabulary
17
+ * (`SHA1_RE`, `SEMANTIC_KEY_RE`, and the two footer renderers) from there so
18
+ * there is exactly one definition of what a fingerprint or a semantic key
19
+ * looks like.
20
+ *
21
+ * @module lib/findings/provenance-field
22
+ */
23
+
24
+ import {
25
+ fingerprintFooter,
26
+ SEMANTIC_KEY_RE,
27
+ SHA1_RE,
28
+ semanticKeyFooter,
29
+ } from './route-finding.js';
30
+
31
+ /** Human-readable rendering of the `provenance` field's two lists. */
32
+ const PROVENANCE_SHAPE = 'fingerprints[] / semanticKeys[]';
33
+
34
+ /** What each `provenance` list accepts, and how to say so when it does not. */
35
+ const PROVENANCE_FIELDS = Object.freeze({
36
+ fingerprints: { pattern: SHA1_RE, expected: 'a 40-char sha1 hex string' },
37
+ semanticKeys: {
38
+ pattern: SEMANTIC_KEY_RE,
39
+ expected: 'a non-empty key carrying no comma or ">"',
40
+ },
41
+ });
42
+
43
+ /**
44
+ * Validate one authored `provenance` list into its normalized form.
45
+ *
46
+ * @param {unknown} list
47
+ * @param {{ where: string, field: string, pattern: RegExp, expected: string }} spec
48
+ * @returns {string[]} Trimmed, de-duplicated, first-seen order.
49
+ */
50
+ function normalizeList(list, { where, field, pattern, expected }) {
51
+ if (list === null || list === undefined) return [];
52
+ if (!Array.isArray(list)) {
53
+ throw new Error(`${where}: ${field} must be an array of strings`);
54
+ }
55
+ const out = [];
56
+ for (const entry of list) {
57
+ const value = typeof entry === 'string' ? entry.trim() : '';
58
+ if (!pattern.test(value)) {
59
+ throw new Error(
60
+ `${where}: ${field} entry ${JSON.stringify(entry)} is not ${expected}`,
61
+ );
62
+ }
63
+ if (!out.includes(value)) out.push(value);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Normalize the optional per-Story `provenance` field a plan may author —
70
+ * the identities of the findings **that Story owns**.
71
+ *
72
+ * Absence is meaningful and must stay cheap: `undefined` / `null` returns
73
+ * `null`, which is the caller's signal to fall back to the whole-seed union
74
+ * carry. That fallback is not vestigial — leaving the authoring agent to
75
+ * hand-carry provenance out of the seed's HTML comments was measured to fail,
76
+ * and the mechanical union is what closed it. Attribution is **additive**: a
77
+ * plan that attributes gets exact stamping, a plan that does not keeps recall.
78
+ *
79
+ * An empty object is therefore *not* the same as an absent field: it means
80
+ * "this Story owns nothing", and stamps nothing.
81
+ *
82
+ * Present-but-malformed is a hard error rather than a silent drop, because a
83
+ * dropped identity is invisible until the next sweep re-files work that was
84
+ * already planned.
85
+ *
86
+ * @param {unknown} raw
87
+ * @param {string} [label] Identifier for the error message (a Story slug).
88
+ * @returns {{ fingerprints: string[], semanticKeys: string[] }|null}
89
+ * @throws {Error} On any shape the stamper cannot honour exactly.
90
+ */
91
+ export function normalizeOwnedProvenance(raw, label = 'story') {
92
+ if (raw === undefined || raw === null) return null;
93
+ const where = `provenance on "${label}"`;
94
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
95
+ throw new Error(`${where} must be an object of ${PROVENANCE_SHAPE}`);
96
+ }
97
+ const out = { fingerprints: [], semanticKeys: [] };
98
+ for (const [field, list] of Object.entries(raw)) {
99
+ const spec = PROVENANCE_FIELDS[field];
100
+ if (!spec) {
101
+ throw new Error(
102
+ `${where} carries an unknown field: ${field} — only ${PROVENANCE_SHAPE} are stamped`,
103
+ );
104
+ }
105
+ out[field] = normalizeList(list, { where, field, ...spec });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Render the provenance **source document** for a set of owned identities, in
112
+ * the same footer vocabulary `carryProvenanceFooters` harvests from an audit
113
+ * seed. That reuse is the point: attribution changes *which* identities reach
114
+ * a Story body, never how they are stamped, so the carry stays additive,
115
+ * union-preserving and idempotent for an attributed plan exactly as it is for
116
+ * an un-attributed one.
117
+ *
118
+ * An empty (or absent) set renders the empty string, which the carry treats as
119
+ * nothing-to-do — so a Story that owns no findings is stamped with none rather
120
+ * than inheriting its siblings'.
121
+ *
122
+ * Expects the normalized shape {@link normalizeOwnedProvenance} returns; the
123
+ * validator runs first on every production path.
124
+ *
125
+ * @param {{ fingerprints?: string[], semanticKeys?: string[] }|null} [provenance]
126
+ * @returns {string}
127
+ */
128
+ export function ownedProvenanceSource(provenance) {
129
+ const shas = provenance?.fingerprints ?? [];
130
+ const keys = provenance?.semanticKeys ?? [];
131
+ const parts = [];
132
+ if (shas.length > 0) parts.push(fingerprintFooter(shas));
133
+ if (keys.length > 0) parts.push(semanticKeyFooter(keys));
134
+ return parts.join('\n');
135
+ }
@@ -35,11 +35,11 @@ import { fingerprintSeverity } from './severity.js';
35
35
  const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
36
36
  const MARKER = 'audit-fingerprints:';
37
37
  const SEMANTIC_MARKER = 'audit-semantic-keys:';
38
- const SHA1_RE = /^[0-9a-f]{40}$/;
38
+ export const SHA1_RE = /^[0-9a-f]{40}$/;
39
39
  // A semantic key round-trips through a comma-joined footer, so it must not
40
40
  // carry a comma or a `>` (which would truncate the HTML comment). Both are
41
41
  // stripped when the key is built, so this guard is defence-in-depth.
42
- const SEMANTIC_KEY_RE = /^[^,>]+$/;
42
+ export const SEMANTIC_KEY_RE = /^[^,>]+$/;
43
43
 
44
44
  /**
45
45
  * Normalise a single scalar identity field to a stable string.
@@ -343,12 +343,59 @@ function decisionForIssue(issue) {
343
343
  return state === 'closed' ? 'regression-of-closed' : 'update-existing';
344
344
  }
345
345
 
346
+ /**
347
+ * Resolve the pool that **attributes** a finding, out of everything that
348
+ * confirmed it (Story #5045).
349
+ *
350
+ * Confirmation admits two different strengths of claim, and collapsing them
351
+ * was the source of two wrong routes:
352
+ *
353
+ * - An issue carrying the finding's exact **fingerprint** owns it. That is
354
+ * identity: this issue tracks *this* finding.
355
+ * - An issue matching only on the location-based **semantic key** is merely
356
+ * adjacent: it tracks *a* finding at the same `area␟primaryFile`.
357
+ *
358
+ * Owners win outright when any exist. Location-only matches are not discarded
359
+ * — they are the whole point of the semantic key and remain the pool when
360
+ * nothing carries the fingerprint (a reworded finding at an unchanged
361
+ * location). The pool is sorted by issue number so a genuine tie resolves to
362
+ * the earliest-filed issue rather than to whatever order the search port
363
+ * happened to return.
364
+ *
365
+ * @param {Array<{ number: number, state: string, body?: string }>} confirmed
366
+ * @param {string} sha
367
+ * @returns {Array<{ number: number, state: string }>}
368
+ */
369
+ function attributedPool(confirmed, sha) {
370
+ const owns = (issue) => issueCarriesFingerprint(issue, sha);
371
+ const owners = confirmed.filter(owns);
372
+ const pool = owners.length > 0 ? owners : confirmed.filter((i) => !owns(i));
373
+ return [...pool].sort((a, b) => (a?.number ?? 0) - (b?.number ?? 0));
374
+ }
375
+
346
376
  /**
347
377
  * Decide the final route from a confirmed-match pool (issues that both
348
- * surfaced in the candidate/search pass AND carry the finding's fingerprint
349
- * in their footer). Shared by both the semantic-first and fingerprint-only
350
- * code paths so the decision enum is identical regardless of how candidates
351
- * were gathered.
378
+ * surfaced in the candidate/search pass AND carry a confirming footer).
379
+ * Shared by both the semantic-first and fingerprint-only code paths so the
380
+ * decision enum is identical regardless of how candidates were gathered.
381
+ *
382
+ * **Attribution decides, not array order (Story #5045).** The pool used to be
383
+ * read flat, which produced two wrong answers whenever more than one issue
384
+ * confirmed:
385
+ *
386
+ * 1. Two open matches routed `duplicate` pinned to `open[0]` — whichever
387
+ * issue the search port happened to return first. With per-Story
388
+ * provenance that pick is answerable rather than arbitrary: the issue
389
+ * carrying the finding's own fingerprint owns it, and a sibling matching
390
+ * only by location does not.
391
+ * 2. Any open match at all masked a closed one, so a finding whose
392
+ * fingerprint is owned by a **closed** Story routed `update-existing`
393
+ * against an open neighbour — a genuine regression filed as a
394
+ * business-as-usual update. Attribution restores it: state is read off the
395
+ * owning issue, not off whatever else shares its location.
396
+ *
397
+ * {@link attributedPool} owns that selection; the decision below reads only
398
+ * the pool it returns.
352
399
  *
353
400
  * @param {Array<{ number: number, state: string }>} confirmed
354
401
  * @param {string} sha
@@ -359,7 +406,8 @@ function decideFromConfirmed(confirmed, sha) {
359
406
  return { decision: 'new', matchedIssue: null, fingerprint: sha };
360
407
  }
361
408
 
362
- const open = confirmed.filter((h) => normaliseField(h.state) === 'open');
409
+ const attributed = attributedPool(confirmed, sha);
410
+ const open = attributed.filter((h) => normaliseField(h.state) === 'open');
363
411
  if (open.length > 1) {
364
412
  return { decision: 'duplicate', matchedIssue: open[0], fingerprint: sha };
365
413
  }
@@ -371,7 +419,7 @@ function decideFromConfirmed(confirmed, sha) {
371
419
  };
372
420
  }
373
421
 
374
- const closed = confirmed[0];
422
+ const closed = attributed[0];
375
423
  return {
376
424
  decision: decisionForIssue(closed),
377
425
  matchedIssue: closed,
@@ -484,4 +532,5 @@ export const __testing = {
484
532
  decideFromConfirmed,
485
533
  issueCarriesSemanticKey,
486
534
  parseSemanticKeyFooter,
535
+ attributedPool,
487
536
  };