mandrel 2.53.0 → 2.55.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 +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -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 +158 -7
- 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/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/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- 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 +19 -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/findings/route-finding.js +4 -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/observability/source-classifier.js +1 -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/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 +241 -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/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 +24 -1
- 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 +119 -6
- 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 +59 -38
- 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/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
- 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/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/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 +40 -16
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* current workflow set.
|
|
23
23
|
* --check — exits 0 when the on-disk file matches the freshly generated
|
|
24
24
|
* content, throws (→ exit 1) with a regeneration hint otherwise.
|
|
25
|
+
* --root — read and write under another checkout's `.agents/` tree.
|
|
26
|
+
* See {@link resolveTargets} for why this seam exists.
|
|
25
27
|
*
|
|
26
28
|
* Per `.agents/rules/orchestration-error-handling.md`, unrecoverable failures
|
|
27
29
|
* surface via `throw new Error(...)` so `runAsCli` maps the throw to
|
|
@@ -39,8 +41,43 @@ import { buildCatalog, buildLoopCatalog } from './lib/mandrel-catalog.js';
|
|
|
39
41
|
const __filename = fileURLToPath(import.meta.url);
|
|
40
42
|
const __dirname = path.dirname(__filename);
|
|
41
43
|
const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the workflow source directory and the generated doc for one
|
|
47
|
+
* repository root.
|
|
48
|
+
*
|
|
49
|
+
* The `--root` seam this backs exists for the drift gate's own test. That
|
|
50
|
+
* test used to prove the gate by editing the *real*
|
|
51
|
+
* `.agents/workflows/mandrel-deliver.md`, running `--check`, and restoring
|
|
52
|
+
* the file in `afterEach`. The proof was sound; the blast radius was not.
|
|
53
|
+
* `node --test` runs test files in parallel against one shared checkout, so
|
|
54
|
+
* for the ~1s the real file sat mutated, every other test file observed a
|
|
55
|
+
* dirty tree — and `tests/enforcement/workflow-script-help.test.js`, whose
|
|
56
|
+
* final assertion is a repo-wide `git status --porcelain`, reported it as
|
|
57
|
+
* "`--help` mutated the working tree" on the Windows Smoke job, where the
|
|
58
|
+
* wider process-spawn cost stretches that window far enough to collide.
|
|
59
|
+
*
|
|
60
|
+
* A generator that can only ever be pointed at its own checkout forces that
|
|
61
|
+
* choice. Pointing it at a fixture root removes it.
|
|
62
|
+
*
|
|
63
|
+
* The `--root` default is applied here rather than at the call site so the
|
|
64
|
+
* one branch it costs lives with the resolution it belongs to.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} [root] Repository root to render against; defaults to this
|
|
67
|
+
* checkout. A relative path is resolved against the process cwd.
|
|
68
|
+
* @returns {{ root: string, workflowsDir: string, docPath: string }}
|
|
69
|
+
*/
|
|
70
|
+
export function resolveTargets(root) {
|
|
71
|
+
const resolved = root ? path.resolve(root) : PROJECT_ROOT;
|
|
72
|
+
return {
|
|
73
|
+
root: resolved,
|
|
74
|
+
workflowsDir: path.join(resolved, '.agents', 'workflows'),
|
|
75
|
+
docPath: path.join(resolved, '.agents', 'docs', 'workflows.md'),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** This checkout's own targets — the default when `--root` is absent. */
|
|
80
|
+
const { workflowsDir: WORKFLOWS_DIR, docPath: DOC_PATH } = resolveTargets();
|
|
44
81
|
|
|
45
82
|
/**
|
|
46
83
|
* Collapse a catalog description to a single Markdown table-cell-safe line.
|
|
@@ -149,16 +186,24 @@ export function renderWorkflowsDoc(catalog, loopCatalog = []) {
|
|
|
149
186
|
/**
|
|
150
187
|
* Build the canonical generated content and read the on-disk file (if any).
|
|
151
188
|
*
|
|
152
|
-
* @
|
|
189
|
+
* @param {string} [root] Repository root to render against; see
|
|
190
|
+
* {@link resolveTargets}.
|
|
191
|
+
* @returns {{
|
|
192
|
+
* generated: string,
|
|
193
|
+
* original: string | null,
|
|
194
|
+
* root: string,
|
|
195
|
+
* docPath: string,
|
|
196
|
+
* }}
|
|
153
197
|
*/
|
|
154
|
-
export function buildExpected() {
|
|
155
|
-
const
|
|
156
|
-
const
|
|
198
|
+
export function buildExpected(root) {
|
|
199
|
+
const { root: resolvedRoot, workflowsDir, docPath } = resolveTargets(root);
|
|
200
|
+
const catalog = buildCatalog(workflowsDir);
|
|
201
|
+
const loopCatalog = buildLoopCatalog(workflowsDir);
|
|
157
202
|
const generated = renderWorkflowsDoc(catalog, loopCatalog);
|
|
158
|
-
const original = fs.existsSync(
|
|
159
|
-
? fs.readFileSync(
|
|
203
|
+
const original = fs.existsSync(docPath)
|
|
204
|
+
? fs.readFileSync(docPath, 'utf8')
|
|
160
205
|
: null;
|
|
161
|
-
return { generated, original };
|
|
206
|
+
return { generated, original, root: resolvedRoot, docPath };
|
|
162
207
|
}
|
|
163
208
|
|
|
164
209
|
/**
|
|
@@ -169,12 +214,13 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
169
214
|
args: argv,
|
|
170
215
|
options: {
|
|
171
216
|
check: { type: 'boolean', default: false },
|
|
217
|
+
root: { type: 'string' },
|
|
172
218
|
},
|
|
173
219
|
allowPositionals: false,
|
|
174
220
|
});
|
|
175
221
|
|
|
176
|
-
const { generated, original } = buildExpected();
|
|
177
|
-
const rel = path.relative(
|
|
222
|
+
const { generated, original, root, docPath } = buildExpected(values.root);
|
|
223
|
+
const rel = path.relative(root, docPath).split(path.sep).join('/');
|
|
178
224
|
|
|
179
225
|
if (values.check) {
|
|
180
226
|
if (original === generated) {
|
|
@@ -191,8 +237,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
191
237
|
Logger.info(`generate-workflows-doc: ${rel} already current — no write.`);
|
|
192
238
|
return;
|
|
193
239
|
}
|
|
194
|
-
fs.mkdirSync(path.dirname(
|
|
195
|
-
fs.writeFileSync(
|
|
240
|
+
fs.mkdirSync(path.dirname(docPath), { recursive: true });
|
|
241
|
+
fs.writeFileSync(docPath, generated, 'utf8');
|
|
196
242
|
Logger.info(`generate-workflows-doc: wrote ${rel}.`);
|
|
197
243
|
}
|
|
198
244
|
|
|
@@ -201,7 +247,8 @@ export { DOC_PATH, WORKFLOWS_DIR };
|
|
|
201
247
|
runAsCli(import.meta.url, main, {
|
|
202
248
|
source: 'generate-workflows-doc',
|
|
203
249
|
usage: {
|
|
204
|
-
invocation:
|
|
250
|
+
invocation:
|
|
251
|
+
'node .agents/scripts/generate-workflows-doc.js [--check] [--root <dir>]',
|
|
205
252
|
summary:
|
|
206
253
|
'Regenerate the workflow catalog from .agents/workflows/. Writes only when the generated content differs.',
|
|
207
254
|
flags: [
|
|
@@ -209,6 +256,10 @@ runAsCli(import.meta.url, main, {
|
|
|
209
256
|
'--check',
|
|
210
257
|
'Verify the doc is current and fail if stale; write nothing.',
|
|
211
258
|
],
|
|
259
|
+
[
|
|
260
|
+
'--root <dir>',
|
|
261
|
+
"Render against another checkout's .agents/ tree instead of this one (test seam).",
|
|
262
|
+
],
|
|
212
263
|
],
|
|
213
264
|
},
|
|
214
265
|
});
|
|
@@ -157,6 +157,10 @@ runAsCli(import.meta.url, main, {
|
|
|
157
157
|
'Never consider branches matching the glob (repeatable).',
|
|
158
158
|
],
|
|
159
159
|
['--drop-stashes <ref>', 'Stash ref approved for dropping (repeatable).'],
|
|
160
|
+
[
|
|
161
|
+
'--include-content-merged',
|
|
162
|
+
'Under --yes, also delete remote refs detected only by content-equivalence.',
|
|
163
|
+
],
|
|
160
164
|
['--base <branch>', 'Base branch (default: project.baseBranch).'],
|
|
161
165
|
['--cwd <path>', 'Repository root (default: process cwd).'],
|
|
162
166
|
],
|
|
@@ -98,6 +98,84 @@ export class ITicketingProvider {
|
|
|
98
98
|
// Intentional no-op. Concrete providers that maintain a cache override.
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* List every ticket carrying `labels`, in the **mapped** ticket shape.
|
|
103
|
+
*
|
|
104
|
+
* This is the declared read for a label scan, and the only one callers
|
|
105
|
+
* should reach for. Implementations MUST map every issue the way every
|
|
106
|
+
* other read on this interface does — in particular `id` is the **issue
|
|
107
|
+
* number**, not the backend's internal database id.
|
|
108
|
+
*
|
|
109
|
+
* That single rule is the whole reason the method exists. The raw REST
|
|
110
|
+
* payload names the issue number `number` and the database id `id`, so a
|
|
111
|
+
* consumer handed either shape wrote `number ?? id` and appeared to cope —
|
|
112
|
+
* while silently addressing issues by database id on the mapped shape,
|
|
113
|
+
* because there `id` is already the number and the fallback never fires.
|
|
114
|
+
* A declared shape removes the choice rather than documenting it.
|
|
115
|
+
*
|
|
116
|
+
* `state` selects `open` (default), `closed` or `all`. Implementations MUST
|
|
117
|
+
* honour it: a caller asking for `all` is asking a question — "did this
|
|
118
|
+
* child reopen?" — that an open-only listing answers wrongly rather than
|
|
119
|
+
* partially.
|
|
120
|
+
*
|
|
121
|
+
* @param {{ state?: 'open'|'closed'|'all', labels?: string }} [_opts]
|
|
122
|
+
* @returns {Promise<Array<{
|
|
123
|
+
* id: number,
|
|
124
|
+
* title: string,
|
|
125
|
+
* body: string,
|
|
126
|
+
* labels: string[],
|
|
127
|
+
* assignees: string[],
|
|
128
|
+
* state: string,
|
|
129
|
+
* url?: string|null,
|
|
130
|
+
* }>>}
|
|
131
|
+
*/
|
|
132
|
+
async listTicketsByLabel(_opts = {}) {
|
|
133
|
+
throw new Error('Not implemented: listTicketsByLabel');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Read a parent's native sub-issue children as issue numbers.
|
|
138
|
+
*
|
|
139
|
+
* Takes **both** identifiers because they address different things: the
|
|
140
|
+
* backend's child edge is keyed by the parent's opaque node id, while
|
|
141
|
+
* `number` exists only so a degraded read can name the parent it failed on.
|
|
142
|
+
* Passing the number where the node id belongs is not a type error — it is
|
|
143
|
+
* a successful call about the wrong issue — which is why the parameter
|
|
144
|
+
* order is fixed here rather than left to each call site.
|
|
145
|
+
*
|
|
146
|
+
* Implementations MUST return `[]` rather than throw when the sub-issue
|
|
147
|
+
* feature is unavailable on the backend: absence of the feature is not a
|
|
148
|
+
* failed read, and callers union this with a body checklist that still
|
|
149
|
+
* answers the question.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} _nodeId Opaque node id of the parent.
|
|
152
|
+
* @param {number} _number Parent's issue number, for diagnostics only.
|
|
153
|
+
* @returns {Promise<number[]>}
|
|
154
|
+
*/
|
|
155
|
+
async getNativeSubIssues(_nodeId, _number) {
|
|
156
|
+
throw new Error('Not implemented: getNativeSubIssues');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Resolve a ticket's container parent in **one** call.
|
|
161
|
+
*
|
|
162
|
+
* Exists so a child→parent lookup is a read, not a search. Without it the
|
|
163
|
+
* only way to find a container was to list every candidate parent and read
|
|
164
|
+
* each one's children — O(containers) requests to answer what the backend
|
|
165
|
+
* knows directly.
|
|
166
|
+
*
|
|
167
|
+
* Returns `null` when the ticket has no parent, and `null` rather than
|
|
168
|
+
* throwing when the backend cannot answer. Callers treat a null as "no
|
|
169
|
+
* parent resolved *here*" and may fall back to a body-declared link; an
|
|
170
|
+
* exception would turn a degraded lookup into a failed lifecycle edge.
|
|
171
|
+
*
|
|
172
|
+
* @param {number} _number Issue number whose parent to resolve.
|
|
173
|
+
* @returns {Promise<object|null>} Mapped parent ticket, or null.
|
|
174
|
+
*/
|
|
175
|
+
async getParentIssue(_number) {
|
|
176
|
+
throw new Error('Not implemented: getParentIssue');
|
|
177
|
+
}
|
|
178
|
+
|
|
101
179
|
/**
|
|
102
180
|
* Return the dependency graph edges for a ticket.
|
|
103
181
|
* Parses `blocked by #NNN` patterns from the ticket body.
|
|
@@ -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
|
*/
|
|
@@ -22,11 +22,18 @@
|
|
|
22
22
|
* reworded finding at an unchanged location still dedupes against its Issue
|
|
23
23
|
* (Story #4626).
|
|
24
24
|
*
|
|
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
|
+
*
|
|
25
30
|
* Pure orchestration: this module performs no network I/O itself.
|
|
26
31
|
*/
|
|
27
32
|
|
|
28
|
-
import { routeFinding } from '../findings/route-finding.js';
|
|
33
|
+
import { routeFinding, semanticKeyFor } from '../findings/route-finding.js';
|
|
34
|
+
import { auditLabelsForFindings } from './audit-lenses.js';
|
|
29
35
|
import { toCanonicalFinding } from './finding-adapter.js';
|
|
36
|
+
import { buildIssueIndex, lookupLocally } from './issue-index.js';
|
|
30
37
|
|
|
31
38
|
/**
|
|
32
39
|
* @typedef {object} GroupClassification
|
|
@@ -76,7 +83,7 @@ function groupLabel(group) {
|
|
|
76
83
|
*/
|
|
77
84
|
async function classifyOneGroup(
|
|
78
85
|
group,
|
|
79
|
-
{ searchIssues, semanticPort, routeOptions },
|
|
86
|
+
{ searchIssues, semanticPort, routeOptions, index },
|
|
80
87
|
) {
|
|
81
88
|
const findings = group.findings ?? [];
|
|
82
89
|
const matchedIssues = [];
|
|
@@ -91,9 +98,7 @@ async function classifyOneGroup(
|
|
|
91
98
|
const canonical = toCanonicalFinding(finding);
|
|
92
99
|
const { decision, matchedIssue, fingerprint } = await routeFinding(
|
|
93
100
|
canonical,
|
|
94
|
-
semanticPort
|
|
95
|
-
? { searchIssues, searchCandidates: () => semanticPort(canonical) }
|
|
96
|
-
: { searchIssues },
|
|
101
|
+
portsFor(canonical, sha, { searchIssues, semanticPort, index }),
|
|
97
102
|
routeOptions,
|
|
98
103
|
);
|
|
99
104
|
|
|
@@ -122,6 +127,58 @@ async function classifyOneGroup(
|
|
|
122
127
|
return { action, matchedIssues, matchedFingerprints };
|
|
123
128
|
}
|
|
124
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The read ports one finding is routed through.
|
|
132
|
+
*
|
|
133
|
+
* With no local index this is the historical wiring: the provider answers the
|
|
134
|
+
* exact lookup and the semantic port always runs. With an index, the exact
|
|
135
|
+
* lookup is answered from memory, and the semantic port — the only remaining
|
|
136
|
+
* network call — runs **only** when the index holds no exact fingerprint hit.
|
|
137
|
+
* That is the whole saving: a finding the sweep has already filed costs zero
|
|
138
|
+
* requests, and only a genuinely-unrecognised one is worth a search.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} canonical — the canonical finding projection.
|
|
141
|
+
* @param {string} sha — its full fingerprint.
|
|
142
|
+
* @param {{ searchIssues: Function, semanticPort?: Function, index?: object }} routing
|
|
143
|
+
* @returns {{ searchIssues: Function, searchCandidates?: Function }}
|
|
144
|
+
*/
|
|
145
|
+
function portsFor(canonical, sha, { searchIssues, semanticPort, index }) {
|
|
146
|
+
const withSemantic = (ports) =>
|
|
147
|
+
semanticPort
|
|
148
|
+
? { ...ports, searchCandidates: () => semanticPort(canonical) }
|
|
149
|
+
: ports;
|
|
150
|
+
if (!index) return withSemantic({ searchIssues });
|
|
151
|
+
|
|
152
|
+
const { exact, pool } = lookupLocally(index, sha, semanticKeyFor(canonical));
|
|
153
|
+
const local = { searchIssues: () => pool };
|
|
154
|
+
return exact.length > 0 ? local : withSemantic(local);
|
|
155
|
+
}
|
|
156
|
+
|
|
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
|
+
|
|
125
182
|
/**
|
|
126
183
|
* @param {object} params
|
|
127
184
|
* @param {Array<object>} params.groups — output of `groupFindings`.
|
|
@@ -130,6 +187,10 @@ async function classifyOneGroup(
|
|
|
130
187
|
* Optional meaning-first candidate search (production: `semantic-issue-search.js`).
|
|
131
188
|
* When supplied, routing runs the Stage-1 semantic pass and opts into
|
|
132
189
|
* location-based semantic-key confirmation.
|
|
190
|
+
* @param {(labels: string[]) => Promise<Array<object>>} [params.listAuditIssues]
|
|
191
|
+
* Optional list port over the run's `audit::*` labels. When wired, its result
|
|
192
|
+
* is fetched once and indexed, and `provider.findIssuesByFingerprint` is not
|
|
193
|
+
* called at all — the exact lookup is answered from that index.
|
|
133
194
|
* @param {(entry: { group: object, reason: string }) => void} [params.onDegraded]
|
|
134
195
|
* Optional sink notified once per group whose dedup lookup could not complete
|
|
135
196
|
* (Story #4678). The group is then classified `create` — a soft-fail, never
|
|
@@ -142,6 +203,7 @@ export async function classifyGroupsAgainstGitHub({
|
|
|
142
203
|
provider,
|
|
143
204
|
searchCandidates,
|
|
144
205
|
onDegraded,
|
|
206
|
+
listAuditIssues,
|
|
145
207
|
}) {
|
|
146
208
|
if (!Array.isArray(groups)) {
|
|
147
209
|
throw new Error('classifyGroupsAgainstGitHub: groups must be an array');
|
|
@@ -163,6 +225,7 @@ export async function classifyGroupsAgainstGitHub({
|
|
|
163
225
|
searchIssues,
|
|
164
226
|
semanticPort,
|
|
165
227
|
routeOptions: { semanticKeyConfirm: Boolean(semanticPort) },
|
|
228
|
+
index: await prefetchIssueIndex({ listAuditIssues, groups }),
|
|
166
229
|
};
|
|
167
230
|
|
|
168
231
|
const classifications = [];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/issue-index.js — a local index of the audit Issues a
|
|
3
|
+
* sweep must dedupe against.
|
|
4
|
+
*
|
|
5
|
+
* Dedup used to answer every finding with a **search** round-trip: one
|
|
6
|
+
* `findIssuesByFingerprint(sha)` per finding, plus a meaning-first semantic
|
|
7
|
+
* search on top. GitHub's search endpoint is rate-limited an order of magnitude
|
|
8
|
+
* harder than the list endpoint, so a full-scope sweep — hundreds of findings —
|
|
9
|
+
* spent its whole budget re-discovering the same few dozen Issues, and then
|
|
10
|
+
* degraded the rest of the run to `create`, which is how a sweep opens
|
|
11
|
+
* duplicates of Issues it already filed.
|
|
12
|
+
*
|
|
13
|
+
* The Issues that can possibly match are exactly those carrying an `audit::*`
|
|
14
|
+
* label, and there are tens of them, not hundreds. Listing them **once per run**
|
|
15
|
+
* and indexing their provenance footers answers every exact-fingerprint lookup
|
|
16
|
+
* locally, for free. The search API is then spent only where it is the only
|
|
17
|
+
* thing that can help: a finding with no exact hit, whose fingerprint may have
|
|
18
|
+
* drifted under a rewording.
|
|
19
|
+
*
|
|
20
|
+
* Pure: the caller injects the list port and this module performs no I/O.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
parseFingerprintFooter,
|
|
25
|
+
parseSemanticKeyFooter,
|
|
26
|
+
} from '../findings/route-finding.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Add `record` to the list `map` keys under `key`.
|
|
30
|
+
*
|
|
31
|
+
* @param {Map<string, object[]>} map
|
|
32
|
+
* @param {string} key
|
|
33
|
+
* @param {object} record
|
|
34
|
+
*/
|
|
35
|
+
function push(map, key, record) {
|
|
36
|
+
const bucket = map.get(key);
|
|
37
|
+
if (bucket) bucket.push(record);
|
|
38
|
+
else map.set(key, [record]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Index issues by both provenance footers the audit filers stamp.
|
|
43
|
+
*
|
|
44
|
+
* An issue carrying neither footer is indexed under nothing — it can never
|
|
45
|
+
* confirm a match, exactly as it could not when it came back from a search.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<{ number: number, state: string, body?: string }>} issues
|
|
48
|
+
* @returns {{ byFingerprint: Map<string, object[]>, bySemanticKey: Map<string, object[]>, size: number }}
|
|
49
|
+
*/
|
|
50
|
+
export function buildIssueIndex(issues) {
|
|
51
|
+
const byFingerprint = new Map();
|
|
52
|
+
const bySemanticKey = new Map();
|
|
53
|
+
const records = (issues ?? []).filter(
|
|
54
|
+
(issue) => typeof issue?.number === 'number',
|
|
55
|
+
);
|
|
56
|
+
for (const issue of records) {
|
|
57
|
+
for (const sha of parseFingerprintFooter(issue.body)) {
|
|
58
|
+
push(byFingerprint, sha, issue);
|
|
59
|
+
}
|
|
60
|
+
for (const key of parseSemanticKeyFooter(issue.body)) {
|
|
61
|
+
push(bySemanticKey, key, issue);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { byFingerprint, bySemanticKey, size: records.length };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The union of the two local lookups for one finding, fingerprint hits first.
|
|
69
|
+
*
|
|
70
|
+
* Order matters downstream: `routeFinding` keeps the first record contributed
|
|
71
|
+
* for an issue number, and the record retrieved by exact identity is the one
|
|
72
|
+
* that should survive into confirmation.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ byFingerprint: Map, bySemanticKey: Map }} index
|
|
75
|
+
* @param {string} sha
|
|
76
|
+
* @param {string} semanticKey
|
|
77
|
+
* @returns {{ exact: object[], pool: object[] }}
|
|
78
|
+
*/
|
|
79
|
+
export function lookupLocally(index, sha, semanticKey) {
|
|
80
|
+
const exact = index.byFingerprint.get(sha) ?? [];
|
|
81
|
+
const byKey = semanticKey ? (index.bySemanticKey.get(semanticKey) ?? []) : [];
|
|
82
|
+
return { exact, pool: [...exact, ...byKey] };
|
|
83
|
+
}
|