@neat.is/core 0.3.0 → 0.3.2

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.
@@ -1,836 +0,0 @@
1
- import {
2
- DEFAULT_PROJECT,
3
- EVENT_BUS_CHANNEL,
4
- PolicyViolationsLog,
5
- Projects,
6
- TRANSITIVE_DEPENDENCIES_DEFAULT_DEPTH,
7
- TRANSITIVE_DEPENDENCIES_MAX_DEPTH,
8
- checkCompatibility,
9
- checkDeprecatedApi,
10
- compatPairs,
11
- confidenceForEdge,
12
- deprecatedApis,
13
- evaluateAllPolicies,
14
- eventBus,
15
- extractFromDirectory,
16
- getBlastRadius,
17
- getProject,
18
- getRootCause,
19
- getTransitiveDependencies,
20
- listProjects,
21
- loadPolicyFile,
22
- pathsForProject,
23
- readErrorEvents,
24
- readStaleEvents
25
- } from "./chunk-B7UUGIXB.js";
26
-
27
- // src/diff.ts
28
- import { promises as fs } from "fs";
29
- async function loadSnapshotForDiff(target) {
30
- if (/^https?:\/\//i.test(target)) {
31
- const res = await fetch(target);
32
- if (!res.ok) {
33
- throw new Error(`fetch ${target} failed: ${res.status} ${res.statusText}`);
34
- }
35
- return await res.json();
36
- }
37
- const raw = await fs.readFile(target, "utf8");
38
- return JSON.parse(raw);
39
- }
40
- function indexEntries(entries) {
41
- const m = /* @__PURE__ */ new Map();
42
- if (!entries) return m;
43
- for (const entry of entries) {
44
- const id = entry.attributes?.id ?? entry.key;
45
- if (!id) continue;
46
- m.set(id, entry.attributes);
47
- }
48
- return m;
49
- }
50
- function computeGraphDiff(liveGraph, baseSnapshot, currentExportedAt = (/* @__PURE__ */ new Date()).toISOString()) {
51
- const baseNodes = indexEntries(baseSnapshot.graph?.nodes);
52
- const baseEdges = indexEntries(baseSnapshot.graph?.edges);
53
- const liveNodes = /* @__PURE__ */ new Map();
54
- liveGraph.forEachNode((id, attrs) => liveNodes.set(id, attrs));
55
- const liveEdges = /* @__PURE__ */ new Map();
56
- liveGraph.forEachEdge((id, attrs) => liveEdges.set(id, attrs));
57
- const result = {
58
- base: { exportedAt: baseSnapshot.exportedAt },
59
- current: { exportedAt: currentExportedAt },
60
- added: { nodes: [], edges: [] },
61
- removed: { nodes: [], edges: [] },
62
- changed: { nodes: [], edges: [] }
63
- };
64
- for (const [id, after] of liveNodes) {
65
- const before = baseNodes.get(id);
66
- if (!before) {
67
- result.added.nodes.push(after);
68
- } else if (!shallowEqual(before, after)) {
69
- result.changed.nodes.push({ id, before, after });
70
- }
71
- }
72
- for (const [id, before] of baseNodes) {
73
- if (!liveNodes.has(id)) result.removed.nodes.push(before);
74
- }
75
- for (const [id, after] of liveEdges) {
76
- const before = baseEdges.get(id);
77
- if (!before) {
78
- result.added.edges.push(after);
79
- } else if (!shallowEqual(before, after)) {
80
- result.changed.edges.push({ id, before, after });
81
- }
82
- }
83
- for (const [id, before] of baseEdges) {
84
- if (!liveEdges.has(id)) result.removed.edges.push(before);
85
- }
86
- return result;
87
- }
88
- function shallowEqual(a, b) {
89
- return canonicalJson(a) === canonicalJson(b);
90
- }
91
- function canonicalJson(value) {
92
- return JSON.stringify(value, (_key, v) => {
93
- if (v && typeof v === "object" && !Array.isArray(v)) {
94
- return Object.keys(v).sort().reduce((acc, k) => {
95
- acc[k] = v[k];
96
- return acc;
97
- }, {});
98
- }
99
- return v;
100
- });
101
- }
102
-
103
- // src/api.ts
104
- import Fastify from "fastify";
105
- import cors from "@fastify/cors";
106
- import { DivergenceTypeSchema, PoliciesCheckBodySchema, PolicySeveritySchema } from "@neat.is/types";
107
-
108
- // src/divergences.ts
109
- import {
110
- DivergenceResultSchema,
111
- EdgeType,
112
- NodeType,
113
- parseEdgeId,
114
- Provenance
115
- } from "@neat.is/types";
116
- function bucketKey(source, target, type) {
117
- return `${type}|${source}|${target}`;
118
- }
119
- function bucketEdges(graph) {
120
- const buckets = /* @__PURE__ */ new Map();
121
- graph.forEachEdge((id, attrs) => {
122
- const e = attrs;
123
- const parsed = parseEdgeId(id);
124
- const provenance = parsed?.provenance ?? e.provenance;
125
- const key = bucketKey(e.source, e.target, e.type);
126
- const cur = buckets.get(key) ?? { source: e.source, target: e.target, type: e.type };
127
- switch (provenance) {
128
- case Provenance.EXTRACTED:
129
- cur.extracted = e;
130
- break;
131
- case Provenance.OBSERVED:
132
- cur.observed = e;
133
- break;
134
- case Provenance.INFERRED:
135
- cur.inferred = e;
136
- break;
137
- case Provenance.FRONTIER:
138
- cur.frontier = e;
139
- break;
140
- default:
141
- if (e.provenance === Provenance.STALE) cur.stale = e;
142
- }
143
- buckets.set(key, cur);
144
- });
145
- return buckets;
146
- }
147
- function nodeIsFrontier(graph, nodeId) {
148
- if (!graph.hasNode(nodeId)) return false;
149
- const attrs = graph.getNodeAttributes(nodeId);
150
- return attrs.type === NodeType.FrontierNode;
151
- }
152
- function hasAnyObservedFromSource(graph, sourceId) {
153
- if (!graph.hasNode(sourceId)) return false;
154
- for (const edgeId of graph.outboundEdges(sourceId)) {
155
- const e = graph.getEdgeAttributes(edgeId);
156
- if (e.provenance === Provenance.OBSERVED) return true;
157
- }
158
- return false;
159
- }
160
- function clampConfidence(n) {
161
- if (!Number.isFinite(n)) return 0;
162
- return Math.max(0, Math.min(1, n));
163
- }
164
- function reasonForMissingObserved(source, target, type) {
165
- return `Code declares ${source} \u2192 ${target} (${type}) but no production traffic has been observed for this edge.`;
166
- }
167
- function reasonForMissingExtracted(source, target, type) {
168
- return `Production observed ${source} \u2192 ${target} (${type}) but static analysis did not surface this edge.`;
169
- }
170
- var RECOMMENDATION_MISSING_OBSERVED = "Verify the code path is exercised in production; check feature flags or conditional branches that might gate the call.";
171
- var RECOMMENDATION_MISSING_EXTRACTED = "Likely dynamic dispatch, reflection, or a coverage gap in tree-sitter extraction. Consider an `aliases` entry on the source service or file an extractor issue.";
172
- var RECOMMENDATION_HOST_MISMATCH = "Check environment-specific config overrides \u2014 the runtime host differs from what static configuration declares.";
173
- function detectMissingDivergences(graph, bucket) {
174
- const out = [];
175
- if (bucket.extracted && !bucket.observed) {
176
- if (!nodeIsFrontier(graph, bucket.target)) {
177
- const sourceHasTraffic = hasAnyObservedFromSource(graph, bucket.source);
178
- out.push({
179
- type: "missing-observed",
180
- source: bucket.source,
181
- target: bucket.target,
182
- edgeType: bucket.type,
183
- extracted: bucket.extracted,
184
- confidence: sourceHasTraffic ? 1 : 0.5,
185
- reason: reasonForMissingObserved(bucket.source, bucket.target, bucket.type),
186
- recommendation: RECOMMENDATION_MISSING_OBSERVED
187
- });
188
- }
189
- }
190
- if (bucket.observed && !bucket.extracted) {
191
- const cascaded = clampConfidence(confidenceForEdge(bucket.observed));
192
- out.push({
193
- type: "missing-extracted",
194
- source: bucket.source,
195
- target: bucket.target,
196
- edgeType: bucket.type,
197
- observed: bucket.observed,
198
- confidence: cascaded,
199
- reason: reasonForMissingExtracted(bucket.source, bucket.target, bucket.type),
200
- recommendation: RECOMMENDATION_MISSING_EXTRACTED
201
- });
202
- }
203
- return out;
204
- }
205
- function declaredHostFor(svc) {
206
- const raw = svc.dbConnectionTarget?.trim();
207
- if (!raw) return null;
208
- const colon = raw.lastIndexOf(":");
209
- if (colon === -1) return raw;
210
- const port = raw.slice(colon + 1);
211
- if (/^\d+$/.test(port)) return raw.slice(0, colon);
212
- return raw;
213
- }
214
- function hasExtractedConfiguredBy(graph, svcId) {
215
- for (const edgeId of graph.outboundEdges(svcId)) {
216
- const e = graph.getEdgeAttributes(edgeId);
217
- if (e.type === EdgeType.CONFIGURED_BY && e.provenance === Provenance.EXTRACTED) {
218
- return true;
219
- }
220
- }
221
- return false;
222
- }
223
- function detectHostMismatch(graph, svcId, svc) {
224
- const declaredHost = declaredHostFor(svc);
225
- if (!declaredHost) return [];
226
- if (!hasExtractedConfiguredBy(graph, svcId)) return [];
227
- const out = [];
228
- for (const edgeId of graph.outboundEdges(svcId)) {
229
- const edge = graph.getEdgeAttributes(edgeId);
230
- if (edge.type !== EdgeType.CONNECTS_TO) continue;
231
- if (edge.provenance !== Provenance.OBSERVED) continue;
232
- const target = graph.getNodeAttributes(edge.target);
233
- if (target.type !== NodeType.DatabaseNode) continue;
234
- const observedHost = target.host?.trim();
235
- if (!observedHost) continue;
236
- if (observedHost === declaredHost) continue;
237
- out.push({
238
- type: "host-mismatch",
239
- source: svcId,
240
- target: edge.target,
241
- extractedHost: declaredHost,
242
- observedHost,
243
- confidence: clampConfidence(confidenceForEdge(edge)),
244
- reason: `Config declares ${svcId} connects to ${declaredHost}; production connects to ${observedHost}.`,
245
- recommendation: RECOMMENDATION_HOST_MISMATCH
246
- });
247
- }
248
- return out;
249
- }
250
- function detectCompatDivergences(graph, svcId, svc) {
251
- const out = [];
252
- const deps = svc.dependencies ?? {};
253
- for (const edgeId of graph.outboundEdges(svcId)) {
254
- const edge = graph.getEdgeAttributes(edgeId);
255
- if (edge.type !== EdgeType.CONNECTS_TO) continue;
256
- if (edge.provenance !== Provenance.OBSERVED) continue;
257
- const target = graph.getNodeAttributes(edge.target);
258
- if (target.type !== NodeType.DatabaseNode) continue;
259
- for (const pair of compatPairs()) {
260
- if (pair.engine !== target.engine) continue;
261
- const declared = deps[pair.driver];
262
- if (!declared) continue;
263
- const result = checkCompatibility(
264
- pair.driver,
265
- declared,
266
- target.engine,
267
- target.engineVersion
268
- );
269
- if (!result.compatible && result.reason) {
270
- out.push({
271
- type: "version-mismatch",
272
- source: svcId,
273
- target: edge.target,
274
- extractedVersion: declared,
275
- observedVersion: target.engineVersion,
276
- compatibility: "incompatible",
277
- confidence: 1,
278
- reason: result.reason,
279
- recommendation: result.minDriverVersion ? `Upgrade ${pair.driver} to >= ${result.minDriverVersion}.` : `Update the ${pair.driver} driver to a version compatible with ${target.engine} ${target.engineVersion}.`
280
- });
281
- }
282
- }
283
- for (const rule of deprecatedApis()) {
284
- const declared = deps[rule.package];
285
- if (!declared) continue;
286
- const result = checkDeprecatedApi(rule, declared);
287
- if (!result.compatible && result.reason) {
288
- const ruleRef = {
289
- kind: rule.kind ?? "deprecated-api",
290
- reason: result.reason,
291
- package: rule.package
292
- };
293
- out.push({
294
- type: "compat-violation",
295
- source: svcId,
296
- target: edge.target,
297
- rule: ruleRef,
298
- observed: edge,
299
- confidence: 1,
300
- reason: result.reason,
301
- recommendation: `Replace deprecated ${rule.package}@${declared} with a supported version.`
302
- });
303
- }
304
- }
305
- }
306
- return out;
307
- }
308
- function involvesNode(d, nodeId) {
309
- return d.source === nodeId || d.target === nodeId;
310
- }
311
- function computeDivergences(graph, opts = {}) {
312
- const all = [];
313
- const buckets = bucketEdges(graph);
314
- for (const bucket of buckets.values()) {
315
- for (const d of detectMissingDivergences(graph, bucket)) all.push(d);
316
- }
317
- graph.forEachNode((nodeId, attrs) => {
318
- const n = attrs;
319
- if (n.type !== NodeType.ServiceNode) return;
320
- const svc = n;
321
- for (const d of detectHostMismatch(graph, nodeId, svc)) all.push(d);
322
- for (const d of detectCompatDivergences(graph, nodeId, svc)) all.push(d);
323
- });
324
- let filtered = all;
325
- if (opts.type) {
326
- const allowed = opts.type;
327
- filtered = filtered.filter((d) => allowed.has(d.type));
328
- }
329
- if (opts.minConfidence !== void 0) {
330
- const threshold = opts.minConfidence;
331
- filtered = filtered.filter((d) => d.confidence >= threshold);
332
- }
333
- if (opts.node) {
334
- const target = opts.node;
335
- filtered = filtered.filter((d) => involvesNode(d, target));
336
- }
337
- filtered.sort((a, b) => {
338
- if (b.confidence !== a.confidence) return b.confidence - a.confidence;
339
- if (a.type !== b.type) return a.type.localeCompare(b.type);
340
- if (a.source !== b.source) return a.source.localeCompare(b.source);
341
- return a.target.localeCompare(b.target);
342
- });
343
- return DivergenceResultSchema.parse({
344
- divergences: filtered,
345
- totalAffected: filtered.length,
346
- computedAt: (/* @__PURE__ */ new Date()).toISOString()
347
- });
348
- }
349
-
350
- // src/streaming.ts
351
- var SSE_HEARTBEAT_MS = 3e4;
352
- var SSE_BACKPRESSURE_CAP = 1e3;
353
- function handleSse(req, reply, opts) {
354
- const heartbeatMs = opts.heartbeatMs ?? SSE_HEARTBEAT_MS;
355
- const backpressureCap = opts.backpressureCap ?? SSE_BACKPRESSURE_CAP;
356
- reply.raw.setHeader("Content-Type", "text/event-stream");
357
- reply.raw.setHeader("Cache-Control", "no-cache, no-transform");
358
- reply.raw.setHeader("Connection", "keep-alive");
359
- reply.raw.setHeader("X-Accel-Buffering", "no");
360
- reply.raw.flushHeaders?.();
361
- let pending = 0;
362
- let dropped = false;
363
- const closeConnection = () => {
364
- if (dropped) return;
365
- dropped = true;
366
- eventBus.off(EVENT_BUS_CHANNEL, listener);
367
- clearInterval(heartbeat);
368
- if (!reply.raw.writableEnded) reply.raw.end();
369
- };
370
- const writeFrame = (frame) => {
371
- if (dropped) return;
372
- if (pending >= backpressureCap) {
373
- const errFrame = `event: error
374
- data: ${JSON.stringify({ reason: "backpressure" })}
375
-
376
- `;
377
- reply.raw.write(errFrame);
378
- closeConnection();
379
- return;
380
- }
381
- pending++;
382
- reply.raw.write(frame, () => {
383
- pending = Math.max(0, pending - 1);
384
- });
385
- };
386
- const listener = (envelope) => {
387
- if (envelope.project !== opts.project) return;
388
- writeFrame(`event: ${envelope.type}
389
- data: ${JSON.stringify(envelope.payload)}
390
-
391
- `);
392
- };
393
- eventBus.on(EVENT_BUS_CHANNEL, listener);
394
- const heartbeat = setInterval(() => {
395
- if (dropped) return;
396
- reply.raw.write(":heartbeat\n\n");
397
- }, heartbeatMs);
398
- if (typeof heartbeat.unref === "function") heartbeat.unref();
399
- req.raw.on("close", closeConnection);
400
- reply.raw.on("close", closeConnection);
401
- reply.raw.on("error", closeConnection);
402
- }
403
-
404
- // src/api.ts
405
- function serializeGraph(graph) {
406
- const nodes = [];
407
- graph.forEachNode((_id, attrs) => {
408
- nodes.push(attrs);
409
- });
410
- const edges = [];
411
- graph.forEachEdge((_id, attrs) => {
412
- edges.push(attrs);
413
- });
414
- return { nodes, edges };
415
- }
416
- function projectFromReq(req) {
417
- const params = req.params;
418
- return params.project ?? DEFAULT_PROJECT;
419
- }
420
- function resolveProject(registry, req, reply) {
421
- const name = projectFromReq(req);
422
- const ctx = registry.get(name);
423
- if (!ctx) {
424
- void reply.code(404).send({ error: "project not found", project: name });
425
- return null;
426
- }
427
- return ctx;
428
- }
429
- function buildLegacyRegistry(opts) {
430
- if (opts.projects) return opts.projects;
431
- if (!opts.graph) {
432
- throw new Error("buildApi: either `projects` or `graph` must be provided");
433
- }
434
- const registry = new Projects();
435
- const paths = pathsForProject(DEFAULT_PROJECT, "");
436
- registry.set(DEFAULT_PROJECT, {
437
- graph: opts.graph,
438
- scanPath: opts.scanPath,
439
- paths: {
440
- snapshotPath: paths.snapshotPath,
441
- errorsPath: opts.errorsPath ?? paths.errorsPath,
442
- staleEventsPath: opts.staleEventsPath ?? paths.staleEventsPath,
443
- embeddingsCachePath: paths.embeddingsCachePath,
444
- policyViolationsPath: paths.policyViolationsPath
445
- },
446
- searchIndex: opts.searchIndex
447
- });
448
- return registry;
449
- }
450
- function registerRoutes(scope, ctx) {
451
- const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
452
- scope.get("/events", (req, reply) => {
453
- const proj = resolveProject(registry, req, reply);
454
- if (!proj) return;
455
- handleSse(req, reply, { project: proj.name });
456
- });
457
- scope.get("/health", async (req, reply) => {
458
- const proj = resolveProject(registry, req, reply);
459
- if (!proj) return;
460
- const uptimeMs = Date.now() - startedAt;
461
- return {
462
- ok: true,
463
- project: proj.name,
464
- uptimeMs,
465
- // Legacy fields kept additively. The web shell's StatusBar reads
466
- // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
467
- // the canonical triple and lets the extras pass through.
468
- uptime: Math.floor(uptimeMs / 1e3),
469
- nodeCount: proj.graph.order,
470
- edgeCount: proj.graph.size,
471
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
472
- };
473
- });
474
- scope.get("/graph", async (req, reply) => {
475
- const proj = resolveProject(registry, req, reply);
476
- if (!proj) return;
477
- return serializeGraph(proj.graph);
478
- });
479
- scope.get(
480
- "/graph/node/:id",
481
- async (req, reply) => {
482
- const proj = resolveProject(registry, req, reply);
483
- if (!proj) return;
484
- const { id } = req.params;
485
- if (!proj.graph.hasNode(id)) {
486
- return reply.code(404).send({ error: "node not found", id });
487
- }
488
- return { node: proj.graph.getNodeAttributes(id) };
489
- }
490
- );
491
- scope.get(
492
- "/graph/edges/:id",
493
- async (req, reply) => {
494
- const proj = resolveProject(registry, req, reply);
495
- if (!proj) return;
496
- const { id } = req.params;
497
- if (!proj.graph.hasNode(id)) {
498
- return reply.code(404).send({ error: "node not found", id });
499
- }
500
- const inbound = proj.graph.inboundEdges(id).map((e) => proj.graph.getEdgeAttributes(e));
501
- const outbound = proj.graph.outboundEdges(id).map((e) => proj.graph.getEdgeAttributes(e));
502
- return { inbound, outbound };
503
- }
504
- );
505
- scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
506
- const proj = resolveProject(registry, req, reply);
507
- if (!proj) return;
508
- const { nodeId } = req.params;
509
- if (!proj.graph.hasNode(nodeId)) {
510
- return reply.code(404).send({ error: "node not found", id: nodeId });
511
- }
512
- const depth = req.query.depth ? Number(req.query.depth) : TRANSITIVE_DEPENDENCIES_DEFAULT_DEPTH;
513
- if (!Number.isFinite(depth) || depth < 1 || depth > TRANSITIVE_DEPENDENCIES_MAX_DEPTH) {
514
- return reply.code(400).send({
515
- error: `depth must be an integer in [1, ${TRANSITIVE_DEPENDENCIES_MAX_DEPTH}]`
516
- });
517
- }
518
- return getTransitiveDependencies(proj.graph, nodeId, depth);
519
- });
520
- scope.get("/graph/divergences", async (req, reply) => {
521
- const proj = resolveProject(registry, req, reply);
522
- if (!proj) return;
523
- let typeFilter;
524
- if (req.query.type) {
525
- const candidates = req.query.type.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
526
- const parsed = [];
527
- for (const c of candidates) {
528
- const r = DivergenceTypeSchema.safeParse(c);
529
- if (!r.success) {
530
- return reply.code(400).send({
531
- error: `unknown divergence type "${c}"`,
532
- allowed: DivergenceTypeSchema.options
533
- });
534
- }
535
- parsed.push(r.data);
536
- }
537
- typeFilter = new Set(parsed);
538
- }
539
- let minConfidence;
540
- if (req.query.minConfidence !== void 0) {
541
- const n = Number(req.query.minConfidence);
542
- if (!Number.isFinite(n) || n < 0 || n > 1) {
543
- return reply.code(400).send({
544
- error: "minConfidence must be a number in [0, 1]"
545
- });
546
- }
547
- minConfidence = n;
548
- }
549
- return computeDivergences(proj.graph, {
550
- ...typeFilter ? { type: typeFilter } : {},
551
- ...minConfidence !== void 0 ? { minConfidence } : {},
552
- ...req.query.node ? { node: req.query.node } : {}
553
- });
554
- });
555
- scope.get("/incidents", async (req, reply) => {
556
- const proj = resolveProject(registry, req, reply);
557
- if (!proj) return;
558
- const epath = errorsPathFor(proj);
559
- if (!epath) return { count: 0, total: 0, events: [] };
560
- const events = await readErrorEvents(epath);
561
- const total = events.length;
562
- const limit = req.query.limit ? Number(req.query.limit) : 50;
563
- const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 200) : 50;
564
- const sliced = events.slice(0, safeLimit);
565
- return { count: sliced.length, total, events: sliced };
566
- });
567
- scope.get("/stale-events", async (req, reply) => {
568
- const proj = resolveProject(registry, req, reply);
569
- if (!proj) return;
570
- const spath = staleEventsPathFor(proj);
571
- if (!spath) return { count: 0, total: 0, events: [] };
572
- const events = await readStaleEvents(spath);
573
- const filtered = req.query.edgeType ? events.filter((e) => e.edgeType === req.query.edgeType) : events;
574
- const ordered = [...filtered].reverse();
575
- const total = ordered.length;
576
- const limit = req.query.limit ? Number(req.query.limit) : 50;
577
- const sliced = ordered.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 50);
578
- return { count: sliced.length, total, events: sliced };
579
- });
580
- scope.get(
581
- "/incidents/:nodeId",
582
- async (req, reply) => {
583
- const proj = resolveProject(registry, req, reply);
584
- if (!proj) return;
585
- const { nodeId } = req.params;
586
- if (!proj.graph.hasNode(nodeId)) {
587
- return reply.code(404).send({ error: "node not found", id: nodeId });
588
- }
589
- const epath = errorsPathFor(proj);
590
- if (!epath) return { count: 0, total: 0, events: [] };
591
- const events = await readErrorEvents(epath);
592
- const filtered = events.filter(
593
- (e) => e.affectedNode === nodeId || e.service === nodeId.replace(/^service:/, "")
594
- );
595
- return { count: filtered.length, total: filtered.length, events: filtered };
596
- }
597
- );
598
- scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
599
- const proj = resolveProject(registry, req, reply);
600
- if (!proj) return;
601
- const { nodeId } = req.params;
602
- if (!proj.graph.hasNode(nodeId)) {
603
- return reply.code(404).send({ error: "node not found", id: nodeId });
604
- }
605
- let errorEvent;
606
- const epath = errorsPathFor(proj);
607
- if (req.query.errorId && epath) {
608
- const events = await readErrorEvents(epath);
609
- errorEvent = events.find((e) => e.id === req.query.errorId);
610
- if (!errorEvent) {
611
- return reply.code(404).send({ error: "error event not found", id: req.query.errorId });
612
- }
613
- }
614
- const result = getRootCause(proj.graph, nodeId, errorEvent);
615
- if (!result) return reply.code(404).send({ error: "no root cause found", id: nodeId });
616
- return result;
617
- });
618
- scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
619
- const proj = resolveProject(registry, req, reply);
620
- if (!proj) return;
621
- const { nodeId } = req.params;
622
- if (!proj.graph.hasNode(nodeId)) {
623
- return reply.code(404).send({ error: "node not found", id: nodeId });
624
- }
625
- const depth = req.query.depth ? Number(req.query.depth) : void 0;
626
- if (depth !== void 0 && (!Number.isFinite(depth) || depth < 0)) {
627
- return reply.code(400).send({ error: "depth must be a non-negative number" });
628
- }
629
- return getBlastRadius(proj.graph, nodeId, depth);
630
- });
631
- scope.get("/search", async (req, reply) => {
632
- const proj = resolveProject(registry, req, reply);
633
- if (!proj) return;
634
- const raw = (req.query.q ?? "").trim();
635
- if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
636
- const limit = req.query.limit ? Number(req.query.limit) : void 0;
637
- const safeLimit = limit !== void 0 && Number.isFinite(limit) && limit > 0 ? limit : void 0;
638
- if (proj.searchIndex) {
639
- const result = await proj.searchIndex.search(raw, safeLimit);
640
- return {
641
- query: result.query,
642
- provider: result.provider,
643
- matches: result.matches.map((m) => ({ ...m.node, score: m.score }))
644
- };
645
- }
646
- const q = raw.toLowerCase();
647
- const matches = [];
648
- proj.graph.forEachNode((id, attrs) => {
649
- const name = attrs.name ?? "";
650
- if (id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
651
- matches.push({ ...attrs, score: 1 });
652
- }
653
- });
654
- return {
655
- query: q,
656
- provider: "substring",
657
- matches: matches.slice(0, safeLimit)
658
- };
659
- });
660
- scope.get(
661
- "/graph/diff",
662
- async (req, reply) => {
663
- const proj = resolveProject(registry, req, reply);
664
- if (!proj) return;
665
- const against = req.query.against;
666
- if (!against) {
667
- return reply.code(400).send({ error: "query parameter `against` is required" });
668
- }
669
- try {
670
- const snapshot = await loadSnapshotForDiff(against);
671
- return computeGraphDiff(proj.graph, snapshot);
672
- } catch (err) {
673
- return reply.code(400).send({ error: "failed to load snapshot", against, detail: err.message });
674
- }
675
- }
676
- );
677
- scope.post("/graph/scan", async (req, reply) => {
678
- const proj = resolveProject(registry, req, reply);
679
- if (!proj) return;
680
- if (!proj.scanPath) {
681
- return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
682
- }
683
- const result = await extractFromDirectory(proj.graph, proj.scanPath);
684
- return {
685
- project: proj.name,
686
- scanned: proj.scanPath,
687
- nodesAdded: result.nodesAdded,
688
- edgesAdded: result.edgesAdded,
689
- nodeCount: proj.graph.order,
690
- edgeCount: proj.graph.size
691
- };
692
- });
693
- scope.get("/policies", async (req, reply) => {
694
- const proj = resolveProject(registry, req, reply);
695
- if (!proj) return;
696
- const policyPath = ctx.policyFilePathFor(proj);
697
- if (!policyPath) {
698
- return { version: 1, policies: [] };
699
- }
700
- try {
701
- const policies = await loadPolicyFile(policyPath);
702
- return { version: 1, policies };
703
- } catch (err) {
704
- return reply.code(400).send({
705
- error: "policy.json failed to parse",
706
- details: err.message
707
- });
708
- }
709
- });
710
- scope.get("/policies/violations", async (req, reply) => {
711
- const proj = resolveProject(registry, req, reply);
712
- if (!proj) return;
713
- const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
714
- let violations = await log.readAll();
715
- if (req.query.severity) {
716
- const sev = PolicySeveritySchema.safeParse(req.query.severity);
717
- if (!sev.success) {
718
- return reply.code(400).send({
719
- error: "invalid severity",
720
- details: sev.error.format()
721
- });
722
- }
723
- violations = violations.filter((v) => v.severity === sev.data);
724
- }
725
- if (req.query.policyId) {
726
- violations = violations.filter((v) => v.policyId === req.query.policyId);
727
- }
728
- return { violations };
729
- });
730
- scope.post("/policies/check", async (req, reply) => {
731
- const proj = resolveProject(registry, req, reply);
732
- if (!proj) return;
733
- const parsed = PoliciesCheckBodySchema.safeParse(req.body ?? {});
734
- if (!parsed.success) {
735
- return reply.code(400).send({
736
- error: "invalid /policies/check body",
737
- details: parsed.error.format()
738
- });
739
- }
740
- const policyPath = ctx.policyFilePathFor(proj);
741
- let policies = [];
742
- if (policyPath) {
743
- try {
744
- policies = await loadPolicyFile(policyPath);
745
- } catch (err) {
746
- return reply.code(400).send({
747
- error: "policy.json failed to parse",
748
- details: err.message
749
- });
750
- }
751
- }
752
- const evalCtx = { now: () => Date.now() };
753
- if (!parsed.data.hypotheticalAction) {
754
- const violations2 = evaluateAllPolicies(proj.graph, policies, evalCtx);
755
- const blocking2 = violations2.filter((v) => v.onViolation === "block");
756
- return { allowed: blocking2.length === 0, violations: violations2 };
757
- }
758
- const violations = evaluateAllPolicies(proj.graph, policies, evalCtx);
759
- const blocking = violations.filter((v) => v.onViolation === "block");
760
- return {
761
- allowed: blocking.length === 0,
762
- hypotheticalAction: parsed.data.hypotheticalAction,
763
- violations
764
- };
765
- });
766
- }
767
- async function buildApi(opts) {
768
- const app = Fastify({ logger: false });
769
- await app.register(cors, { origin: true });
770
- const startedAt = opts.startedAt ?? Date.now();
771
- const registry = buildLegacyRegistry(opts);
772
- const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
773
- const legacyStaleExplicit = !opts.projects && opts.staleEventsPath !== void 0;
774
- const errorsPathFor = (proj) => {
775
- if (proj.name === DEFAULT_PROJECT && !opts.projects) {
776
- return legacyErrorsExplicit ? opts.errorsPath : void 0;
777
- }
778
- return proj.paths.errorsPath;
779
- };
780
- const staleEventsPathFor = (proj) => {
781
- if (proj.name === DEFAULT_PROJECT && !opts.projects) {
782
- return legacyStaleExplicit ? opts.staleEventsPath : void 0;
783
- }
784
- return proj.paths.staleEventsPath;
785
- };
786
- const policyFilePathFor = (proj) => {
787
- if (!proj.scanPath) return void 0;
788
- return `${proj.scanPath}/policy.json`;
789
- };
790
- const routeCtx = {
791
- registry,
792
- startedAt,
793
- errorsPathFor,
794
- staleEventsPathFor,
795
- policyFilePathFor
796
- };
797
- app.get("/projects", async (_req, reply) => {
798
- try {
799
- return await listProjects();
800
- } catch (err) {
801
- return reply.code(500).send({
802
- error: "failed to read project registry",
803
- details: err.message
804
- });
805
- }
806
- });
807
- app.get("/projects/:project", async (req, reply) => {
808
- try {
809
- const entry = await getProject(req.params.project);
810
- if (!entry) {
811
- return reply.code(404).send({ error: "project not found", project: req.params.project });
812
- }
813
- return { project: entry };
814
- } catch (err) {
815
- return reply.code(500).send({
816
- error: "failed to read project registry",
817
- details: err.message
818
- });
819
- }
820
- });
821
- registerRoutes(app, routeCtx);
822
- await app.register(
823
- async (scope) => {
824
- registerRoutes(scope, routeCtx);
825
- },
826
- { prefix: "/projects/:project" }
827
- );
828
- return app;
829
- }
830
-
831
- export {
832
- loadSnapshotForDiff,
833
- computeGraphDiff,
834
- buildApi
835
- };
836
- //# sourceMappingURL=chunk-NVCEZXL7.js.map