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.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +5 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +34 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +374 -76
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -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 +80 -29
- 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/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +52 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- 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 +42 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- 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 +2 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- 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/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +30 -0
- package/.agents/workflows/mandrel-plan.md +10 -13
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +37 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- 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
|
-
*
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
+
}
|