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.
Files changed (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +40 -16
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. 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
- const WORKFLOWS_DIR = path.join(PROJECT_ROOT, '.agents', 'workflows');
43
- const DOC_PATH = path.join(PROJECT_ROOT, '.agents', 'docs', 'workflows.md');
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
- * @returns {{ generated: string, original: string | null }}
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 catalog = buildCatalog(WORKFLOWS_DIR);
156
- const loopCatalog = buildLoopCatalog(WORKFLOWS_DIR);
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(DOC_PATH)
159
- ? fs.readFileSync(DOC_PATH, 'utf8')
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(PROJECT_ROOT, DOC_PATH).split(path.sep).join('/');
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(DOC_PATH), { recursive: true });
195
- fs.writeFileSync(DOC_PATH, generated, 'utf8');
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: 'node .agents/scripts/generate-workflows-doc.js [--check]',
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
+ }