@heretek-ai/epistemic-swarm 0.7.21 → 0.7.23

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 (104) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli.js +3 -2
  4. package/extensions/pi/index.js +3 -2
  5. package/package.json +5 -1
  6. package/plugins/antigravity/plugin.json +1 -1
  7. package/plugins/factory/skills/factory/SKILL.md +1 -1
  8. package/plugins/opencode/analysis-bridge.js +566 -0
  9. package/plugins/opencode/config-io.js +598 -31
  10. package/plugins/opencode/dep-health.js +1039 -0
  11. package/plugins/opencode/hook-bus.js +434 -0
  12. package/plugins/opencode/index.js +252 -19
  13. package/plugins/opencode/tui.js +230 -17
  14. package/plugins/opencode/weight-baseline.json +16 -0
  15. package/plugins/opencode/weight-signals.js +769 -0
  16. package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
  17. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  29. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  30. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  31. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  32. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  33. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  34. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  35. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  36. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  37. package/runner/mcp_server.py +249 -81
  38. package/runner/refinement.py +5 -0
  39. package/runner/schema_validate.py +63 -1
  40. package/runner/schemas.py +9 -4
  41. package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  63. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  64. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  65. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  66. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  67. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  68. package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
  69. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  70. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  71. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  72. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  73. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  74. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  75. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  76. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  77. package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
  78. package/runner/tests/fixtures/config_validation_cases.json +60 -0
  79. package/runner/tests/test_analysis_bridge.py +356 -0
  80. package/runner/tests/test_config_schema.py +119 -15
  81. package/runner/tests/test_dep_health.py +484 -0
  82. package/runner/tests/test_hardening_contract.py +391 -0
  83. package/runner/tests/test_hook_bus.py +802 -0
  84. package/runner/tests/test_opencode_ux.py +232 -0
  85. package/runner/tests/test_opencode_v2_registrars.py +548 -16
  86. package/runner/tests/test_phase08_waiver_reopens.py +539 -0
  87. package/runner/tests/test_weight_signals.py +361 -0
  88. package/schemas/config.schema.json +4 -3
  89. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  90. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  91. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  92. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  93. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  94. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  95. package/skills/factory/SKILL.md +1 -1
  96. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  97. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  98. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  99. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  100. package/skills/research_cache/hasher.py +33 -8
  101. package/skills/swarm_config/SKILL.md +6 -1
  102. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  103. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  104. package/skills/swarm_config/configure.py +254 -17
@@ -0,0 +1,1039 @@
1
+ /**
2
+ * IUMBTEMS dep-health gate (Phase 03-dep-health-gate): programmer-time
3
+ * outdated/vulnerable-dependency warnings plus a mechanical QA coverage gate.
4
+ *
5
+ * Second hook-bus consumer (after the Phase-02 analysis bridge): runs local
6
+ * `npm outdated --json` + `npm audit --json`, enriches npm/pip packages with
7
+ * a best-effort credential-free OSV.dev lookup, statically scans container
8
+ * base images (compose files + Dockerfiles) and GitHub Actions `uses:` pins —
9
+ * and reports everything as ADVISORY annotations. This module NEVER returns
10
+ * deny: severities never block (pinned by the never-blocks test with a
11
+ * critical-CVE fixture); the QA gate (`depHealthCoverage`) fails only when a
12
+ * check didn't run or findings went unrecorded.
13
+ *
14
+ * Findings shape: `{ tool, eventId, ecosystem, package, installed, wanted,
15
+ * latest, severity, advisory, fixedIn, source }` — every key ALWAYS present
16
+ * (`null` when unknown), reusing the Phase-02 annotation shape `{ tool,
17
+ * eventId, ... }`. Every finding is audit-logged on the bus (one
18
+ * `notification` record per finding via the internal audit sink); the toast
19
+ * summarizes counts only (no finding text, no log spam).
20
+ *
21
+ * Zero runtime dependencies (node builtins only: child_process, fs, path,
22
+ * url, https). Network/OSV failures fail open with a recorded `unavailable`
23
+ * entry — never throws into the host.
24
+ *
25
+ * Phase evidence (cited per programmer brief):
26
+ * - npm surface: package.json + package-lock.json present (Biome 2.5.15
27
+ * devDep from Phase-02)
28
+ * [VERIFIED: sha256:7690b878a654ee4dee2b649b60740bc95d4b746cf786051887153b6f656f2e5e
29
+ * `file:///home/john/Projects/IUMBTEMS/package.json`]
30
+ * [VERIFIED: sha256:1fd0c2b8b0d37d553f1f841cea4e4b60d92cdf873257172a726d482954dddf8f
31
+ * `file:///home/john/Projects/IUMBTEMS/package-lock.json`]
32
+ * - Container surface: config/docker-compose.infra.yml pins
33
+ * `searxng/searxng:latest` and `mendableai/firecrawl:latest` — floating
34
+ * tags, canonical advisory fodder
35
+ * [VERIFIED: sha256:7b62111192e07c73547a125eeab7ff98f2d1305a04149f1ab51c66a41a3e3f95
36
+ * `file:///home/john/Projects/IUMBTEMS/config/docker-compose.infra.yml`:5,16]
37
+ * - Actions surface: 4 workflows, all `uses:` pinned to majors
38
+ * (`checkout@v4`, `setup-python@v5`, `setup-node@v4`,
39
+ * `upload-artifact@v4`) — healthy pattern to assert, not fix
40
+ * [VERIFIED: sha256:c381a646894ce66669432e847f0afd647ea5ff00ac64a01cf91f325f5515e601
41
+ * `file:///home/john/Projects/IUMBTEMS/.github/workflows/plugin-evals.yml`]
42
+ * - Dep-health vocabulary precedent: scout weights CVEs + npm-audit warnings
43
+ * + Dependabot advisories
44
+ * [VERIFIED: sha256:ae16788a0a85caed3983dbc70455eaa05857827c837a5f5999242ef32b0a16a7
45
+ * `file:///home/john/Projects/IUMBTEMS/prompts/agent_oss_scout.md`:30]
46
+ */
47
+
48
+ import { spawn } from 'node:child_process';
49
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
50
+ import https from 'node:https';
51
+ import path from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
53
+ import { SLOW_TIMEOUT_MS } from './hook-bus.js';
54
+
55
+ /** Tool tag carried on every finding and audit record from this consumer. */
56
+ export const DEP_HEALTH_TOOL = 'dep-health';
57
+
58
+ /** Bus events this consumer subscribes (programmer action-time points). */
59
+ export const DEP_HEALTH_EVENTS = Object.freeze(['pre-tool-use', 'pre-commit']);
60
+
61
+ /** Ecosystems covered, in canonical order. */
62
+ export const ECOSYSTEMS = Object.freeze(['npm', 'pip', 'containers', 'actions']);
63
+
64
+ /**
65
+ * OSV enrichment budget: the whole OSV phase must settle inside it. Kept
66
+ * under the slow-tier budget so a hung feed can never brick the caller —
67
+ * an overrun degrades to best-effort skip (pip falls back to a recorded
68
+ * `unavailable` entry; npm keeps its local signals).
69
+ */
70
+ export const OSV_TIMEOUT_MS = Math.min(4000, SLOW_TIMEOUT_MS - 1000);
71
+
72
+ /** Cap on findings per ecosystem (bounds audit + toast fan-out). */
73
+ export const MAX_DEP_FINDINGS = 50;
74
+
75
+ /** Cap on packages sent to OSV (bounds feed fan-out inside the budget). */
76
+ export const MAX_OSV_PACKAGES = 20;
77
+
78
+ /** Marker distinguishing per-finding audit records on `notification`. */
79
+ export const DEP_AUDIT_KIND = 'dep-health-finding';
80
+
81
+ const __filename = fileURLToPath(import.meta.url);
82
+ const __dirname = path.dirname(__filename);
83
+ const PKG_ROOT = path.resolve(__dirname, '../..');
84
+
85
+ function resolveTool(payload) {
86
+ try {
87
+ const v = payload?.tool;
88
+ return typeof v === 'string' && v.trim() ? v : DEP_HEALTH_TOOL;
89
+ } catch {
90
+ return DEP_HEALTH_TOOL;
91
+ }
92
+ }
93
+
94
+ function resolveEventId(payload) {
95
+ try {
96
+ const v = payload?.eventId ?? payload?.event_id ?? null;
97
+ return typeof v === 'string' || typeof v === 'number' ? v : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Normalise one finding. Every key is ALWAYS present (`null` when unknown)
105
+ * so audit consumers can rely on shape — the same guarantee the bus gives
106
+ * `tool`/`eventId` and the bridge gives its seven finding keys.
107
+ */
108
+ export function toDepFinding({
109
+ tool, eventId, ecosystem, package: pkg, installed,
110
+ wanted, latest, severity, advisory, fixedIn, source,
111
+ }) {
112
+ const eco = typeof ecosystem === 'string' && ecosystem ? ecosystem : 'npm';
113
+ return {
114
+ tool: typeof tool === 'string' && tool ? tool : DEP_HEALTH_TOOL,
115
+ eventId: eventId ?? null,
116
+ ecosystem: eco,
117
+ package: typeof pkg === 'string' && pkg ? pkg : null,
118
+ installed: typeof installed === 'string' && installed ? installed : null,
119
+ wanted: typeof wanted === 'string' && wanted ? wanted : null,
120
+ latest: typeof latest === 'string' && latest ? latest : null,
121
+ severity: typeof severity === 'string' && severity ? severity.toLowerCase() : 'info',
122
+ advisory: typeof advisory === 'string' && advisory ? advisory : null,
123
+ fixedIn: typeof fixedIn === 'string' && fixedIn ? fixedIn : null,
124
+ source: typeof source === 'string' && source ? source : 'unknown',
125
+ };
126
+ }
127
+
128
+ /** The eleven always-present finding keys (pinned by the shape test). */
129
+ export const DEP_FINDING_KEYS = Object.freeze([
130
+ 'tool', 'eventId', 'ecosystem', 'package', 'installed', 'wanted',
131
+ 'latest', 'severity', 'advisory', 'fixedIn', 'source',
132
+ ]);
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Parsers (pure; all four ecosystems). Never throw.
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /** Parse package.json text → [{name, spec}] over dependencies+devDependencies. */
139
+ export function parseNpmDeps(packageJsonText) {
140
+ try {
141
+ const pkg = JSON.parse(String(packageJsonText || ''));
142
+ const out = [];
143
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
144
+ const deps = pkg?.[field];
145
+ if (!deps || typeof deps !== 'object') continue;
146
+ for (const [name, spec] of Object.entries(deps)) {
147
+ if (typeof name === 'string' && name && typeof spec === 'string') {
148
+ out.push({ name, spec });
149
+ }
150
+ }
151
+ }
152
+ return out;
153
+ } catch {
154
+ return [];
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Parse pip requirement lines (requirements*.txt plus `dependencies = [...]`
160
+ * fragments of pyproject.toml) → [{name, spec}]. Never throws.
161
+ */
162
+ export function parsePipDeps(texts) {
163
+ const out = [];
164
+ try {
165
+ const list = Array.isArray(texts) ? texts : [texts];
166
+ for (const text of list) {
167
+ for (const raw of String(text || '').split('\n')) {
168
+ const line = raw.trim().replace(/\\#.*$/, '').trim();
169
+ if (!line || line.startsWith('#') || line.startsWith('-') || line.startsWith('[')) continue;
170
+ // Strip env markers and extras: `name[extra]>=1.0; python_version>"3.8"`.
171
+ const noMarker = line.split(';')[0].trim();
172
+ const m = noMarker.match(/^([A-Za-z0-9_.\-]+(?:\[[^\]]*\])?)\s*(.*)$/);
173
+ if (!m) continue;
174
+ const name = m[1].replace(/\[.*\]$/, '');
175
+ const spec = (m[2] || '').trim().replace(/^[",'\s]+|[",'\s,]+$/g, '');
176
+ if (name) out.push({ name, spec });
177
+ }
178
+ }
179
+ } catch {
180
+ /* fall through with what we have */
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /** Extract image refs from compose-file / Dockerfile text → [{image, tag, source}]. */
186
+ export function parseContainerImages(texts, source = 'compose-scan') {
187
+ const out = [];
188
+ const seen = new Set();
189
+ try {
190
+ const list = Array.isArray(texts) ? texts : [texts];
191
+ for (const text of list) {
192
+ const content = String(text || '');
193
+ // Compose `image:` lines.
194
+ for (const m of content.matchAll(/^\s*image\s*:\s*["']?([^\s"'#]+)["']?\s*(?:#.*)?$/gm)) {
195
+ pushImage(out, seen, m[1], source);
196
+ }
197
+ // Dockerfile `FROM` lines (incl. `FROM img AS base`, `--platform=`).
198
+ for (const m of content.matchAll(/^\s*FROM\s+(?:--\S+\s+)*([^\s#]+)/gim)) {
199
+ if (m[1].toLowerCase() === 'scratch') continue;
200
+ pushImage(out, seen, m[1].split(/\s+AS\s+/i)[0], 'dockerfile-scan');
201
+ }
202
+ }
203
+ } catch {
204
+ /* fall through with what we have */
205
+ }
206
+ return out;
207
+ }
208
+
209
+ function pushImage(out, seen, ref, source) {
210
+ try {
211
+ const clean = String(ref || '').trim().replace(/^["']|["']$/g, '');
212
+ if (!clean || seen.has(`${source}:${clean}`)) return;
213
+ seen.add(`${source}:${clean}`);
214
+ // Split `registry/org/name:tag[@digest]`.
215
+ const atDigest = clean.includes('@');
216
+ const withoutDigest = atDigest ? clean.split('@')[0] : clean;
217
+ const lastSlash = withoutDigest.lastIndexOf('/');
218
+ const lastColon = withoutDigest.lastIndexOf(':');
219
+ const tag = lastColon > lastSlash ? withoutDigest.slice(lastColon + 1) : null;
220
+ out.push({ image: withoutDigest, tag, digest: atDigest, source });
221
+ } catch {
222
+ /* one bad ref must not drop the rest */
223
+ }
224
+ }
225
+
226
+ /** An image ref is floating when it has no digest and no immutable tag. */
227
+ export function isFloatingImage(ref) {
228
+ try {
229
+ if (ref?.digest) return false;
230
+ const tag = ref?.tag;
231
+ if (!tag) return true; // bare `name` == implicit `:latest`
232
+ return tag.toLowerCase() === 'latest';
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ /** Parse workflow text `uses:` pins → [{uses, ref, pinned}]. Never throws. */
239
+ export function parseActionPins(texts) {
240
+ const out = [];
241
+ try {
242
+ const list = Array.isArray(texts) ? texts : [texts];
243
+ for (const text of list) {
244
+ for (const m of String(text || '').matchAll(/uses\s*:\s*["']?([^\s"'#]+)["']?/g)) {
245
+ const uses = m[1].trim();
246
+ if (!uses || uses.startsWith('./') || uses.startsWith('docker://')) continue;
247
+ const at = uses.lastIndexOf('@');
248
+ const ref = at >= 0 ? uses.slice(at + 1) : null;
249
+ out.push({ uses, ref, pinned: isPinnedRef(ref) });
250
+ }
251
+ }
252
+ } catch {
253
+ /* fall through with what we have */
254
+ }
255
+ return out;
256
+ }
257
+
258
+ /**
259
+ * A pin counts as healthy when it names an immutable ref: a major/minor
260
+ * version (`v4`, `v5.1`), a full semver, or a 40-hex commit SHA. Branch names
261
+ * (`main`, `master`, `latest`, `head`) and missing refs are unpinned.
262
+ */
263
+ export function isPinnedRef(ref) {
264
+ if (typeof ref !== 'string' || !ref.trim()) return false;
265
+ const r = ref.trim();
266
+ if (/^[0-9a-f]{40}$/i.test(r)) return true;
267
+ if (/^v?\d+(\.\d+){0,2}([\-.+][0-9A-Za-z.\-]+)?$/.test(r)) return true;
268
+ return false;
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Local runners (default: real `npm` spawn; injected mocks in tests).
273
+ // ---------------------------------------------------------------------------
274
+
275
+ function spawnJson(bin, args, opts = {}) {
276
+ const timeoutMs = Number(opts.timeoutMs ?? OSV_TIMEOUT_MS);
277
+ const budget = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : OSV_TIMEOUT_MS;
278
+ return new Promise((resolve) => {
279
+ let done = false;
280
+ const finish = (v) => {
281
+ if (done) return;
282
+ done = true;
283
+ resolve(v);
284
+ };
285
+ let child;
286
+ try {
287
+ child = spawn(bin, args, { cwd: opts.cwd || process.cwd() });
288
+ } catch (err) {
289
+ finish({ status: 'error', message: String(err?.message ?? err) });
290
+ return;
291
+ }
292
+ let stdout = '';
293
+ const timer = setTimeout(() => {
294
+ try { child.kill('SIGKILL'); } catch { /* already exited */ }
295
+ finish({ status: 'timeout' });
296
+ }, budget);
297
+ child.on('error', (err) => {
298
+ clearTimeout(timer);
299
+ finish({ status: err?.code === 'ENOENT' ? 'missing' : 'error', message: String(err?.message ?? err) });
300
+ });
301
+ child.stdout?.on('data', (d) => { stdout += String(d); });
302
+ child.on('close', () => {
303
+ if (done) return;
304
+ clearTimeout(timer);
305
+ finish({ status: 'ok', stdout });
306
+ });
307
+ });
308
+ }
309
+
310
+ /** Default `npm outdated --json` runner. Never rejects. */
311
+ export function defaultRunOutdated(opts = {}) {
312
+ return spawnJson('npm', ['outdated', '--json', '--long=false'], opts).then((r) => {
313
+ if (!r || r.status !== 'ok') return r;
314
+ // `npm outdated` exits nonzero when anything is outdated, but stdout
315
+ // still carries the JSON payload — the raw text is what matters here.
316
+ try {
317
+ return { status: 'ok', json: JSON.parse(String(r.stdout || '{}')) };
318
+ } catch {
319
+ return { status: 'ok', json: {} };
320
+ }
321
+ });
322
+ }
323
+
324
+ /** Default `npm audit --json` runner. Never rejects. */
325
+ export function defaultRunAudit(opts = {}) {
326
+ return spawnJson('npm', ['audit', '--json'], opts).then((r) => {
327
+ if (!r || r.status !== 'ok') return r;
328
+ try {
329
+ return { status: 'ok', json: JSON.parse(String(r.stdout || '{}')) };
330
+ } catch {
331
+ return { status: 'ok', json: {} };
332
+ }
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Default OSV.dev query (`POST https://api.osv.dev/v1/query`, credential-free).
338
+ * Resolves `{ status: 'ok', vulns }`, `{ status: 'timeout' }`, or
339
+ * `{ status: 'error', message }`. Never rejects, never throws.
340
+ */
341
+ export function defaultQueryOsv({ ecosystem, package: pkg, version, timeoutMs }) {
342
+ const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) >= 0
343
+ ? Number(timeoutMs) : OSV_TIMEOUT_MS;
344
+ return new Promise((resolve) => {
345
+ let done = false;
346
+ const finish = (v) => {
347
+ if (done) return;
348
+ done = true;
349
+ resolve(v);
350
+ };
351
+ const timer = setTimeout(() => finish({ status: 'timeout' }), budget);
352
+ let req;
353
+ try {
354
+ const body = JSON.stringify({
355
+ package: { name: pkg, ecosystem },
356
+ ...(version ? { version } : {}),
357
+ });
358
+ req = https.request(
359
+ 'https://api.osv.dev/v1/query',
360
+ {
361
+ method: 'POST',
362
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
363
+ },
364
+ (res) => {
365
+ let data = '';
366
+ res.on('data', (d) => { data += String(d); });
367
+ res.on('end', () => {
368
+ clearTimeout(timer);
369
+ try {
370
+ const parsed = JSON.parse(data || '{}');
371
+ finish({ status: 'ok', vulns: Array.isArray(parsed?.vulns) ? parsed.vulns : [] });
372
+ } catch {
373
+ finish({ status: 'error', message: 'unparseable OSV response' });
374
+ }
375
+ });
376
+ },
377
+ );
378
+ req.on('error', (err) => {
379
+ clearTimeout(timer);
380
+ finish({ status: 'error', message: String(err?.message ?? err) });
381
+ });
382
+ req.on('timeout', () => {
383
+ try { req.destroy(); } catch { /* already closed */ }
384
+ clearTimeout(timer);
385
+ finish({ status: 'timeout' });
386
+ });
387
+ req.setTimeout(budget);
388
+ req.write(body);
389
+ req.end();
390
+ } catch (err) {
391
+ clearTimeout(timer);
392
+ finish({ status: 'error', message: String(err?.message ?? err) });
393
+ }
394
+ });
395
+ }
396
+
397
+ /** Race any runner against a budget. Never rejects. */
398
+ function runBounded(runnerPromise, timeoutMs) {
399
+ return new Promise((resolve) => {
400
+ let done = false;
401
+ const timer = setTimeout(() => {
402
+ if (done) return;
403
+ done = true;
404
+ resolve({ status: 'timeout' });
405
+ }, timeoutMs);
406
+ Promise.resolve(runnerPromise).then(
407
+ (v) => {
408
+ if (done) return;
409
+ done = true;
410
+ clearTimeout(timer);
411
+ resolve(v && typeof v === 'object' ? v : { status: 'error', message: 'malformed runner result' });
412
+ },
413
+ (err) => {
414
+ if (done) return;
415
+ done = true;
416
+ clearTimeout(timer);
417
+ resolve({ status: 'error', message: String(err?.message ?? err) });
418
+ },
419
+ );
420
+ });
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Finding builders (pure over runner payloads). Never throw.
425
+ // ---------------------------------------------------------------------------
426
+
427
+ function outdatedFindings(outdatedJson, tool, eventId) {
428
+ const out = [];
429
+ try {
430
+ const table = outdatedJson && typeof outdatedJson === 'object' ? outdatedJson : {};
431
+ for (const [name, info] of Object.entries(table)) {
432
+ if (!info || typeof info !== 'object') continue;
433
+ out.push(toDepFinding({
434
+ tool, eventId, ecosystem: 'npm', package: name,
435
+ installed: info.current ?? null, wanted: info.wanted ?? null, latest: info.latest ?? null,
436
+ severity: 'info',
437
+ advisory: `outdated: ${info.current ?? '?'} → wanted ${info.wanted ?? '?'} / latest ${info.latest ?? '?'}`,
438
+ fixedIn: info.wanted ?? info.latest ?? null,
439
+ source: 'npm-outdated',
440
+ }));
441
+ if (out.length >= MAX_DEP_FINDINGS) break;
442
+ }
443
+ } catch {
444
+ /* fall through with what we have */
445
+ }
446
+ return out;
447
+ }
448
+
449
+ function auditFindings(auditJson, tool, eventId) {
450
+ const out = [];
451
+ try {
452
+ const advisories = auditJson?.advisories && typeof auditJson.advisories === 'object'
453
+ ? Object.values(auditJson.advisories) : [];
454
+ for (const adv of advisories) {
455
+ if (!adv || typeof adv !== 'object') continue;
456
+ out.push(toDepFinding({
457
+ tool, eventId, ecosystem: 'npm',
458
+ package: adv.module_name ?? adv.name ?? null,
459
+ installed: adv.found_by?.version ?? null,
460
+ wanted: null, latest: adv.patched_versions ?? null,
461
+ severity: adv.severity ?? 'info',
462
+ advisory: adv.title ?? adv.url ?? adv.id ?? 'npm audit advisory',
463
+ fixedIn: adv.patched_versions ?? null,
464
+ source: 'npm-audit',
465
+ }));
466
+ if (out.length >= MAX_DEP_FINDINGS) break;
467
+ }
468
+ // npm v7+ audit format: `vulnerabilities` map.
469
+ const vulns = auditJson?.vulnerabilities && typeof auditJson.vulnerabilities === 'object'
470
+ ? Object.entries(auditJson.vulnerabilities) : [];
471
+ for (const [name, v] of vulns) {
472
+ if (!v || typeof v !== 'object') continue;
473
+ const via = Array.isArray(v.via) ? v.via.find((x) => x && typeof x === 'object') : null;
474
+ out.push(toDepFinding({
475
+ tool, eventId, ecosystem: 'npm', package: name,
476
+ installed: v.range ?? null, wanted: null, latest: v.fixAvailable ?? null,
477
+ severity: v.severity ?? 'info',
478
+ advisory: (via && (via.title || via.url)) || `npm audit: ${name} ${v.severity ?? ''}`.trim(),
479
+ fixedIn: typeof v.fixAvailable === 'string' ? v.fixAvailable
480
+ : (v.fixAvailable && v.fixAvailable.version) || null,
481
+ source: 'npm-audit',
482
+ }));
483
+ if (out.length >= MAX_DEP_FINDINGS) break;
484
+ }
485
+ } catch {
486
+ /* fall through with what we have */
487
+ }
488
+ return out;
489
+ }
490
+
491
+ function osvFindings(vulns, ecosystem, name, installed, tool, eventId) {
492
+ const out = [];
493
+ try {
494
+ for (const v of vulns || []) {
495
+ if (!v || typeof v !== 'object') continue;
496
+ const sev = Array.isArray(v.severity) && v.severity[0]?.score
497
+ ? 'high' : (v.database_specific?.severity ?? 'info');
498
+ let fixedIn = null;
499
+ try {
500
+ for (const aff of v.affected || []) {
501
+ for (const r of aff.ranges || []) {
502
+ const fix = (r.events || []).find((e) => e && e.fixed);
503
+ if (fix?.fixed) { fixedIn = fix.fixed; break; }
504
+ }
505
+ if (fixedIn) break;
506
+ }
507
+ } catch {
508
+ /* fixed lookup is best-effort */
509
+ }
510
+ out.push(toDepFinding({
511
+ tool, eventId, ecosystem, package: name, installed: installed ?? null,
512
+ wanted: null, latest: null,
513
+ severity: typeof sev === 'string' ? sev : 'info',
514
+ advisory: v.summary || v.id || 'OSV advisory',
515
+ fixedIn,
516
+ source: 'osv.dev',
517
+ }));
518
+ if (out.length >= MAX_DEP_FINDINGS) break;
519
+ }
520
+ } catch {
521
+ /* fall through with what we have */
522
+ }
523
+ return out;
524
+ }
525
+
526
+ function unavailableFinding(ecosystem, detail, tool, eventId) {
527
+ return toDepFinding({
528
+ tool, eventId, ecosystem, package: null, installed: null,
529
+ wanted: null, latest: null, severity: 'info',
530
+ advisory: `feed unavailable: ${detail} (recorded; action allowed)`,
531
+ fixedIn: null, source: 'unavailable',
532
+ });
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // Manifest discovery (explicit texts win; disk fallback; never throws).
537
+ // ---------------------------------------------------------------------------
538
+
539
+ function readText(file) {
540
+ try {
541
+ return readFileSync(file, 'utf-8');
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
546
+
547
+ const PIP_CANDIDATES = ['requirements.txt', 'requirements-dev.txt', 'requirements/prod.txt', 'pyproject.toml', 'Pipfile', 'Pipfile.lock'];
548
+ const COMPOSE_CANDIDATES = [
549
+ 'config/docker-compose.infra.yml',
550
+ 'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml',
551
+ 'docker-compose.infra.yml', 'config/docker-compose.yml',
552
+ ];
553
+ const DOCKERFILE_CANDIDATES = ['Dockerfile', 'Dockerfile.dev', 'docker/Dockerfile'];
554
+
555
+ function discoverManifests(root) {
556
+ const found = {
557
+ packageJson: null, pipTexts: [], containerTexts: [], workflowTexts: [],
558
+ pipFiles: [], containerFiles: [], workflowFiles: [],
559
+ };
560
+ try {
561
+ if (!root || typeof root !== 'string') return found;
562
+ const pkg = readText(path.join(root, 'package.json'));
563
+ if (typeof pkg === 'string' && pkg.trim()) found.packageJson = pkg;
564
+ for (const rel of PIP_CANDIDATES) {
565
+ const t = readText(path.join(root, rel));
566
+ if (typeof t === 'string' && t.trim()) {
567
+ found.pipTexts.push(t);
568
+ found.pipFiles.push(rel);
569
+ }
570
+ }
571
+ for (const rel of COMPOSE_CANDIDATES) {
572
+ const t = readText(path.join(root, rel));
573
+ if (typeof t === 'string' && t.trim()) {
574
+ found.containerTexts.push(t);
575
+ found.containerFiles.push(rel);
576
+ }
577
+ }
578
+ for (const rel of DOCKERFILE_CANDIDATES) {
579
+ const t = readText(path.join(root, rel));
580
+ if (typeof t === 'string' && t.trim()) {
581
+ found.containerTexts.push(t);
582
+ found.containerFiles.push(rel);
583
+ }
584
+ }
585
+ try {
586
+ const wfDir = path.join(root, '.github', 'workflows');
587
+ for (const f of readdirSync(wfDir)) {
588
+ if (!/\.(ya?ml)$/i.test(f)) continue;
589
+ const t = readText(path.join(wfDir, f));
590
+ if (typeof t === 'string' && t.trim()) {
591
+ found.workflowTexts.push(t);
592
+ found.workflowFiles.push(`.github/workflows/${f}`);
593
+ }
594
+ }
595
+ } catch {
596
+ /* no workflows dir — actions surface absent */
597
+ }
598
+ } catch {
599
+ /* discovery never throws */
600
+ }
601
+ return found;
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // checkDeps — the scanner. ALWAYS resolves; NEVER returns deny.
606
+ // ---------------------------------------------------------------------------
607
+
608
+ /**
609
+ * Scan all four ecosystems. ALWAYS resolves
610
+ * `{ allowed: true, ecosystems: { npm, pip, containers, actions } }` where
611
+ * each ecosystem is `{ status: 'scanned' | 'unavailable' | 'absent',
612
+ * findings: [...] }`. `allowed` is unconditionally `true` and no `deny`
613
+ * verdict appears anywhere — severities never block. Offline/feed failures
614
+ * degrade to a RECORDED `unavailable` entry, never a throw.
615
+ *
616
+ * Options (all optional): `root` (disk discovery), `manifests`
617
+ * (`{ packageJson, pipTexts, containerTexts, workflowTexts }` — explicit
618
+ * texts win over disk), `runOutdated` / `runAudit` / `queryOsv` (injected
619
+ * runners; defaults hit real `npm` / OSV.dev), `osvTimeoutMs`, `cwd`,
620
+ * `tool`, `eventId` (carried onto every finding).
621
+ */
622
+ export async function checkDeps(opts = {}) {
623
+ try {
624
+ const tool = resolveTool(opts);
625
+ const eventId = resolveEventId(opts);
626
+ const cwd = opts.cwd || opts.root || process.cwd();
627
+ const osvBudget = Number.isFinite(Number(opts.osvTimeoutMs)) && Number(opts.osvTimeoutMs) >= 0
628
+ ? Number(opts.osvTimeoutMs) : OSV_TIMEOUT_MS;
629
+ const runOutdated = typeof opts.runOutdated === 'function' ? opts.runOutdated : defaultRunOutdated;
630
+ const runAudit = typeof opts.runAudit === 'function' ? opts.runAudit : defaultRunAudit;
631
+ const queryOsv = typeof opts.queryOsv === 'function' ? opts.queryOsv : defaultQueryOsv;
632
+
633
+ const discovered = discoverManifests(
634
+ typeof opts.root === 'string' && opts.root ? opts.root : cwd,
635
+ );
636
+ const m = opts.manifests && typeof opts.manifests === 'object' ? opts.manifests : {};
637
+ const packageJson = typeof m.packageJson === 'string' ? m.packageJson : discovered.packageJson;
638
+ const pipTexts = Array.isArray(m.pipTexts) ? m.pipTexts : discovered.pipTexts;
639
+ const containerTexts = Array.isArray(m.containerTexts) ? m.containerTexts : discovered.containerTexts;
640
+ const workflowTexts = Array.isArray(m.workflowTexts) ? m.workflowTexts : discovered.workflowTexts;
641
+
642
+ const ecosystems = {};
643
+
644
+ // --- npm: local signals primary, OSV best-effort enrichment. ---
645
+ if (packageJson) {
646
+ const findings = [];
647
+ let localOk = false;
648
+ try {
649
+ const [outRes, audRes] = await Promise.all([
650
+ runBounded(runOutdated({ cwd, timeoutMs: osvBudget }), osvBudget),
651
+ runBounded(runAudit({ cwd, timeoutMs: osvBudget }), osvBudget),
652
+ ]);
653
+ if (outRes?.status === 'ok') {
654
+ localOk = true;
655
+ findings.push(...outdatedFindings(outRes.json, tool, eventId));
656
+ }
657
+ if (audRes?.status === 'ok') {
658
+ localOk = true;
659
+ findings.push(...auditFindings(audRes.json, tool, eventId));
660
+ }
661
+ } catch {
662
+ /* local-signal faults degrade below */
663
+ }
664
+ if (!localOk) {
665
+ ecosystems.npm = {
666
+ status: 'unavailable',
667
+ findings: [unavailableFinding('npm', 'npm outdated/audit unreachable', tool, eventId)],
668
+ };
669
+ } else {
670
+ // OSV enrichment is best-effort inside the remaining budget: a feed
671
+ // failure here never flips a scanned ecosystem to unavailable.
672
+ try {
673
+ const deps = parseNpmDeps(packageJson).slice(0, MAX_OSV_PACKAGES);
674
+ const enriched = await runBounded(
675
+ (async () => {
676
+ const extra = [];
677
+ for (const d of deps) {
678
+ const r = await queryOsv({
679
+ ecosystem: 'npm', package: d.name,
680
+ version: String(d.spec || '').replace(/^[\^~>=<\s]+/, '').split(' ')[0] || undefined,
681
+ timeoutMs: osvBudget,
682
+ });
683
+ if (r?.status === 'ok') {
684
+ extra.push(...osvFindings(r.vulns, 'npm', d.name, d.spec, tool, eventId));
685
+ }
686
+ if (extra.length >= MAX_DEP_FINDINGS) break;
687
+ }
688
+ return { status: 'ok', extra };
689
+ })(),
690
+ osvBudget,
691
+ );
692
+ if (enriched?.status === 'ok' && Array.isArray(enriched.extra)) {
693
+ findings.push(...enriched.extra);
694
+ }
695
+ } catch {
696
+ /* enrichment is best-effort */
697
+ }
698
+ ecosystems.npm = { status: 'scanned', findings: findings.slice(0, MAX_DEP_FINDINGS) };
699
+ }
700
+ } else {
701
+ ecosystems.npm = { status: 'absent', findings: [] };
702
+ }
703
+
704
+ // --- pip: manifest parse + OSV (no local scanner); feed failure → unavailable. ---
705
+ const pipDeps = parsePipDeps(pipTexts);
706
+ if (pipTexts.length === 0 && pipDeps.length === 0) {
707
+ ecosystems.pip = { status: 'absent', findings: [] };
708
+ } else {
709
+ let scanned = false;
710
+ const findings = [];
711
+ try {
712
+ const res = await runBounded(
713
+ (async () => {
714
+ const extra = [];
715
+ for (const d of pipDeps.slice(0, MAX_OSV_PACKAGES)) {
716
+ const r = await queryOsv({
717
+ ecosystem: 'PyPI', package: d.name,
718
+ version: String(d.spec || '').replace(/^[=<>!~,\s]+/, '').split(/[;, ]/)[0] || undefined,
719
+ timeoutMs: osvBudget,
720
+ });
721
+ if (r?.status === 'ok') {
722
+ extra.push(...osvFindings(r.vulns, 'pip', d.name, d.spec, tool, eventId));
723
+ }
724
+ if (extra.length >= MAX_DEP_FINDINGS) break;
725
+ }
726
+ return { status: 'ok', extra };
727
+ })(),
728
+ osvBudget,
729
+ );
730
+ if (res?.status === 'ok') {
731
+ scanned = true;
732
+ findings.push(...res.extra);
733
+ }
734
+ } catch {
735
+ /* feed faults degrade below */
736
+ }
737
+ ecosystems.pip = scanned
738
+ ? { status: 'scanned', findings: findings.slice(0, MAX_DEP_FINDINGS) }
739
+ : {
740
+ status: 'unavailable',
741
+ findings: [unavailableFinding('pip', 'OSV.dev unreachable for PyPI lookup', tool, eventId)],
742
+ };
743
+ }
744
+
745
+ // --- containers: static scan (always decisive when manifests exist). ---
746
+ if (containerTexts.length === 0) {
747
+ ecosystems.containers = { status: 'absent', findings: [] };
748
+ } else {
749
+ const findings = [];
750
+ for (const ref of parseContainerImages(containerTexts)) {
751
+ if (isFloatingImage(ref)) {
752
+ findings.push(toDepFinding({
753
+ tool, eventId, ecosystem: 'containers', package: ref.image,
754
+ installed: ref.tag ?? '(implicit latest)', wanted: null, latest: null,
755
+ severity: 'info',
756
+ advisory: `floating base image tag \`${ref.tag ?? 'latest'}\`: pin to a digest or immutable tag`,
757
+ fixedIn: null, source: ref.source,
758
+ }));
759
+ }
760
+ if (findings.length >= MAX_DEP_FINDINGS) break;
761
+ }
762
+ ecosystems.containers = { status: 'scanned', findings };
763
+ }
764
+
765
+ // --- actions: static `uses:` pin scan (always decisive when workflows exist). ---
766
+ if (workflowTexts.length === 0) {
767
+ ecosystems.actions = { status: 'absent', findings: [] };
768
+ } else {
769
+ const findings = [];
770
+ for (const pin of parseActionPins(workflowTexts)) {
771
+ if (!pin.pinned) {
772
+ findings.push(toDepFinding({
773
+ tool, eventId, ecosystem: 'actions', package: pin.uses,
774
+ installed: pin.ref, wanted: null, latest: null,
775
+ severity: 'info',
776
+ advisory: `unpinned action ref \`${pin.ref ?? '(none)'}\`: pin to a major version or commit SHA`,
777
+ fixedIn: null, source: 'actions-scan',
778
+ }));
779
+ }
780
+ if (findings.length >= MAX_DEP_FINDINGS) break;
781
+ }
782
+ ecosystems.actions = { status: 'scanned', findings };
783
+ }
784
+
785
+ return { allowed: true, ecosystems };
786
+ } catch {
787
+ // Last resort: fail-open allow with recorded unavailability everywhere.
788
+ // This path is unreachable by construction (every phase is guarded), but
789
+ // the never-throws contract is pinned, so it stays.
790
+ try {
791
+ const tool = DEP_HEALTH_TOOL;
792
+ const ecosystems = {};
793
+ for (const eco of ECOSYSTEMS) {
794
+ ecosystems[eco] = { status: 'unavailable', findings: [unavailableFinding(eco, 'internal fault', tool, null)] };
795
+ }
796
+ return { allowed: true, ecosystems };
797
+ } catch {
798
+ return { allowed: true, ecosystems: {} };
799
+ }
800
+ }
801
+ }
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // depHealthCoverage — the mechanical QA gate. Pure. Never throws.
805
+ // ---------------------------------------------------------------------------
806
+
807
+ /**
808
+ * QA/programmer self-check over per-ecosystem reports. Returns
809
+ * `{ pass, gaps[] }` — fails IFF a present ecosystem lacks a report or
810
+ * findings went unrecorded:
811
+ * - `scanned` + findings array → pass (findings may be empty = clean).
812
+ * - `unavailable` + recorded findings (≥1 entry) → pass (feed outage is a
813
+ * recorded fact, not a gate failure).
814
+ * - `absent` → pass WITH a note in `gaps[]` (no manifests to check).
815
+ * - missing report (key absent / null) → FAIL.
816
+ * - findings unrecorded (no findings array, or explicit `recorded: false`,
817
+ * or `unavailable` with zero entries) → FAIL.
818
+ * - unknown status → FAIL.
819
+ *
820
+ * Severities NEVER fail the gate — only missing/unrecorded checks do.
821
+ */
822
+ export function depHealthCoverage(reports) {
823
+ try {
824
+ const gaps = [];
825
+ const r = reports && typeof reports === 'object' ? reports : {};
826
+ for (const eco of ECOSYSTEMS) {
827
+ const rep = r[eco];
828
+ if (!rep || typeof rep !== 'object') {
829
+ gaps.push({ ecosystem: eco, reason: 'missing report', fail: true });
830
+ continue;
831
+ }
832
+ if (rep.recorded === false) {
833
+ gaps.push({ ecosystem: eco, reason: 'findings unrecorded', fail: true });
834
+ continue;
835
+ }
836
+ const status = rep.status;
837
+ const findings = rep.findings;
838
+ if (status === 'absent') {
839
+ gaps.push({ ecosystem: eco, reason: 'absent — no manifests (pass-with-note)', fail: false });
840
+ } else if (status === 'scanned') {
841
+ if (!Array.isArray(findings)) {
842
+ gaps.push({ ecosystem: eco, reason: 'findings unrecorded', fail: true });
843
+ }
844
+ } else if (status === 'unavailable') {
845
+ if (!Array.isArray(findings) || findings.length === 0) {
846
+ gaps.push({ ecosystem: eco, reason: 'findings unrecorded', fail: true });
847
+ }
848
+ } else {
849
+ gaps.push({ ecosystem: eco, reason: `unknown status \`${String(status)}\``, fail: true });
850
+ }
851
+ }
852
+ const failures = gaps.filter((g) => g.fail);
853
+ return { pass: failures.length === 0, gaps };
854
+ } catch {
855
+ return { pass: false, gaps: [{ ecosystem: '(all)', reason: 'coverage evaluation fault', fail: true }] };
856
+ }
857
+ }
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // Bus consumer: advisory findings → audit log + counts-only toast.
861
+ // ---------------------------------------------------------------------------
862
+
863
+ /** Best-effort toast: counts only, never any finding text. Never throws. */
864
+ export function notifyDepSummary(opts, summary) {
865
+ try {
866
+ const fn = typeof opts?.notify === 'function' ? opts.notify : defaultDepHostNotify(opts?.host);
867
+ if (typeof fn !== 'function') return;
868
+ try {
869
+ const r = fn(summary);
870
+ if (r && typeof r.catch === 'function') r.catch(() => {});
871
+ } catch {
872
+ /* notify is best-effort */
873
+ }
874
+ } catch {
875
+ /* never throws */
876
+ }
877
+ }
878
+
879
+ function defaultDepHostNotify(host) {
880
+ if (!host || typeof host !== 'object') return null;
881
+ try {
882
+ const show = host.ui?.toast?.show;
883
+ if (typeof show === 'function') {
884
+ return (s) => show.call(host.ui.toast, {
885
+ title: 'IUMBTEMS dep-health',
886
+ message: s.message,
887
+ variant: 'info',
888
+ duration: 4000,
889
+ });
890
+ }
891
+ } catch {
892
+ /* fall through */
893
+ }
894
+ try {
895
+ const sink = host.client?.app?.log;
896
+ if (typeof sink === 'function') {
897
+ return (s) => sink.call(host.client.app, {
898
+ body: { service: 'iumbtems', level: 'info', message: `dep-health: ${s.message}` },
899
+ });
900
+ }
901
+ } catch {
902
+ /* fall through */
903
+ }
904
+ return null;
905
+ }
906
+
907
+ /**
908
+ * Build the bus handler. Never throws into the bus and NEVER returns deny:
909
+ * findings → `{ verdict: 'advisory', reason: '<n> finding(s)…' }` (each
910
+ * finding additionally audit-logged via one `notification` record);
911
+ * clean/absent → fail-open `allow` (carries a `reason` so the single audit
912
+ * entry identifies the skip; no toast).
913
+ */
914
+ export function createDepHealthHandler(bus, opts = {}) {
915
+ const runOpts = { ...opts };
916
+ return async function depHealthHandler(payload) {
917
+ try {
918
+ const res = await checkDeps({ ...runOpts, tool: payload?.tool, eventId: payload?.eventId });
919
+ const ecosystems = (res && res.ecosystems) || {};
920
+ const findings = [];
921
+ for (const eco of ECOSYSTEMS) {
922
+ const rep = ecosystems[eco];
923
+ if (rep && Array.isArray(rep.findings)) findings.push(...rep.findings);
924
+ }
925
+ if (findings.length === 0) {
926
+ return { verdict: 'allow', reason: 'dep-health: no findings; skipped' };
927
+ }
928
+ // Every finding audit-logged on the bus (one `notification` record
929
+ // each; the audit sink below turns each into exactly one audit entry
930
+ // carrying tool/eventId). Best-effort and awaited.
931
+ if (bus && typeof bus.emit === 'function') {
932
+ for (const f of findings) {
933
+ try {
934
+ await bus.emit('notification', { ...f, auditKind: DEP_AUDIT_KIND });
935
+ } catch {
936
+ /* one finding's audit record must not drop the rest */
937
+ }
938
+ }
939
+ }
940
+ const ecos = new Set(findings.map((f) => f.ecosystem).filter(Boolean));
941
+ notifyDepSummary(runOpts, {
942
+ title: 'IUMBTEMS dep-health',
943
+ message: `dep-health: ${findings.length} finding(s) across ${ecos.size} ecosystem(s) (advisory; action allowed)`,
944
+ });
945
+ return {
946
+ verdict: 'advisory',
947
+ reason: `dep-health: ${findings.length} finding(s) across ${ecos.size} ecosystem(s) (advisory; action allowed)`,
948
+ };
949
+ } catch {
950
+ return { verdict: 'allow', reason: 'dep-health: internal fault; fail-open allow' };
951
+ }
952
+ };
953
+ }
954
+
955
+ /**
956
+ * Internal audit sink on `notification`: turns each per-finding record into
957
+ * exactly one audit entry (fail-open `allow`, so it never blocks anything).
958
+ * Finding detail (ecosystem / package / severity, length-capped) rides in
959
+ * the entry's `reason` — `tool`/`eventId` travel in the entry's dedicated
960
+ * keys. Non-dep-health notifications pass through as plain `allow`.
961
+ */
962
+ export function depHealthAuditSink(payload) {
963
+ try {
964
+ if (payload && payload.auditKind === DEP_AUDIT_KIND) {
965
+ const eco = typeof payload.ecosystem === 'string' && payload.ecosystem ? payload.ecosystem : '?';
966
+ const pkg = typeof payload.package === 'string' && payload.package ? payload.package : '?';
967
+ const severity = typeof payload.severity === 'string' && payload.severity ? payload.severity : 'info';
968
+ let advisory = typeof payload.advisory === 'string' ? payload.advisory : '';
969
+ if (advisory.length > 300) advisory = `${advisory.slice(0, 297)}...`;
970
+ return { verdict: 'allow', reason: `dep-health-finding ${eco} ${pkg} [${severity}] ${advisory}` };
971
+ }
972
+ } catch {
973
+ /* fall through to plain allow */
974
+ }
975
+ return 'allow';
976
+ }
977
+
978
+ /**
979
+ * Adopt the dep-health consumer alongside the existing registrations (same
980
+ * adopt/best-effort pattern as `setup` in `index.js`). Subscribes the
981
+ * advisory handler on the action-time events plus the audit sink on
982
+ * `notification`; attaches the registration to the host for debugging
983
+ * (`host.iumbtemsDepHealth`, best-effort). Returns a disposable
984
+ * registration; returns null (never throws) when there is no bus to adopt.
985
+ */
986
+ export async function registerDepHealth(host, opts = {}) {
987
+ try {
988
+ const bus = opts.bus || null;
989
+ if (!bus || typeof bus.on !== 'function' || typeof bus.emit !== 'function') return null;
990
+ const runOpts = { ...opts, host };
991
+ const handler = createDepHealthHandler(bus, runOpts);
992
+ const unsubs = [];
993
+ for (const event of DEP_HEALTH_EVENTS) {
994
+ try {
995
+ unsubs.push(bus.on(event, handler, { tier: 'slow', name: 'dep-health' }));
996
+ } catch {
997
+ /* one failed subscription must not drop the rest */
998
+ }
999
+ }
1000
+ try {
1001
+ unsubs.push(bus.on('notification', depHealthAuditSink, { tier: 'slow', name: 'dep-health-audit-sink' }));
1002
+ } catch {
1003
+ /* sink is best-effort */
1004
+ }
1005
+ if (unsubs.length === 0) return null;
1006
+ try {
1007
+ if (host && typeof host === 'object') host.iumbtemsDepHealth = { bus, events: [...DEP_HEALTH_EVENTS] };
1008
+ } catch {
1009
+ /* host attach is best-effort */
1010
+ }
1011
+ let disposed = false;
1012
+ return {
1013
+ bus,
1014
+ events: [...DEP_HEALTH_EVENTS],
1015
+ dispose: () => {
1016
+ disposed = true;
1017
+ for (const unsub of unsubs) {
1018
+ try {
1019
+ if (typeof unsub === 'function') unsub();
1020
+ } catch {
1021
+ /* dispose never throws */
1022
+ }
1023
+ }
1024
+ try {
1025
+ if (host && typeof host === 'object' && host.iumbtemsDepHealth?.bus === bus) {
1026
+ delete host.iumbtemsDepHealth;
1027
+ }
1028
+ } catch {
1029
+ /* dispose never throws */
1030
+ }
1031
+ },
1032
+ get disposed() {
1033
+ return disposed;
1034
+ },
1035
+ };
1036
+ } catch {
1037
+ return null;
1038
+ }
1039
+ }