mandrel 2.55.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 (37) hide show
  1. package/.agents/docs/agentrc-reference.json +4 -0
  2. package/.agents/docs/configuration.md +3 -0
  3. package/.agents/rules/ci-remediation.md +39 -21
  4. package/.agents/schemas/agentrc.schema.json +19 -0
  5. package/.agents/scripts/audit-to-stories.js +222 -75
  6. package/.agents/scripts/file-ci-gap.js +306 -0
  7. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  8. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
  9. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  10. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  11. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  12. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
  13. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  14. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  15. package/.agents/scripts/lib/config-settings-schema.js +33 -0
  16. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  17. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  18. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  19. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  20. package/.agents/scripts/lib/findings/route-finding.js +38 -0
  21. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  22. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  23. package/.agents/scripts/lib/label-constants.js +6 -1
  24. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  25. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  26. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  27. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  28. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
  29. package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
  30. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  31. package/.agents/scripts/pr-watch-with-update.js +3 -2
  32. package/.agents/workflows/audit-to-stories.md +63 -27
  33. package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
  34. package/.agents/workflows/helpers/plan-reference.md +23 -0
  35. package/.agents/workflows/mandrel-plan.md +6 -6
  36. package/docs/CHANGELOG.md +10 -0
  37. package/package.json +1 -1
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * file-ci-gap.js — the CI-remediation Option-2 filing command.
4
+ *
5
+ * `rules/ci-remediation.md` sends three verdicts here — `pre-existing`,
6
+ * `capacity`, `unreproducible-tier` — each meaning "this red check is real,
7
+ * and fixing it is not this delivery's job". The rule used to say "file a
8
+ * `meta::framework-gap` issue" and stop, leaving the agent to hand-run
9
+ * `gh issue create` wherever it was standing. This is the mechanism behind
10
+ * that sentence: evidence from the CI digest, ownership routing, fingerprint
11
+ * dedup, the `friction` comment, and the `agent::blocked` flip in one call.
12
+ *
13
+ * It files an **intake** issue, never a Story: `/mandrel-plan <id>` graduates
14
+ * it on the next planning pass. Delivery never blocks on planning — see
15
+ * `lib/orchestration/ci-gap-intake.js` for why that split is load-bearing.
16
+ */
17
+
18
+ import { parseArgs } from 'node:util';
19
+
20
+ import { runAsCli } from './lib/cli-utils.js';
21
+ import { resolveConfig } from './lib/config-resolver.js';
22
+ import {
23
+ createFollowUpIssue,
24
+ ensureIssueLabels,
25
+ updateFollowUpIssue,
26
+ } from './lib/feedback-loop/graduator-core.js';
27
+ import { issueNumberFromUrl } from './lib/feedback-loop/retro-proposals-graduator.js';
28
+ import {
29
+ resolveOwnershipRepos,
30
+ routeOwnership,
31
+ } from './lib/github/framework-repo.js';
32
+ import { Logger } from './lib/Logger.js';
33
+ import {
34
+ fileCiGapIntake,
35
+ INTAKE_VERDICTS,
36
+ REFUSED_VERDICT,
37
+ } from './lib/orchestration/ci-gap-intake.js';
38
+ import { readCiDigest } from './lib/orchestration/ci-rerun-guard.js';
39
+ import {
40
+ STATE_LABELS,
41
+ transitionTicketState,
42
+ upsertStructuredComment,
43
+ } from './lib/orchestration/ticketing.js';
44
+ import { createProvider } from './lib/provider-factory.js';
45
+
46
+ const USAGE = {
47
+ invocation:
48
+ 'node .agents/scripts/file-ci-gap.js --story <id> --verdict <verdict> --owner <bucket> [--evidence "<proof reading>"] [--pr <n>] [--block] [--dry-run]',
49
+ summary:
50
+ 'File (or update) the CI-gap intake issue for an Option-2 verdict in .agents/rules/ci-remediation.md, routed to the repository that owns the fault and deduped by failure signature.',
51
+ flags: [
52
+ [
53
+ '--story <id>',
54
+ 'Story the red check blocked (required — keys the CI digest).',
55
+ ],
56
+ [
57
+ '--verdict <verdict>',
58
+ `One of ${INTAKE_VERDICTS.join(' | ')}. "${REFUSED_VERDICT}" is refused: it routes to Option 1, fix at source.`,
59
+ ],
60
+ [
61
+ '--owner <bucket>',
62
+ 'Who owns the fault: consumer | framework | platform. Resolves through github.followUpRepos.*.',
63
+ ],
64
+ [
65
+ '--evidence <text>',
66
+ "The verdict's proof reading (the exhausted-limit log line, the failed attach). Recorded in the body.",
67
+ ],
68
+ ['--pr <n>', 'PR number the red check ran on; recorded as an occurrence.'],
69
+ [
70
+ '--block',
71
+ 'Also flip the Story to agent::blocked. Without it the friction comment is posted but the Story is left where it is.',
72
+ ],
73
+ ['--dry-run', 'Compose the filing and print it; write nothing.'],
74
+ ],
75
+ notes: [
76
+ 'Requires a CI digest at temp/story-<id>-ci-digest.json — pr-watch-with-update.js --story <id> writes it on the first red.',
77
+ 'A repeat occurrence of a known signature UPDATES the existing intake issue rather than opening a second one.',
78
+ 'The issue it files is intake, not an executable Story: graduate it with /mandrel-plan <issue number>.',
79
+ ],
80
+ };
81
+
82
+ /**
83
+ * Wire the live GitHub ports the intake filer writes through.
84
+ *
85
+ * `gh` is the transport rather than the provider facade because a filing can
86
+ * target a repository other than the configured one, and `gh issue create
87
+ * --repo` is the surface that already does that (the graduators file
88
+ * cross-repo the same way).
89
+ *
90
+ * @param {object} opts
91
+ * @returns {object} ports for `fileCiGapIntake`
92
+ */
93
+ function liveIntakePorts({ provider, searchRepo, cwd, logger }) {
94
+ const labelCache = new Map();
95
+ return {
96
+ searchIssues: (query) =>
97
+ provider.searchIssues({
98
+ query,
99
+ owner: searchRepo.owner,
100
+ repo: searchRepo.repo,
101
+ }),
102
+ createIssue: async ({ owner, repo, title, body, labels }) => {
103
+ // `gh issue create --label <absent>` fails outright, so a brand-new
104
+ // routing label (meta::platform-gap, friction::unreproducible-tier)
105
+ // has to exist before the create — not after it errors.
106
+ const ensured = await ensureIssueLabels({
107
+ owner,
108
+ repo,
109
+ labels,
110
+ labelCache,
111
+ cwd,
112
+ });
113
+ for (const err of ensured.errors) logger?.warn?.(`[file-ci-gap] ${err}`);
114
+ const created = await createFollowUpIssue({
115
+ owner,
116
+ repo,
117
+ title,
118
+ body,
119
+ labels,
120
+ ghPath: 'gh',
121
+ cwd,
122
+ });
123
+ return {
124
+ url: created.url,
125
+ number: created.url ? issueNumberFromUrl(created.url) : null,
126
+ error: created.error,
127
+ };
128
+ },
129
+ updateIssue: ({ owner, repo, number, body }) =>
130
+ updateFollowUpIssue({ owner, repo, number, body, ghPath: 'gh', cwd }),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Render the `friction` comment the Story carries so the blocker is legible
136
+ * on the ticket itself, not only in the intake issue.
137
+ *
138
+ * @param {object} opts
139
+ * @returns {string}
140
+ */
141
+ export function renderFrictionComment({ verdict, result, digest }) {
142
+ const target = result.issue?.url ?? result.issue?.number ?? '(not filed)';
143
+ const lines = [
144
+ `### CI gap filed — verdict \`${verdict}\``,
145
+ '',
146
+ `- **Failing check:** \`${digest?.failingCheck ?? 'unknown'}\``,
147
+ `- **Run:** ${digest?.runUrl ?? `run id ${digest?.runId ?? 'unresolved'}`}`,
148
+ `- **Intake issue:** ${target} (${result.decision})`,
149
+ `- **Owner:** \`${result.routing.bucket}\` → \`${result.routing.routedRepo.owner}/${result.routing.routedRepo.repo}\``,
150
+ ];
151
+ if (!result.routing.routable) {
152
+ lines.push(
153
+ `- **Routing:** \`unroutable\` — \`${result.routing.missingKey}\` is unset, so the intake issue was filed locally.`,
154
+ );
155
+ }
156
+ if (result.routing.deferredFrom) {
157
+ lines.push(
158
+ `- **Routing:** deferred from \`${result.routing.deferredFrom}\` (${result.routing.deferralReason}) — filed locally instead.`,
159
+ );
160
+ }
161
+ lines.push(
162
+ '',
163
+ 'This verdict does **not** license a re-run of the failed job. Graduate the',
164
+ 'intake issue with `/mandrel-plan <issue number>` to turn it into a Story.',
165
+ );
166
+ return lines.join('\n');
167
+ }
168
+
169
+ /**
170
+ * File the CI-gap intake issue for one Story, post the `friction` comment,
171
+ * and optionally flip the Story to `agent::blocked`.
172
+ *
173
+ * Every port is injectable so the unit tests exercise the whole command with
174
+ * no network and no live tracker.
175
+ *
176
+ * @param {object} opts
177
+ * @returns {Promise<object>} the intake result, plus what the command did.
178
+ */
179
+ export async function runFileCiGap({
180
+ storyId,
181
+ verdict,
182
+ owner: bucket,
183
+ evidence = '',
184
+ prNumber = null,
185
+ dryRun = false,
186
+ block = false,
187
+ config,
188
+ provider,
189
+ ports,
190
+ digest,
191
+ tempRoot,
192
+ cwd = process.cwd(),
193
+ logger = Logger,
194
+ now,
195
+ } = {}) {
196
+ const sid = Number(storyId);
197
+ if (!Number.isInteger(sid) || sid <= 0) {
198
+ throw new Error('--story <id> is required (a positive issue number).');
199
+ }
200
+ const resolved = config ?? resolveConfig();
201
+ const ciDigest = digest ?? readCiDigest({ storyId: sid, tempRoot, cwd });
202
+ if (!ciDigest) {
203
+ throw new Error(
204
+ `no CI digest for Story #${sid}. The digest is written by \`pr-watch-with-update.js --story ${sid}\` on the first red; without it there is no run link or failure signature to file.`,
205
+ );
206
+ }
207
+
208
+ const repos = resolveOwnershipRepos(resolved);
209
+ const currentRepo = repos.consumer ?? { owner: 'unknown', repo: 'unknown' };
210
+ // Dedup searches the repository the filing will land in — routing is a pure
211
+ // function, so computing it here and inside the filer cannot disagree.
212
+ const routed = routeOwnership({ bucket, repos, currentRepo });
213
+ const searchRepo = routed.routable ? routed.routedRepo : currentRepo;
214
+
215
+ const ticketing =
216
+ provider ?? (dryRun ? null : (createProvider(resolved) ?? null));
217
+ const livePorts =
218
+ ports ??
219
+ liveIntakePorts({
220
+ provider: ticketing ?? createProvider(resolved),
221
+ searchRepo,
222
+ cwd,
223
+ logger,
224
+ });
225
+
226
+ const result = await fileCiGapIntake({
227
+ digest: ciDigest,
228
+ verdict,
229
+ bucket,
230
+ evidence,
231
+ repos,
232
+ currentRepo,
233
+ prNumber,
234
+ dryRun,
235
+ ports: livePorts,
236
+ logger,
237
+ now,
238
+ });
239
+
240
+ const actions = { commented: false, blocked: false };
241
+ if (!dryRun && ticketing) {
242
+ const body = renderFrictionComment({ verdict, result, digest: ciDigest });
243
+ try {
244
+ await upsertStructuredComment(ticketing, sid, 'friction', body);
245
+ actions.commented = true;
246
+ } catch (err) {
247
+ result.errors.push(`friction comment failed: ${err?.message ?? err}`);
248
+ }
249
+ if (block) {
250
+ try {
251
+ await transitionTicketState(ticketing, sid, STATE_LABELS.BLOCKED, {});
252
+ actions.blocked = true;
253
+ } catch (err) {
254
+ result.errors.push(
255
+ `agent::blocked transition failed: ${err?.message ?? err}`,
256
+ );
257
+ }
258
+ }
259
+ }
260
+
261
+ return { storyId: sid, verdict, ...result, actions };
262
+ }
263
+
264
+ /**
265
+ * CLI entrypoint.
266
+ *
267
+ * @returns {Promise<void>}
268
+ */
269
+ async function main() {
270
+ const { values } = parseArgs({
271
+ args: process.argv.slice(2),
272
+ options: {
273
+ story: { type: 'string' },
274
+ verdict: { type: 'string' },
275
+ owner: { type: 'string' },
276
+ evidence: { type: 'string' },
277
+ pr: { type: 'string' },
278
+ block: { type: 'boolean', default: false },
279
+ 'dry-run': { type: 'boolean', default: false },
280
+ },
281
+ });
282
+
283
+ const result = await runFileCiGap({
284
+ storyId: values.story,
285
+ verdict: values.verdict,
286
+ owner: values.owner,
287
+ evidence: values.evidence ?? '',
288
+ prNumber: values.pr ? Number(values.pr) : null,
289
+ dryRun: values['dry-run'],
290
+ block: values.block,
291
+ });
292
+
293
+ // Single-line JSON per the script-output contract — an orchestrator parses
294
+ // this, and a pretty dump is noise in a delivery transcript.
295
+ process.stdout.write(`${JSON.stringify(result)}\n`);
296
+ for (const err of result.errors) {
297
+ Logger.error(`[file-ci-gap] ${err}`);
298
+ }
299
+ if (result.errors.length > 0) process.exitCode = 1;
300
+ }
301
+
302
+ runAsCli(import.meta.url, main, {
303
+ source: 'file-ci-gap',
304
+ errorPrefix: '[file-ci-gap]',
305
+ usage: USAGE,
306
+ });
@@ -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
+ }
@@ -27,13 +27,21 @@
27
27
  * both provenance footers, so `findIssuesByFingerprint` is answered locally and
28
28
  * the rate-limited search API is spent only on findings with no exact hit.
29
29
  *
30
- * Pure orchestration: this module performs no network I/O itself.
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.
31
39
  */
32
40
 
33
41
  import { routeFinding, semanticKeyFor } from '../findings/route-finding.js';
34
- import { auditLabelsForFindings } from './audit-lenses.js';
35
42
  import { toCanonicalFinding } from './finding-adapter.js';
36
- import { buildIssueIndex, lookupLocally } from './issue-index.js';
43
+ import { prepareDedupRouting } from './issue-corpus.js';
44
+ import { lookupLocally } from './issue-index.js';
37
45
 
38
46
  /**
39
47
  * @typedef {object} GroupClassification
@@ -46,6 +54,11 @@ import { buildIssueIndex, lookupLocally } from './issue-index.js';
46
54
  /**
47
55
  * Render a short, operator-legible reason from a dedup-lookup failure. Pure —
48
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
+ *
49
62
  * @param {unknown} err
50
63
  * @returns {string}
51
64
  */
@@ -154,31 +167,6 @@ function portsFor(canonical, sha, { searchIssues, semanticPort, index }) {
154
167
  return exact.length > 0 ? local : withSemantic(local);
155
168
  }
156
169
 
157
- /**
158
- * Pre-fetch and index every Issue carrying one of the run's `audit::*` labels.
159
- *
160
- * Returns `null` — the un-indexed, per-finding-search path — when no list port
161
- * is wired, when the run's findings resolve to no canonical lens label, or when
162
- * the list itself fails. A degraded pre-fetch must cost the run its saving, not
163
- * its dedup.
164
- *
165
- * @param {{ listAuditIssues?: Function, groups: Array<object>,
166
- * onDegraded?: Function }} params
167
- * @returns {Promise<object|null>}
168
- */
169
- async function prefetchIssueIndex({ listAuditIssues, groups }) {
170
- if (typeof listAuditIssues !== 'function') return null;
171
- const labels = auditLabelsForFindings(
172
- groups.flatMap((group) => group?.findings ?? []),
173
- );
174
- if (labels.length === 0) return null;
175
- try {
176
- return buildIssueIndex(await listAuditIssues(labels));
177
- } catch (_) {
178
- return null;
179
- }
180
- }
181
-
182
170
  /**
183
171
  * @param {object} params
184
172
  * @param {Array<object>} params.groups — output of `groupFindings`.
@@ -191,6 +179,12 @@ async function prefetchIssueIndex({ listAuditIssues, groups }) {
191
179
  * Optional list port over the run's `audit::*` labels. When wired, its result
192
180
  * is fetched once and indexed, and `provider.findIssuesByFingerprint` is not
193
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".
194
188
  * @param {(entry: { group: object, reason: string }) => void} [params.onDegraded]
195
189
  * Optional sink notified once per group whose dedup lookup could not complete
196
190
  * (Story #4678). The group is then classified `create` — a soft-fail, never
@@ -204,37 +198,31 @@ export async function classifyGroupsAgainstGitHub({
204
198
  searchCandidates,
205
199
  onDegraded,
206
200
  listAuditIssues,
201
+ issues,
207
202
  }) {
208
203
  if (!Array.isArray(groups)) {
209
204
  throw new Error('classifyGroupsAgainstGitHub: groups must be an array');
210
205
  }
211
- if (!provider || typeof provider.findIssuesByFingerprint !== 'function') {
212
- throw new Error(
213
- 'classifyGroupsAgainstGitHub: provider.findIssuesByFingerprint is required',
214
- );
215
- }
216
206
 
217
- // Adapt the provider port into the `searchIssues` shape routeFinding wants.
218
- // routeFinding hands the port the sha it computed off the canonical
219
- // projection, which equals the sha the group already carries (both come
220
- // from the same `toCanonicalFinding` projection).
221
- const searchIssues = (sha) => provider.findIssuesByFingerprint(sha);
222
- const semanticPort =
223
- typeof searchCandidates === 'function' ? searchCandidates : undefined;
224
- const routing = {
225
- searchIssues,
226
- semanticPort,
227
- routeOptions: { semanticKeyConfirm: Boolean(semanticPort) },
228
- index: await prefetchIssueIndex({ listAuditIssues, groups }),
229
- };
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
+ }
230
224
 
231
225
  const classifications = [];
232
- const summary = {
233
- create: 0,
234
- skipOpen: 0,
235
- skipReoccurring: 0,
236
- dedupDegraded: { count: 0, groups: [] },
237
- };
238
226
 
239
227
  for (const group of groups) {
240
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
+ }