mandrel 2.54.0 → 2.56.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 (134) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +8 -2
  5. package/.agents/docs/configuration.md +5 -0
  6. package/.agents/rules/ci-remediation.md +39 -21
  7. package/.agents/schemas/agentrc.schema.json +34 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/audit-to-stories.js +374 -76
  11. package/.agents/scripts/check-audit-attribution.js +119 -62
  12. package/.agents/scripts/check-test-portability.js +512 -0
  13. package/.agents/scripts/coverage-capture.js +17 -10
  14. package/.agents/scripts/evidence-gate.js +31 -4
  15. package/.agents/scripts/file-ci-gap.js +306 -0
  16. package/.agents/scripts/generate-workflows-doc.js +65 -14
  17. package/.agents/scripts/git-cleanup.js +4 -0
  18. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  19. package/.agents/scripts/lib/audit-advisories.js +195 -0
  20. package/.agents/scripts/lib/audit-attribution.js +22 -0
  21. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
  23. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  24. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  25. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  26. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  27. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
  28. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  29. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  30. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  32. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  33. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  34. package/.agents/scripts/lib/cli-args.js +26 -0
  35. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  36. package/.agents/scripts/lib/close-validation/process.js +7 -3
  37. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  38. package/.agents/scripts/lib/config/ci.js +28 -9
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  40. package/.agents/scripts/lib/config-settings-schema.js +52 -1
  41. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  42. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  43. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  44. package/.agents/scripts/lib/coverage-capture.js +77 -3
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +42 -2
  50. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  51. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  52. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  53. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  54. package/.agents/scripts/lib/label-constants.js +6 -1
  55. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  56. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  57. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  58. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  59. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  60. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  61. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  62. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  63. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  64. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  65. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  66. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  67. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  68. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  69. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  70. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  71. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  72. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  73. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  74. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  75. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  76. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
  77. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  78. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  79. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  80. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  81. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  82. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  83. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  84. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  85. package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
  86. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  92. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  93. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  94. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  95. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  96. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  97. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  98. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  99. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  100. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  102. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  103. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  104. package/.agents/scripts/lib/test-temp.js +167 -30
  105. package/.agents/scripts/lib/validation-evidence.js +37 -0
  106. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  107. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  108. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  109. package/.agents/scripts/merge-baseline.js +175 -21
  110. package/.agents/scripts/pr-watch-with-update.js +3 -2
  111. package/.agents/scripts/providers/github/errors.js +22 -1
  112. package/.agents/scripts/providers/github/issues.js +106 -1
  113. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  114. package/.agents/scripts/providers/github.js +6 -0
  115. package/.agents/scripts/resolve-stories.js +44 -34
  116. package/.agents/scripts/single-story-close.js +5 -0
  117. package/.agents/scripts/stories-wave-tick.js +37 -13
  118. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  119. package/.agents/workflows/audit-accessibility.md +16 -31
  120. package/.agents/workflows/audit-mobile.md +20 -37
  121. package/.agents/workflows/audit-to-stories.md +63 -27
  122. package/.agents/workflows/git-cleanup.md +17 -3
  123. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  124. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  125. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  126. package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
  127. package/.agents/workflows/helpers/deliver-story.md +15 -12
  128. package/.agents/workflows/helpers/plan-reference.md +30 -0
  129. package/.agents/workflows/mandrel-plan.md +10 -13
  130. package/.agents/workflows/memory-consolidate.md +14 -9
  131. package/docs/CHANGELOG.md +37 -0
  132. package/lib/cli/registry.js +64 -21
  133. package/lib/cli/sync.js +27 -2
  134. package/package.json +7 -4
@@ -0,0 +1,195 @@
1
+ /**
2
+ * audit-advisories.js — run `npm audit --json` and diff advisories across refs.
3
+ *
4
+ * The measuring half of `audit-attribution.js` next door. That module owns the
5
+ * verdict vocabulary and how a verdict is worded; this one owns what a verdict
6
+ * is computed FROM.
7
+ *
8
+ * The comparison is **per advisory**, not per exit code. Reading only "did the
9
+ * base audit fail too" collapses the case a busy repository meets most: a base
10
+ * that is already red for advisory A, and a diff that adds advisory B. That
11
+ * answered `pre-existing` — a true statement about A, and a misleading one
12
+ * about the branch, because the author was told their diff was innocent while
13
+ * it was carrying B. Diffing advisory ids at or above `high` reports both facts
14
+ * separately: B introduced, A pre-existing.
15
+ *
16
+ * The npm spawn is injectable (`.agents/rules/test-seams.md`), so the whole
17
+ * projection is reachable without a registry round-trip.
18
+ */
19
+
20
+ import { spawnCapture } from './child-exec.js';
21
+
22
+ /** The levels the required SCA step fails on, so the levels attribution reads. */
23
+ const BLOCKING_SEVERITIES = new Set(['critical', 'high']);
24
+
25
+ /**
26
+ * The stable identity of one advisory in an `npm audit --json` report.
27
+ *
28
+ * `source` is the advisory's own numeric id and is what makes two runs
29
+ * comparable; the URL and title are fallbacks for a registry that omits it. A
30
+ * `via` entry that is a bare string names another package rather than an
31
+ * advisory and is handled by the package-level fallback in
32
+ * {@link extractBlockingAdvisories}.
33
+ *
34
+ * @param {object} via
35
+ * @returns {string|null}
36
+ */
37
+ function advisoryIdOf(via) {
38
+ if (via?.source !== undefined && via.source !== null) {
39
+ return `advisory:${via.source}`;
40
+ }
41
+ if (typeof via?.url === 'string' && via.url.length > 0) return via.url;
42
+ if (typeof via?.title === 'string' && via.title.length > 0) {
43
+ return `title:${via.title}`;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Project an `npm audit --json` report onto the set of advisories at or above
50
+ * `high`, as `{ id, severity, title }` records sorted by id.
51
+ *
52
+ * A vulnerable package whose `via` list carries no advisory object at all — the
53
+ * transitive case, where `via` is a list of package names — still contributes a
54
+ * `package:<name>` entry. Dropping it would let a real blocking advisory go
55
+ * uncounted, and an attribution that under-counts the head is one that reports
56
+ * a genuine regression as `pre-existing`.
57
+ *
58
+ * @param {object|null} report — parsed `npm audit --json` output.
59
+ * @returns {Array<{ id: string, severity: string, title: string }>}
60
+ */
61
+ export function extractBlockingAdvisories(report) {
62
+ const packages = report?.vulnerabilities;
63
+ if (!packages || typeof packages !== 'object') return [];
64
+ const found = new Map();
65
+ for (const [name, entry] of Object.entries(packages)) {
66
+ if (BLOCKING_SEVERITIES.has(String(entry?.severity ?? '').toLowerCase())) {
67
+ collectPackageAdvisories(name, entry, found);
68
+ }
69
+ }
70
+ return [...found.values()].sort((a, b) => a.id.localeCompare(b.id));
71
+ }
72
+
73
+ /**
74
+ * Add one vulnerable package's blocking advisories to `found`, falling back to
75
+ * a `package:<name>` entry when its `via` list names packages rather than
76
+ * advisories.
77
+ *
78
+ * @param {string} name
79
+ * @param {object} entry
80
+ * @param {Map<string, object>} found
81
+ */
82
+ function collectPackageAdvisories(name, entry, found) {
83
+ const before = found.size;
84
+ for (const via of Array.isArray(entry?.via) ? entry.via : []) {
85
+ const severity = String(via?.severity ?? entry.severity).toLowerCase();
86
+ const id = BLOCKING_SEVERITIES.has(severity) ? advisoryIdOf(via) : null;
87
+ if (id && !found.has(id)) {
88
+ found.set(id, { id, severity, title: via?.title ?? name });
89
+ }
90
+ }
91
+ if (found.size === before) {
92
+ found.set(`package:${name}`, {
93
+ id: `package:${name}`,
94
+ severity: entry.severity,
95
+ title: name,
96
+ });
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Split the head's blocking advisories into the ones this diff **introduced**
102
+ * (head minus base) and the ones the merge base already carried
103
+ * (the intersection).
104
+ *
105
+ * A `null` base is the degraded read: nothing can be attributed, so neither
106
+ * list is populated and the caller reports `unknown` rather than guessing.
107
+ *
108
+ * @param {{ head?: Array<object>, base?: Array<object>|null }} params
109
+ * @returns {{ introduced: Array<object>, preExisting: Array<object> }}
110
+ */
111
+ export function diffAdvisories({ head = [], base = null }) {
112
+ if (!Array.isArray(base)) return { introduced: [], preExisting: [] };
113
+ const baseIds = new Set(base.map((a) => a.id));
114
+ return {
115
+ introduced: head.filter((a) => !baseIds.has(a.id)),
116
+ preExisting: head.filter((a) => baseIds.has(a.id)),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Run `npm audit --json` over a dependency manifest pair and project it.
122
+ *
123
+ * `--package-lock-only` audits the committed lockfile without installing, so
124
+ * the probe never touches the job's own `node_modules`: an attribution
125
+ * mechanism that could disturb the tree it is reporting on would be a worse
126
+ * defect than the one it explains. `--json` is what makes the two runs
127
+ * comparable at all — the human report says which advisories exist, but only
128
+ * as prose.
129
+ *
130
+ * @param {string} dir — directory holding package.json + package-lock.json.
131
+ * @param {{ spawn?: Function }} [deps]
132
+ * @returns {{ failed: boolean, advisories: Array<object> }}
133
+ * @throws {Error} when npm could not evaluate the tree at all.
134
+ */
135
+ export function auditAdvisories(dir, { spawn = spawnCapture } = {}) {
136
+ const result = spawn(
137
+ 'npm',
138
+ ['audit', '--json', '--audit-level=high', '--package-lock-only'],
139
+ { cwd: dir },
140
+ );
141
+ const report = parseAuditJson(result?.stdout);
142
+ // npm exits non-zero for "advisories found" and for "could not audit" alike.
143
+ // Only a real audit verdict carries a `vulnerabilities` map; anything else is
144
+ // a probe failure the caller must read as `unknown`.
145
+ if (!report) {
146
+ throw new Error(
147
+ `npm audit could not evaluate the tree: ${String(result?.stderr ?? '').slice(0, 200)}`,
148
+ );
149
+ }
150
+ const advisories = extractBlockingAdvisories(report);
151
+ return { failed: advisories.length > 0, advisories };
152
+ }
153
+
154
+ /**
155
+ * Parse `npm audit --json` stdout, returning `null` for anything that is not a
156
+ * real audit report. Never throws: a probe that crashed on its own output would
157
+ * be the second failure mode this module exists to avoid.
158
+ *
159
+ * @param {unknown} stdout
160
+ * @returns {object|null}
161
+ */
162
+ function parseAuditJson(stdout) {
163
+ try {
164
+ const parsed = JSON.parse(String(stdout ?? ''));
165
+ return parsed?.vulnerabilities ? parsed : null;
166
+ } catch (_) {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Render the per-advisory detail lines that follow the verdict.
173
+ *
174
+ * Named lists, not counts: "3 introduced" sends the reader back to the raw
175
+ * audit output, which is the trip attribution exists to save.
176
+ *
177
+ * @param {{ introduced?: Array<object>, preExisting?: Array<object> }} input
178
+ * @returns {string[]}
179
+ */
180
+ export function renderAdvisoryDetail({ introduced = [], preExisting = [] }) {
181
+ const list = (advisories) =>
182
+ advisories.map((a) => `${a.id} (${a.severity})`).join(', ');
183
+ const lines = [];
184
+ if (introduced.length > 0) {
185
+ lines.push(
186
+ `Introduced by this diff (${introduced.length}): ${list(introduced)}.`,
187
+ );
188
+ }
189
+ if (preExisting.length > 0) {
190
+ lines.push(
191
+ `Already on the merge base (${preExisting.length}): ${list(preExisting)}. Those are not this branch's to fix.`,
192
+ );
193
+ }
194
+ return lines;
195
+ }
@@ -16,8 +16,25 @@
16
16
  * the same audit, evaluated against the pull request's merge base.
17
17
  *
18
18
  * It buys legibility, never permission — see `attributionExitCode`.
19
+ *
20
+ * The comparison is **per advisory**, not per exit code. Reading only "did the
21
+ * base audit fail too" collapses the case a busy repository meets most: a base
22
+ * that is already red for advisory A, and a diff that adds advisory B. That
23
+ * answered `pre-existing` — a true statement about A, and a misleading one
24
+ * about the branch, because the author was told their diff was innocent while
25
+ * it was carrying B. Diffing advisory ids at or above `high` reports both
26
+ * facts separately: B introduced, A pre-existing.
19
27
  */
20
28
 
29
+ // The per-advisory machinery lives next door and is re-exported here, so this
30
+ // module stays the one import an attribution consumer needs while the audit
31
+ // runner, the projection and the diff keep their own file.
32
+ export {
33
+ auditAdvisories,
34
+ diffAdvisories,
35
+ renderAdvisoryDetail,
36
+ } from './audit-advisories.js';
37
+
21
38
  // The verdict vocabulary. Only `UNKNOWN` is exported: the CLI branches on it
22
39
  // to decide whether to audit the base at all, while the other two are read by
23
40
  // callers out of the rendered report rather than compared as symbols. Their
@@ -39,6 +56,11 @@ export const UNKNOWN = 'unknown';
39
56
  * attribution mechanism must never guess, because the guess it would make
40
57
  * (`introduced-by-this-diff`) is an accusation against the author reading it.
41
58
  *
59
+ * `baseAudit.failed` is asked **per advisory** by the CLI: it passes
60
+ * `{ failed: <the base already carries every advisory the head has> }`, so a
61
+ * base that is red for its own advisory does not absolve a diff that added a
62
+ * different one.
63
+ *
42
64
  * @param {{ headFailed: boolean, baseAudit: { failed: boolean } | null }} input
43
65
  * @returns {string} one of INTRODUCED / PRE_EXISTING / UNKNOWN
44
66
  */
@@ -26,13 +26,14 @@
26
26
  * label spelling — so a rename still lands in one place.
27
27
  */
28
28
 
29
+ import { auditLabelFooter } from '../findings/route-finding.js';
29
30
  import {
30
31
  AGENT_LABELS,
31
32
  LABEL_COLORS,
32
33
  RISK_LABELS,
33
34
  TYPE_LABELS,
34
35
  } from '../label-constants.js';
35
- import { AUDIT_LENSES } from './audit-lenses.js';
36
+ import { AUDIT_LENSES, auditLabelsForFindings } from './audit-lenses.js';
36
37
 
37
38
  /**
38
39
  * Per-lens label presentation, keyed by canonical lens name. A lens absent from
@@ -183,3 +184,26 @@ const DEFINED_NAMES = new Set(AUDIT_LABEL_TAXONOMY.map((l) => l.name));
183
184
  export function definesAuditLabel(name) {
184
185
  return typeof name === 'string' && DEFINED_NAMES.has(name);
185
186
  }
187
+
188
+ /**
189
+ * Render the `audit-labels` footer for a group of findings.
190
+ *
191
+ * The dedup corpus is listed by `audit::*` label, so a Story filed without one
192
+ * is absent from the pool an indexed run matches against — and with an index in
193
+ * play the exact lookup is answered locally and never reaches the provider, so
194
+ * a fingerprint footer alone cannot rescue it. Carrying the labels through the
195
+ * seed is what lets the chained planning path stamp them without the authoring
196
+ * agent being asked to notice them (Story #5307).
197
+ *
198
+ * Lives here rather than beside {@link auditLabelsForFindings} because it needs
199
+ * {@link definesAuditLabel}, and the taxonomy already depends on the lens list —
200
+ * the reverse edge would be a cycle.
201
+ *
202
+ * @param {Array<object>} findings
203
+ * @returns {string} the footer, or '' when no finding resolves to a label.
204
+ */
205
+ export function auditLabelFooterForFindings(findings) {
206
+ return auditLabelFooter(
207
+ auditLabelsForFindings(findings).filter(definesAuditLabel),
208
+ );
209
+ }
@@ -22,11 +22,26 @@
22
22
  * reworded finding at an unchanged location still dedupes against its Issue
23
23
  * (Story #4626).
24
24
  *
25
- * Pure orchestration: this module performs no network I/O itself.
25
+ * When the caller injects a `listAuditIssues(labels)` port, the whole dedup
26
+ * corpus is pre-fetched **once per run** off the list endpoint and indexed by
27
+ * both provenance footers, so `findIssuesByFingerprint` is answered locally and
28
+ * the rate-limited search API is spent only on findings with no exact hit.
29
+ *
30
+ * A caller that already holds the corpus injects it directly as `issues`
31
+ * instead (Story #5301) — the host fetched it by whatever access path it has,
32
+ * which is what lets dedup run on a host with no `gh` CLI at all. That source
33
+ * needs no provider: with an index in play the exact lookup is answered from
34
+ * memory and `findIssuesByFingerprint` is never called, so the port is required
35
+ * only on the un-indexed path where it is genuinely used.
36
+ *
37
+ * Pure orchestration: this module performs no network I/O itself, and reads no
38
+ * file — the caller hands over an array, never a path.
26
39
  */
27
40
 
28
- import { routeFinding } from '../findings/route-finding.js';
41
+ import { routeFinding, semanticKeyFor } from '../findings/route-finding.js';
29
42
  import { toCanonicalFinding } from './finding-adapter.js';
43
+ import { prepareDedupRouting } from './issue-corpus.js';
44
+ import { lookupLocally } from './issue-index.js';
30
45
 
31
46
  /**
32
47
  * @typedef {object} GroupClassification
@@ -39,6 +54,11 @@ import { toCanonicalFinding } from './finding-adapter.js';
39
54
  /**
40
55
  * Render a short, operator-legible reason from a dedup-lookup failure. Pure —
41
56
  * no imports, no I/O — so the module stays pure orchestration (Story #4678).
57
+ *
58
+ * Both degrade paths run through here, so the wording an operator reads for a
59
+ * failed index pre-fetch matches the wording for a failed per-group lookup:
60
+ * one vocabulary for "the GitHub read did not complete", whichever read it was.
61
+ *
42
62
  * @param {unknown} err
43
63
  * @returns {string}
44
64
  */
@@ -76,7 +96,7 @@ function groupLabel(group) {
76
96
  */
77
97
  async function classifyOneGroup(
78
98
  group,
79
- { searchIssues, semanticPort, routeOptions },
99
+ { searchIssues, semanticPort, routeOptions, index },
80
100
  ) {
81
101
  const findings = group.findings ?? [];
82
102
  const matchedIssues = [];
@@ -91,9 +111,7 @@ async function classifyOneGroup(
91
111
  const canonical = toCanonicalFinding(finding);
92
112
  const { decision, matchedIssue, fingerprint } = await routeFinding(
93
113
  canonical,
94
- semanticPort
95
- ? { searchIssues, searchCandidates: () => semanticPort(canonical) }
96
- : { searchIssues },
114
+ portsFor(canonical, sha, { searchIssues, semanticPort, index }),
97
115
  routeOptions,
98
116
  );
99
117
 
@@ -122,6 +140,33 @@ async function classifyOneGroup(
122
140
  return { action, matchedIssues, matchedFingerprints };
123
141
  }
124
142
 
143
+ /**
144
+ * The read ports one finding is routed through.
145
+ *
146
+ * With no local index this is the historical wiring: the provider answers the
147
+ * exact lookup and the semantic port always runs. With an index, the exact
148
+ * lookup is answered from memory, and the semantic port — the only remaining
149
+ * network call — runs **only** when the index holds no exact fingerprint hit.
150
+ * That is the whole saving: a finding the sweep has already filed costs zero
151
+ * requests, and only a genuinely-unrecognised one is worth a search.
152
+ *
153
+ * @param {object} canonical — the canonical finding projection.
154
+ * @param {string} sha — its full fingerprint.
155
+ * @param {{ searchIssues: Function, semanticPort?: Function, index?: object }} routing
156
+ * @returns {{ searchIssues: Function, searchCandidates?: Function }}
157
+ */
158
+ function portsFor(canonical, sha, { searchIssues, semanticPort, index }) {
159
+ const withSemantic = (ports) =>
160
+ semanticPort
161
+ ? { ...ports, searchCandidates: () => semanticPort(canonical) }
162
+ : ports;
163
+ if (!index) return withSemantic({ searchIssues });
164
+
165
+ const { exact, pool } = lookupLocally(index, sha, semanticKeyFor(canonical));
166
+ const local = { searchIssues: () => pool };
167
+ return exact.length > 0 ? local : withSemantic(local);
168
+ }
169
+
125
170
  /**
126
171
  * @param {object} params
127
172
  * @param {Array<object>} params.groups — output of `groupFindings`.
@@ -130,6 +175,16 @@ async function classifyOneGroup(
130
175
  * Optional meaning-first candidate search (production: `semantic-issue-search.js`).
131
176
  * When supplied, routing runs the Stage-1 semantic pass and opts into
132
177
  * location-based semantic-key confirmation.
178
+ * @param {(labels: string[]) => Promise<Array<object>>} [params.listAuditIssues]
179
+ * Optional list port over the run's `audit::*` labels. When wired, its result
180
+ * is fetched once and indexed, and `provider.findIssuesByFingerprint` is not
181
+ * called at all — the exact lookup is answered from that index.
182
+ * @param {Array<object>} [params.issues]
183
+ * Optional pre-fetched corpus the caller already holds, used in preference to
184
+ * `listAuditIssues`. Supplying it makes `provider` optional: with an index in
185
+ * play no provider read port is ever invoked, which is what lets a host with
186
+ * no `gh` CLI dedup at all (Story #5301). An empty array is a valid corpus —
187
+ * a first sweep — and is NOT read as "no index".
133
188
  * @param {(entry: { group: object, reason: string }) => void} [params.onDegraded]
134
189
  * Optional sink notified once per group whose dedup lookup could not complete
135
190
  * (Story #4678). The group is then classified `create` — a soft-fail, never
@@ -142,36 +197,32 @@ export async function classifyGroupsAgainstGitHub({
142
197
  provider,
143
198
  searchCandidates,
144
199
  onDegraded,
200
+ listAuditIssues,
201
+ issues,
145
202
  }) {
146
203
  if (!Array.isArray(groups)) {
147
204
  throw new Error('classifyGroupsAgainstGitHub: groups must be an array');
148
205
  }
149
- if (!provider || typeof provider.findIssuesByFingerprint !== 'function') {
150
- throw new Error(
151
- 'classifyGroupsAgainstGitHub: provider.findIssuesByFingerprint is required',
152
- );
153
- }
154
206
 
155
- // Adapt the provider port into the `searchIssues` shape routeFinding wants.
156
- // routeFinding hands the port the sha it computed off the canonical
157
- // projection, which equals the sha the group already carries (both come
158
- // from the same `toCanonicalFinding` projection).
159
- const searchIssues = (sha) => provider.findIssuesByFingerprint(sha);
160
- const semanticPort =
161
- typeof searchCandidates === 'function' ? searchCandidates : undefined;
162
- const routing = {
163
- searchIssues,
164
- semanticPort,
165
- routeOptions: { semanticKeyConfirm: Boolean(semanticPort) },
166
- };
207
+ const { routing, summary, error } = await prepareDedupRouting({
208
+ groups,
209
+ provider,
210
+ searchCandidates,
211
+ listAuditIssues,
212
+ issues,
213
+ });
214
+ if (error) {
215
+ // Same vocabulary as a per-group failure, but deliberately NOT counted as
216
+ // a degraded group: the count names groups classified without a check, and
217
+ // every group still gets one here, off the per-finding search path. Until
218
+ // Story #5301 this failure was swallowed whole, so the operator saw only
219
+ // the downstream per-group degradation and could not tell what caused it.
220
+ const reason = `issue-index pre-fetch failed: ${describeDegradeReason(error)}`;
221
+ summary.dedupDegraded.indexPrefetch = reason;
222
+ if (typeof onDegraded === 'function') onDegraded({ group: null, reason });
223
+ }
167
224
 
168
225
  const classifications = [];
169
- const summary = {
170
- create: 0,
171
- skipOpen: 0,
172
- skipReoccurring: 0,
173
- dedupDegraded: { count: 0, groups: [] },
174
- };
175
226
 
176
227
  for (const group of groups) {
177
228
  let result;
@@ -62,10 +62,14 @@ export function fingerprintAuditFinding(finding) {
62
62
  * shared helper. Stable across a reworded title; used to confirm a dedup
63
63
  * match when the fingerprint has drifted (Story #4626).
64
64
  *
65
+ * Module-internal since the ledger moved to the shared findings layer and takes
66
+ * its projection injected: `renderSemanticKeyFooter` below is the only caller,
67
+ * and re-exporting it for none would trip `dead-exports:production`.
68
+ *
65
69
  * @param {object} finding
66
70
  * @returns {string}
67
71
  */
68
- export function semanticKeyForAuditFinding(finding) {
72
+ function semanticKeyForAuditFinding(finding) {
69
73
  return semanticKeyFor(toCanonicalFinding(finding));
70
74
  }
71
75
 
@@ -0,0 +1,162 @@
1
+ /**
2
+ * lib/audit-to-stories/issue-corpus.js — where the dedup corpus comes from,
3
+ * and how a corpus that could not be fetched is described to the operator.
4
+ *
5
+ * Dedup needs exactly one thing from GitHub: the Issues carrying an `audit::*`
6
+ * label. Until Story #5301 the only source was the provider's list port, which
7
+ * spawns `gh`, so a host without a `gh` CLI — a Claude Code cloud sandbox,
8
+ * where `gh` is absent and direct API access is disabled while the GitHub MCP
9
+ * tools work fine — could not dedup at all: every group classified `create`
10
+ * and a scheduled sweep re-filed what it had already filed.
11
+ *
12
+ * Sourcing lives here rather than in `dedupe-against-github.js` so that module
13
+ * stays what its own header claims — pure routing of findings to verdicts —
14
+ * and so the empty-corpus and failed-fetch cases cannot diverge between call
15
+ * sites. Nothing here reaches the network or the filesystem: a caller that
16
+ * already holds the corpus passes the array in.
17
+ */
18
+
19
+ import { auditLabelsForFindings } from './audit-lenses.js';
20
+ import { buildIssueIndex } from './issue-index.js';
21
+
22
+ /**
23
+ * Resolve the dedup corpus into an index, from whichever source is wired.
24
+ *
25
+ * A corpus the caller already holds (`issues`) wins: the host fetched it by
26
+ * whatever GitHub access path it has, which is what lets dedup run where there
27
+ * is no `gh` CLI. Otherwise the run's `audit::*` Issues are pre-fetched off the
28
+ * list port, once.
29
+ *
30
+ * Two results deliberately do NOT collapse to "no index", because a null index
31
+ * silently returns the run to the per-finding search — which on a
32
+ * provider-less host is no dedup at all, the failure this path exists to kill.
33
+ * An **empty** injected corpus is a legitimate first sweep and yields a real
34
+ * zero-row index. A **failed** pre-fetch hands back its `error` so the caller
35
+ * can say so in its own words: an operator who cannot see that the pre-fetch
36
+ * failed cannot tell a checked plan from an unchecked one.
37
+ *
38
+ * Module-internal: every caller reaches it through `prepareDedupRouting`, so
39
+ * the empty-corpus and failed-fetch cases cannot diverge between call sites.
40
+ *
41
+ * @param {{ listAuditIssues?: Function, groups?: Array<object>,
42
+ * issues?: Array<object> }} params
43
+ * @returns {Promise<{ index: object|null, source: 'injected'|'prefetch'|'none',
44
+ * error?: unknown }>}
45
+ */
46
+ async function resolveIssueCorpus({ listAuditIssues, groups, issues }) {
47
+ if (Array.isArray(issues)) {
48
+ return { index: buildIssueIndex(issues), source: 'injected' };
49
+ }
50
+ const labels =
51
+ typeof listAuditIssues === 'function'
52
+ ? auditLabelsForFindings(
53
+ (groups ?? []).flatMap((group) => group?.findings ?? []),
54
+ )
55
+ : [];
56
+ if (labels.length === 0) return { index: null, source: 'none' };
57
+ try {
58
+ return {
59
+ index: buildIssueIndex(await listAuditIssues(labels)),
60
+ source: 'prefetch',
61
+ };
62
+ } catch (err) {
63
+ return { index: null, source: 'none', error: err };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Attach the index description to a seeded summary, when there is one to make.
69
+ *
70
+ * A run with neither an injected corpus nor a list port has no index to
71
+ * describe, and `{ source: 'none', size: 0 }` says nothing the field's absence
72
+ * does not — while inviting the reading "an index was consulted and it was
73
+ * empty", the exact confusion this whole path exists to remove. Omitting it
74
+ * also leaves the summary a pure per-finding-search run emits byte-identical
75
+ * to what it has always been.
76
+ *
77
+ * A failed pre-fetch is the one `none` that IS described: there the run ended
78
+ * *without* an index it expected to have, and the operator needs to see that.
79
+ *
80
+ * @param {object} summary — the seeded counters.
81
+ * @param {{ source: string, index: object|null, error?: unknown }} resolution
82
+ * @returns {object} the same summary, with `dedupIndex` when applicable.
83
+ */
84
+ function withIndexDescription(summary, { source, index, error }) {
85
+ if (source === 'none' && !error) return summary;
86
+ return { ...summary, dedupIndex: { source, size: index?.size ?? 0 } };
87
+ }
88
+
89
+ /**
90
+ * Assemble everything routing needs from the caller's ports and corpus: the
91
+ * read ports, the resolved index, and the two facts the caller must report —
92
+ * what the corpus was and whether fetching it degraded.
93
+ *
94
+ * The provider port is validated here because this is where "is there a usable
95
+ * dedup source at all" is actually known. It is required only on the
96
+ * un-indexed path: once an index exists every exact lookup is answered from
97
+ * memory and `findIssuesByFingerprint` is never called, so demanding it there
98
+ * would be the one thing standing between a `gh`-less host and a real dedup
99
+ * run.
100
+ *
101
+ * @param {{ groups?: Array<object>, provider?: object,
102
+ * searchCandidates?: Function, listAuditIssues?: Function,
103
+ * issues?: Array<object> }} params
104
+ * The seeded `summary` comes back with it: the corpus is the only thing that
105
+ * knows what the index was, and returning the counters beside it keeps the
106
+ * caller from reconstructing a shape it does not own.
107
+ *
108
+ * @returns {Promise<{ routing: object, summary: object, error?: unknown }>}
109
+ * @throws {Error} when neither a provider read port nor a corpus is supplied.
110
+ */
111
+ export async function prepareDedupRouting({
112
+ groups,
113
+ provider,
114
+ searchCandidates,
115
+ listAuditIssues,
116
+ issues,
117
+ }) {
118
+ const hasProviderPort =
119
+ Boolean(provider) && typeof provider.findIssuesByFingerprint === 'function';
120
+ if (!hasProviderPort && !Array.isArray(issues)) {
121
+ throw new Error(
122
+ 'classifyGroupsAgainstGitHub: provider.findIssuesByFingerprint is required ' +
123
+ 'when no `issues` corpus is supplied',
124
+ );
125
+ }
126
+ const { index, source, error } = await resolveIssueCorpus({
127
+ listAuditIssues,
128
+ groups,
129
+ issues,
130
+ });
131
+ const semanticPort =
132
+ typeof searchCandidates === 'function' ? searchCandidates : undefined;
133
+ return {
134
+ routing: {
135
+ // routeFinding hands the port the sha it computed off the canonical
136
+ // projection, which equals the sha the group already carries.
137
+ searchIssues: hasProviderPort
138
+ ? (sha) => provider.findIssuesByFingerprint(sha)
139
+ : undefined,
140
+ semanticPort,
141
+ // An index carries the semantic-key map, so location-based confirmation
142
+ // costs nothing once one exists. Without this, confirmation would discard
143
+ // the `bySemanticKey` half of the pool the local lookup just built, and a
144
+ // provider-less run would be fingerprint-exact only — strictly weaker
145
+ // than the path it replaces.
146
+ routeOptions: {
147
+ semanticKeyConfirm: Boolean(semanticPort) || Boolean(index),
148
+ },
149
+ index,
150
+ },
151
+ summary: withIndexDescription(
152
+ {
153
+ create: 0,
154
+ skipOpen: 0,
155
+ skipReoccurring: 0,
156
+ dedupDegraded: { count: 0, groups: [] },
157
+ },
158
+ { source, index, error },
159
+ ),
160
+ error,
161
+ };
162
+ }