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.
- package/.agents/docs/agentrc-reference.json +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +19 -0
- package/.agents/scripts/audit-to-stories.js +222 -75
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/config-settings-schema.js +33 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +38 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
- package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
- package/.agents/workflows/helpers/plan-reference.md +23 -0
- package/.agents/workflows/mandrel-plan.md +6 -6
- package/docs/CHANGELOG.md +10 -0
- 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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
+
}
|