@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.
@@ -3492,6 +3492,82 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
3492
3492
  };
3493
3493
  }
3494
3494
 
3495
+ // src/diff.ts
3496
+ import { promises as fs17 } from "fs";
3497
+ async function loadSnapshotForDiff(target) {
3498
+ if (/^https?:\/\//i.test(target)) {
3499
+ const res = await fetch(target);
3500
+ if (!res.ok) {
3501
+ throw new Error(`fetch ${target} failed: ${res.status} ${res.statusText}`);
3502
+ }
3503
+ return await res.json();
3504
+ }
3505
+ const raw = await fs17.readFile(target, "utf8");
3506
+ return JSON.parse(raw);
3507
+ }
3508
+ function indexEntries(entries) {
3509
+ const m = /* @__PURE__ */ new Map();
3510
+ if (!entries) return m;
3511
+ for (const entry of entries) {
3512
+ const id = entry.attributes?.id ?? entry.key;
3513
+ if (!id) continue;
3514
+ m.set(id, entry.attributes);
3515
+ }
3516
+ return m;
3517
+ }
3518
+ function computeGraphDiff(liveGraph, baseSnapshot, currentExportedAt = (/* @__PURE__ */ new Date()).toISOString()) {
3519
+ const baseNodes = indexEntries(baseSnapshot.graph?.nodes);
3520
+ const baseEdges = indexEntries(baseSnapshot.graph?.edges);
3521
+ const liveNodes = /* @__PURE__ */ new Map();
3522
+ liveGraph.forEachNode((id, attrs) => liveNodes.set(id, attrs));
3523
+ const liveEdges = /* @__PURE__ */ new Map();
3524
+ liveGraph.forEachEdge((id, attrs) => liveEdges.set(id, attrs));
3525
+ const result = {
3526
+ base: { exportedAt: baseSnapshot.exportedAt },
3527
+ current: { exportedAt: currentExportedAt },
3528
+ added: { nodes: [], edges: [] },
3529
+ removed: { nodes: [], edges: [] },
3530
+ changed: { nodes: [], edges: [] }
3531
+ };
3532
+ for (const [id, after] of liveNodes) {
3533
+ const before = baseNodes.get(id);
3534
+ if (!before) {
3535
+ result.added.nodes.push(after);
3536
+ } else if (!shallowEqual(before, after)) {
3537
+ result.changed.nodes.push({ id, before, after });
3538
+ }
3539
+ }
3540
+ for (const [id, before] of baseNodes) {
3541
+ if (!liveNodes.has(id)) result.removed.nodes.push(before);
3542
+ }
3543
+ for (const [id, after] of liveEdges) {
3544
+ const before = baseEdges.get(id);
3545
+ if (!before) {
3546
+ result.added.edges.push(after);
3547
+ } else if (!shallowEqual(before, after)) {
3548
+ result.changed.edges.push({ id, before, after });
3549
+ }
3550
+ }
3551
+ for (const [id, before] of baseEdges) {
3552
+ if (!liveEdges.has(id)) result.removed.edges.push(before);
3553
+ }
3554
+ return result;
3555
+ }
3556
+ function shallowEqual(a, b) {
3557
+ return canonicalJson(a) === canonicalJson(b);
3558
+ }
3559
+ function canonicalJson(value) {
3560
+ return JSON.stringify(value, (_key, v) => {
3561
+ if (v && typeof v === "object" && !Array.isArray(v)) {
3562
+ return Object.keys(v).sort().reduce((acc, k) => {
3563
+ acc[k] = v[k];
3564
+ return acc;
3565
+ }, {});
3566
+ }
3567
+ return v;
3568
+ });
3569
+ }
3570
+
3495
3571
  // src/projects.ts
3496
3572
  import path29 from "path";
3497
3573
  function pathsForProject(project, baseDir) {
@@ -3548,7 +3624,7 @@ function parseExtraProjects(raw) {
3548
3624
  }
3549
3625
 
3550
3626
  // src/registry.ts
3551
- import { promises as fs17 } from "fs";
3627
+ import { promises as fs18 } from "fs";
3552
3628
  import os2 from "os";
3553
3629
  import path30 from "path";
3554
3630
  import {
@@ -3570,29 +3646,29 @@ function registryLockPath() {
3570
3646
  async function normalizeProjectPath(input) {
3571
3647
  const resolved = path30.resolve(input);
3572
3648
  try {
3573
- return await fs17.realpath(resolved);
3649
+ return await fs18.realpath(resolved);
3574
3650
  } catch {
3575
3651
  return resolved;
3576
3652
  }
3577
3653
  }
3578
3654
  async function writeAtomically(target, contents) {
3579
- await fs17.mkdir(path30.dirname(target), { recursive: true });
3655
+ await fs18.mkdir(path30.dirname(target), { recursive: true });
3580
3656
  const tmp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
3581
- const fd = await fs17.open(tmp, "w");
3657
+ const fd = await fs18.open(tmp, "w");
3582
3658
  try {
3583
3659
  await fd.writeFile(contents, "utf8");
3584
3660
  await fd.sync();
3585
3661
  } finally {
3586
3662
  await fd.close();
3587
3663
  }
3588
- await fs17.rename(tmp, target);
3664
+ await fs18.rename(tmp, target);
3589
3665
  }
3590
3666
  async function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
3591
3667
  const deadline = Date.now() + timeoutMs;
3592
- await fs17.mkdir(path30.dirname(lockPath), { recursive: true });
3668
+ await fs18.mkdir(path30.dirname(lockPath), { recursive: true });
3593
3669
  while (true) {
3594
3670
  try {
3595
- const fd = await fs17.open(lockPath, "wx");
3671
+ const fd = await fs18.open(lockPath, "wx");
3596
3672
  await fd.close();
3597
3673
  return;
3598
3674
  } catch (err) {
@@ -3608,7 +3684,7 @@ async function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
3608
3684
  }
3609
3685
  }
3610
3686
  async function releaseLock(lockPath) {
3611
- await fs17.unlink(lockPath).catch(() => {
3687
+ await fs18.unlink(lockPath).catch(() => {
3612
3688
  });
3613
3689
  }
3614
3690
  async function withLock(fn) {
@@ -3624,7 +3700,7 @@ async function readRegistry() {
3624
3700
  const file = registryPath();
3625
3701
  let raw;
3626
3702
  try {
3627
- raw = await fs17.readFile(file, "utf8");
3703
+ raw = await fs18.readFile(file, "utf8");
3628
3704
  } catch (err) {
3629
3705
  if (err.code === "ENOENT") {
3630
3706
  return { version: 1, projects: [] };
@@ -3716,25 +3792,746 @@ async function removeProject(name) {
3716
3792
  });
3717
3793
  }
3718
3794
 
3795
+ // src/api.ts
3796
+ import Fastify from "fastify";
3797
+ import cors from "@fastify/cors";
3798
+ import { DivergenceTypeSchema, PoliciesCheckBodySchema, PolicySeveritySchema } from "@neat.is/types";
3799
+
3800
+ // src/divergences.ts
3801
+ import {
3802
+ DivergenceResultSchema,
3803
+ EdgeType as EdgeType9,
3804
+ NodeType as NodeType10,
3805
+ parseEdgeId,
3806
+ Provenance as Provenance10
3807
+ } from "@neat.is/types";
3808
+ function bucketKey(source, target, type) {
3809
+ return `${type}|${source}|${target}`;
3810
+ }
3811
+ function bucketEdges(graph) {
3812
+ const buckets = /* @__PURE__ */ new Map();
3813
+ graph.forEachEdge((id, attrs) => {
3814
+ const e = attrs;
3815
+ const parsed = parseEdgeId(id);
3816
+ const provenance = parsed?.provenance ?? e.provenance;
3817
+ const key = bucketKey(e.source, e.target, e.type);
3818
+ const cur = buckets.get(key) ?? { source: e.source, target: e.target, type: e.type };
3819
+ switch (provenance) {
3820
+ case Provenance10.EXTRACTED:
3821
+ cur.extracted = e;
3822
+ break;
3823
+ case Provenance10.OBSERVED:
3824
+ cur.observed = e;
3825
+ break;
3826
+ case Provenance10.INFERRED:
3827
+ cur.inferred = e;
3828
+ break;
3829
+ case Provenance10.FRONTIER:
3830
+ cur.frontier = e;
3831
+ break;
3832
+ default:
3833
+ if (e.provenance === Provenance10.STALE) cur.stale = e;
3834
+ }
3835
+ buckets.set(key, cur);
3836
+ });
3837
+ return buckets;
3838
+ }
3839
+ function nodeIsFrontier(graph, nodeId) {
3840
+ if (!graph.hasNode(nodeId)) return false;
3841
+ const attrs = graph.getNodeAttributes(nodeId);
3842
+ return attrs.type === NodeType10.FrontierNode;
3843
+ }
3844
+ function hasAnyObservedFromSource(graph, sourceId) {
3845
+ if (!graph.hasNode(sourceId)) return false;
3846
+ for (const edgeId of graph.outboundEdges(sourceId)) {
3847
+ const e = graph.getEdgeAttributes(edgeId);
3848
+ if (e.provenance === Provenance10.OBSERVED) return true;
3849
+ }
3850
+ return false;
3851
+ }
3852
+ function clampConfidence(n) {
3853
+ if (!Number.isFinite(n)) return 0;
3854
+ return Math.max(0, Math.min(1, n));
3855
+ }
3856
+ function reasonForMissingObserved(source, target, type) {
3857
+ return `Code declares ${source} \u2192 ${target} (${type}) but no production traffic has been observed for this edge.`;
3858
+ }
3859
+ function reasonForMissingExtracted(source, target, type) {
3860
+ return `Production observed ${source} \u2192 ${target} (${type}) but static analysis did not surface this edge.`;
3861
+ }
3862
+ var RECOMMENDATION_MISSING_OBSERVED = "Verify the code path is exercised in production; check feature flags or conditional branches that might gate the call.";
3863
+ 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.";
3864
+ var RECOMMENDATION_HOST_MISMATCH = "Check environment-specific config overrides \u2014 the runtime host differs from what static configuration declares.";
3865
+ function detectMissingDivergences(graph, bucket) {
3866
+ const out = [];
3867
+ if (bucket.extracted && !bucket.observed) {
3868
+ if (!nodeIsFrontier(graph, bucket.target)) {
3869
+ const sourceHasTraffic = hasAnyObservedFromSource(graph, bucket.source);
3870
+ out.push({
3871
+ type: "missing-observed",
3872
+ source: bucket.source,
3873
+ target: bucket.target,
3874
+ edgeType: bucket.type,
3875
+ extracted: bucket.extracted,
3876
+ confidence: sourceHasTraffic ? 1 : 0.5,
3877
+ reason: reasonForMissingObserved(bucket.source, bucket.target, bucket.type),
3878
+ recommendation: RECOMMENDATION_MISSING_OBSERVED
3879
+ });
3880
+ }
3881
+ }
3882
+ if (bucket.observed && !bucket.extracted) {
3883
+ const cascaded = clampConfidence(confidenceForEdge(bucket.observed));
3884
+ out.push({
3885
+ type: "missing-extracted",
3886
+ source: bucket.source,
3887
+ target: bucket.target,
3888
+ edgeType: bucket.type,
3889
+ observed: bucket.observed,
3890
+ confidence: cascaded,
3891
+ reason: reasonForMissingExtracted(bucket.source, bucket.target, bucket.type),
3892
+ recommendation: RECOMMENDATION_MISSING_EXTRACTED
3893
+ });
3894
+ }
3895
+ return out;
3896
+ }
3897
+ function declaredHostFor(svc) {
3898
+ const raw = svc.dbConnectionTarget?.trim();
3899
+ if (!raw) return null;
3900
+ const colon = raw.lastIndexOf(":");
3901
+ if (colon === -1) return raw;
3902
+ const port = raw.slice(colon + 1);
3903
+ if (/^\d+$/.test(port)) return raw.slice(0, colon);
3904
+ return raw;
3905
+ }
3906
+ function hasExtractedConfiguredBy(graph, svcId) {
3907
+ for (const edgeId of graph.outboundEdges(svcId)) {
3908
+ const e = graph.getEdgeAttributes(edgeId);
3909
+ if (e.type === EdgeType9.CONFIGURED_BY && e.provenance === Provenance10.EXTRACTED) {
3910
+ return true;
3911
+ }
3912
+ }
3913
+ return false;
3914
+ }
3915
+ function detectHostMismatch(graph, svcId, svc) {
3916
+ const declaredHost = declaredHostFor(svc);
3917
+ if (!declaredHost) return [];
3918
+ if (!hasExtractedConfiguredBy(graph, svcId)) return [];
3919
+ const out = [];
3920
+ for (const edgeId of graph.outboundEdges(svcId)) {
3921
+ const edge = graph.getEdgeAttributes(edgeId);
3922
+ if (edge.type !== EdgeType9.CONNECTS_TO) continue;
3923
+ if (edge.provenance !== Provenance10.OBSERVED) continue;
3924
+ const target = graph.getNodeAttributes(edge.target);
3925
+ if (target.type !== NodeType10.DatabaseNode) continue;
3926
+ const observedHost = target.host?.trim();
3927
+ if (!observedHost) continue;
3928
+ if (observedHost === declaredHost) continue;
3929
+ out.push({
3930
+ type: "host-mismatch",
3931
+ source: svcId,
3932
+ target: edge.target,
3933
+ extractedHost: declaredHost,
3934
+ observedHost,
3935
+ confidence: clampConfidence(confidenceForEdge(edge)),
3936
+ reason: `Config declares ${svcId} connects to ${declaredHost}; production connects to ${observedHost}.`,
3937
+ recommendation: RECOMMENDATION_HOST_MISMATCH
3938
+ });
3939
+ }
3940
+ return out;
3941
+ }
3942
+ function detectCompatDivergences(graph, svcId, svc) {
3943
+ const out = [];
3944
+ const deps = svc.dependencies ?? {};
3945
+ for (const edgeId of graph.outboundEdges(svcId)) {
3946
+ const edge = graph.getEdgeAttributes(edgeId);
3947
+ if (edge.type !== EdgeType9.CONNECTS_TO) continue;
3948
+ if (edge.provenance !== Provenance10.OBSERVED) continue;
3949
+ const target = graph.getNodeAttributes(edge.target);
3950
+ if (target.type !== NodeType10.DatabaseNode) continue;
3951
+ for (const pair of compatPairs()) {
3952
+ if (pair.engine !== target.engine) continue;
3953
+ const declared = deps[pair.driver];
3954
+ if (!declared) continue;
3955
+ const result = checkCompatibility(
3956
+ pair.driver,
3957
+ declared,
3958
+ target.engine,
3959
+ target.engineVersion
3960
+ );
3961
+ if (!result.compatible && result.reason) {
3962
+ out.push({
3963
+ type: "version-mismatch",
3964
+ source: svcId,
3965
+ target: edge.target,
3966
+ extractedVersion: declared,
3967
+ observedVersion: target.engineVersion,
3968
+ compatibility: "incompatible",
3969
+ confidence: 1,
3970
+ reason: result.reason,
3971
+ recommendation: result.minDriverVersion ? `Upgrade ${pair.driver} to >= ${result.minDriverVersion}.` : `Update the ${pair.driver} driver to a version compatible with ${target.engine} ${target.engineVersion}.`
3972
+ });
3973
+ }
3974
+ }
3975
+ for (const rule of deprecatedApis()) {
3976
+ const declared = deps[rule.package];
3977
+ if (!declared) continue;
3978
+ const result = checkDeprecatedApi(rule, declared);
3979
+ if (!result.compatible && result.reason) {
3980
+ const ruleRef = {
3981
+ kind: rule.kind ?? "deprecated-api",
3982
+ reason: result.reason,
3983
+ package: rule.package
3984
+ };
3985
+ out.push({
3986
+ type: "compat-violation",
3987
+ source: svcId,
3988
+ target: edge.target,
3989
+ rule: ruleRef,
3990
+ observed: edge,
3991
+ confidence: 1,
3992
+ reason: result.reason,
3993
+ recommendation: `Replace deprecated ${rule.package}@${declared} with a supported version.`
3994
+ });
3995
+ }
3996
+ }
3997
+ }
3998
+ return out;
3999
+ }
4000
+ function involvesNode(d, nodeId) {
4001
+ return d.source === nodeId || d.target === nodeId;
4002
+ }
4003
+ function computeDivergences(graph, opts = {}) {
4004
+ const all = [];
4005
+ const buckets = bucketEdges(graph);
4006
+ for (const bucket of buckets.values()) {
4007
+ for (const d of detectMissingDivergences(graph, bucket)) all.push(d);
4008
+ }
4009
+ graph.forEachNode((nodeId, attrs) => {
4010
+ const n = attrs;
4011
+ if (n.type !== NodeType10.ServiceNode) return;
4012
+ const svc = n;
4013
+ for (const d of detectHostMismatch(graph, nodeId, svc)) all.push(d);
4014
+ for (const d of detectCompatDivergences(graph, nodeId, svc)) all.push(d);
4015
+ });
4016
+ let filtered = all;
4017
+ if (opts.type) {
4018
+ const allowed = opts.type;
4019
+ filtered = filtered.filter((d) => allowed.has(d.type));
4020
+ }
4021
+ if (opts.minConfidence !== void 0) {
4022
+ const threshold = opts.minConfidence;
4023
+ filtered = filtered.filter((d) => d.confidence >= threshold);
4024
+ }
4025
+ if (opts.node) {
4026
+ const target = opts.node;
4027
+ filtered = filtered.filter((d) => involvesNode(d, target));
4028
+ }
4029
+ filtered.sort((a, b) => {
4030
+ if (b.confidence !== a.confidence) return b.confidence - a.confidence;
4031
+ if (a.type !== b.type) return a.type.localeCompare(b.type);
4032
+ if (a.source !== b.source) return a.source.localeCompare(b.source);
4033
+ return a.target.localeCompare(b.target);
4034
+ });
4035
+ return DivergenceResultSchema.parse({
4036
+ divergences: filtered,
4037
+ totalAffected: filtered.length,
4038
+ computedAt: (/* @__PURE__ */ new Date()).toISOString()
4039
+ });
4040
+ }
4041
+
4042
+ // src/streaming.ts
4043
+ var SSE_HEARTBEAT_MS = 3e4;
4044
+ var SSE_BACKPRESSURE_CAP = 1e3;
4045
+ function handleSse(req, reply, opts) {
4046
+ const heartbeatMs = opts.heartbeatMs ?? SSE_HEARTBEAT_MS;
4047
+ const backpressureCap = opts.backpressureCap ?? SSE_BACKPRESSURE_CAP;
4048
+ reply.raw.setHeader("Content-Type", "text/event-stream");
4049
+ reply.raw.setHeader("Cache-Control", "no-cache, no-transform");
4050
+ reply.raw.setHeader("Connection", "keep-alive");
4051
+ reply.raw.setHeader("X-Accel-Buffering", "no");
4052
+ reply.raw.flushHeaders?.();
4053
+ let pending = 0;
4054
+ let dropped = false;
4055
+ const closeConnection = () => {
4056
+ if (dropped) return;
4057
+ dropped = true;
4058
+ eventBus.off(EVENT_BUS_CHANNEL, listener);
4059
+ clearInterval(heartbeat);
4060
+ if (!reply.raw.writableEnded) reply.raw.end();
4061
+ };
4062
+ const writeFrame = (frame) => {
4063
+ if (dropped) return;
4064
+ if (pending >= backpressureCap) {
4065
+ const errFrame = `event: error
4066
+ data: ${JSON.stringify({ reason: "backpressure" })}
4067
+
4068
+ `;
4069
+ reply.raw.write(errFrame);
4070
+ closeConnection();
4071
+ return;
4072
+ }
4073
+ pending++;
4074
+ reply.raw.write(frame, () => {
4075
+ pending = Math.max(0, pending - 1);
4076
+ });
4077
+ };
4078
+ const listener = (envelope) => {
4079
+ if (envelope.project !== opts.project) return;
4080
+ writeFrame(`event: ${envelope.type}
4081
+ data: ${JSON.stringify(envelope.payload)}
4082
+
4083
+ `);
4084
+ };
4085
+ eventBus.on(EVENT_BUS_CHANNEL, listener);
4086
+ const heartbeat = setInterval(() => {
4087
+ if (dropped) return;
4088
+ reply.raw.write(":heartbeat\n\n");
4089
+ }, heartbeatMs);
4090
+ if (typeof heartbeat.unref === "function") heartbeat.unref();
4091
+ req.raw.on("close", closeConnection);
4092
+ reply.raw.on("close", closeConnection);
4093
+ reply.raw.on("error", closeConnection);
4094
+ }
4095
+
4096
+ // src/api.ts
4097
+ function serializeGraph(graph) {
4098
+ const nodes = [];
4099
+ graph.forEachNode((_id, attrs) => {
4100
+ nodes.push(attrs);
4101
+ });
4102
+ const edges = [];
4103
+ graph.forEachEdge((_id, attrs) => {
4104
+ edges.push(attrs);
4105
+ });
4106
+ return { nodes, edges };
4107
+ }
4108
+ function projectFromReq(req) {
4109
+ const params = req.params;
4110
+ return params.project ?? DEFAULT_PROJECT;
4111
+ }
4112
+ function resolveProject(registry, req, reply) {
4113
+ const name = projectFromReq(req);
4114
+ const ctx = registry.get(name);
4115
+ if (!ctx) {
4116
+ void reply.code(404).send({ error: "project not found", project: name });
4117
+ return null;
4118
+ }
4119
+ return ctx;
4120
+ }
4121
+ function buildLegacyRegistry(opts) {
4122
+ if (opts.projects) return opts.projects;
4123
+ if (!opts.graph) {
4124
+ throw new Error("buildApi: either `projects` or `graph` must be provided");
4125
+ }
4126
+ const registry = new Projects();
4127
+ const paths = pathsForProject(DEFAULT_PROJECT, "");
4128
+ registry.set(DEFAULT_PROJECT, {
4129
+ graph: opts.graph,
4130
+ scanPath: opts.scanPath,
4131
+ paths: {
4132
+ snapshotPath: paths.snapshotPath,
4133
+ errorsPath: opts.errorsPath ?? paths.errorsPath,
4134
+ staleEventsPath: opts.staleEventsPath ?? paths.staleEventsPath,
4135
+ embeddingsCachePath: paths.embeddingsCachePath,
4136
+ policyViolationsPath: paths.policyViolationsPath
4137
+ },
4138
+ searchIndex: opts.searchIndex
4139
+ });
4140
+ return registry;
4141
+ }
4142
+ function registerRoutes(scope, ctx) {
4143
+ const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
4144
+ scope.get("/events", (req, reply) => {
4145
+ const proj = resolveProject(registry, req, reply);
4146
+ if (!proj) return;
4147
+ handleSse(req, reply, { project: proj.name });
4148
+ });
4149
+ scope.get("/health", async (req, reply) => {
4150
+ const proj = resolveProject(registry, req, reply);
4151
+ if (!proj) return;
4152
+ const uptimeMs = Date.now() - startedAt;
4153
+ return {
4154
+ ok: true,
4155
+ project: proj.name,
4156
+ uptimeMs,
4157
+ // Legacy fields kept additively. The web shell's StatusBar reads
4158
+ // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4159
+ // the canonical triple and lets the extras pass through.
4160
+ uptime: Math.floor(uptimeMs / 1e3),
4161
+ nodeCount: proj.graph.order,
4162
+ edgeCount: proj.graph.size,
4163
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4164
+ };
4165
+ });
4166
+ scope.get("/graph", async (req, reply) => {
4167
+ const proj = resolveProject(registry, req, reply);
4168
+ if (!proj) return;
4169
+ return serializeGraph(proj.graph);
4170
+ });
4171
+ scope.get(
4172
+ "/graph/node/:id",
4173
+ async (req, reply) => {
4174
+ const proj = resolveProject(registry, req, reply);
4175
+ if (!proj) return;
4176
+ const { id } = req.params;
4177
+ if (!proj.graph.hasNode(id)) {
4178
+ return reply.code(404).send({ error: "node not found", id });
4179
+ }
4180
+ return { node: proj.graph.getNodeAttributes(id) };
4181
+ }
4182
+ );
4183
+ scope.get(
4184
+ "/graph/edges/:id",
4185
+ async (req, reply) => {
4186
+ const proj = resolveProject(registry, req, reply);
4187
+ if (!proj) return;
4188
+ const { id } = req.params;
4189
+ if (!proj.graph.hasNode(id)) {
4190
+ return reply.code(404).send({ error: "node not found", id });
4191
+ }
4192
+ const inbound = proj.graph.inboundEdges(id).map((e) => proj.graph.getEdgeAttributes(e));
4193
+ const outbound = proj.graph.outboundEdges(id).map((e) => proj.graph.getEdgeAttributes(e));
4194
+ return { inbound, outbound };
4195
+ }
4196
+ );
4197
+ scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
4198
+ const proj = resolveProject(registry, req, reply);
4199
+ if (!proj) return;
4200
+ const { nodeId } = req.params;
4201
+ if (!proj.graph.hasNode(nodeId)) {
4202
+ return reply.code(404).send({ error: "node not found", id: nodeId });
4203
+ }
4204
+ const depth = req.query.depth ? Number(req.query.depth) : TRANSITIVE_DEPENDENCIES_DEFAULT_DEPTH;
4205
+ if (!Number.isFinite(depth) || depth < 1 || depth > TRANSITIVE_DEPENDENCIES_MAX_DEPTH) {
4206
+ return reply.code(400).send({
4207
+ error: `depth must be an integer in [1, ${TRANSITIVE_DEPENDENCIES_MAX_DEPTH}]`
4208
+ });
4209
+ }
4210
+ return getTransitiveDependencies(proj.graph, nodeId, depth);
4211
+ });
4212
+ scope.get("/graph/divergences", async (req, reply) => {
4213
+ const proj = resolveProject(registry, req, reply);
4214
+ if (!proj) return;
4215
+ let typeFilter;
4216
+ if (req.query.type) {
4217
+ const candidates = req.query.type.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
4218
+ const parsed = [];
4219
+ for (const c of candidates) {
4220
+ const r = DivergenceTypeSchema.safeParse(c);
4221
+ if (!r.success) {
4222
+ return reply.code(400).send({
4223
+ error: `unknown divergence type "${c}"`,
4224
+ allowed: DivergenceTypeSchema.options
4225
+ });
4226
+ }
4227
+ parsed.push(r.data);
4228
+ }
4229
+ typeFilter = new Set(parsed);
4230
+ }
4231
+ let minConfidence;
4232
+ if (req.query.minConfidence !== void 0) {
4233
+ const n = Number(req.query.minConfidence);
4234
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
4235
+ return reply.code(400).send({
4236
+ error: "minConfidence must be a number in [0, 1]"
4237
+ });
4238
+ }
4239
+ minConfidence = n;
4240
+ }
4241
+ return computeDivergences(proj.graph, {
4242
+ ...typeFilter ? { type: typeFilter } : {},
4243
+ ...minConfidence !== void 0 ? { minConfidence } : {},
4244
+ ...req.query.node ? { node: req.query.node } : {}
4245
+ });
4246
+ });
4247
+ scope.get("/incidents", async (req, reply) => {
4248
+ const proj = resolveProject(registry, req, reply);
4249
+ if (!proj) return;
4250
+ const epath = errorsPathFor(proj);
4251
+ if (!epath) return { count: 0, total: 0, events: [] };
4252
+ const events = await readErrorEvents(epath);
4253
+ const total = events.length;
4254
+ const limit = req.query.limit ? Number(req.query.limit) : 50;
4255
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 200) : 50;
4256
+ const sliced = events.slice(0, safeLimit);
4257
+ return { count: sliced.length, total, events: sliced };
4258
+ });
4259
+ scope.get("/stale-events", async (req, reply) => {
4260
+ const proj = resolveProject(registry, req, reply);
4261
+ if (!proj) return;
4262
+ const spath = staleEventsPathFor(proj);
4263
+ if (!spath) return { count: 0, total: 0, events: [] };
4264
+ const events = await readStaleEvents(spath);
4265
+ const filtered = req.query.edgeType ? events.filter((e) => e.edgeType === req.query.edgeType) : events;
4266
+ const ordered = [...filtered].reverse();
4267
+ const total = ordered.length;
4268
+ const limit = req.query.limit ? Number(req.query.limit) : 50;
4269
+ const sliced = ordered.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 50);
4270
+ return { count: sliced.length, total, events: sliced };
4271
+ });
4272
+ scope.get(
4273
+ "/incidents/:nodeId",
4274
+ async (req, reply) => {
4275
+ const proj = resolveProject(registry, req, reply);
4276
+ if (!proj) return;
4277
+ const { nodeId } = req.params;
4278
+ if (!proj.graph.hasNode(nodeId)) {
4279
+ return reply.code(404).send({ error: "node not found", id: nodeId });
4280
+ }
4281
+ const epath = errorsPathFor(proj);
4282
+ if (!epath) return { count: 0, total: 0, events: [] };
4283
+ const events = await readErrorEvents(epath);
4284
+ const filtered = events.filter(
4285
+ (e) => e.affectedNode === nodeId || e.service === nodeId.replace(/^service:/, "")
4286
+ );
4287
+ return { count: filtered.length, total: filtered.length, events: filtered };
4288
+ }
4289
+ );
4290
+ scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
4291
+ const proj = resolveProject(registry, req, reply);
4292
+ if (!proj) return;
4293
+ const { nodeId } = req.params;
4294
+ if (!proj.graph.hasNode(nodeId)) {
4295
+ return reply.code(404).send({ error: "node not found", id: nodeId });
4296
+ }
4297
+ let errorEvent;
4298
+ const epath = errorsPathFor(proj);
4299
+ if (req.query.errorId && epath) {
4300
+ const events = await readErrorEvents(epath);
4301
+ errorEvent = events.find((e) => e.id === req.query.errorId);
4302
+ if (!errorEvent) {
4303
+ return reply.code(404).send({ error: "error event not found", id: req.query.errorId });
4304
+ }
4305
+ }
4306
+ const result = getRootCause(proj.graph, nodeId, errorEvent);
4307
+ if (!result) return reply.code(404).send({ error: "no root cause found", id: nodeId });
4308
+ return result;
4309
+ });
4310
+ scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
4311
+ const proj = resolveProject(registry, req, reply);
4312
+ if (!proj) return;
4313
+ const { nodeId } = req.params;
4314
+ if (!proj.graph.hasNode(nodeId)) {
4315
+ return reply.code(404).send({ error: "node not found", id: nodeId });
4316
+ }
4317
+ const depth = req.query.depth ? Number(req.query.depth) : void 0;
4318
+ if (depth !== void 0 && (!Number.isFinite(depth) || depth < 0)) {
4319
+ return reply.code(400).send({ error: "depth must be a non-negative number" });
4320
+ }
4321
+ return getBlastRadius(proj.graph, nodeId, depth);
4322
+ });
4323
+ scope.get("/search", async (req, reply) => {
4324
+ const proj = resolveProject(registry, req, reply);
4325
+ if (!proj) return;
4326
+ const raw = (req.query.q ?? "").trim();
4327
+ if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
4328
+ const limit = req.query.limit ? Number(req.query.limit) : void 0;
4329
+ const safeLimit = limit !== void 0 && Number.isFinite(limit) && limit > 0 ? limit : void 0;
4330
+ if (proj.searchIndex) {
4331
+ const result = await proj.searchIndex.search(raw, safeLimit);
4332
+ return {
4333
+ query: result.query,
4334
+ provider: result.provider,
4335
+ matches: result.matches.map((m) => ({ ...m.node, score: m.score }))
4336
+ };
4337
+ }
4338
+ const q = raw.toLowerCase();
4339
+ const matches = [];
4340
+ proj.graph.forEachNode((id, attrs) => {
4341
+ const name = attrs.name ?? "";
4342
+ if (id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
4343
+ matches.push({ ...attrs, score: 1 });
4344
+ }
4345
+ });
4346
+ return {
4347
+ query: q,
4348
+ provider: "substring",
4349
+ matches: matches.slice(0, safeLimit)
4350
+ };
4351
+ });
4352
+ scope.get(
4353
+ "/graph/diff",
4354
+ async (req, reply) => {
4355
+ const proj = resolveProject(registry, req, reply);
4356
+ if (!proj) return;
4357
+ const against = req.query.against;
4358
+ if (!against) {
4359
+ return reply.code(400).send({ error: "query parameter `against` is required" });
4360
+ }
4361
+ try {
4362
+ const snapshot = await loadSnapshotForDiff(against);
4363
+ return computeGraphDiff(proj.graph, snapshot);
4364
+ } catch (err) {
4365
+ return reply.code(400).send({ error: "failed to load snapshot", against, detail: err.message });
4366
+ }
4367
+ }
4368
+ );
4369
+ scope.post("/graph/scan", async (req, reply) => {
4370
+ const proj = resolveProject(registry, req, reply);
4371
+ if (!proj) return;
4372
+ if (!proj.scanPath) {
4373
+ return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
4374
+ }
4375
+ const result = await extractFromDirectory(proj.graph, proj.scanPath);
4376
+ return {
4377
+ project: proj.name,
4378
+ scanned: proj.scanPath,
4379
+ nodesAdded: result.nodesAdded,
4380
+ edgesAdded: result.edgesAdded,
4381
+ nodeCount: proj.graph.order,
4382
+ edgeCount: proj.graph.size
4383
+ };
4384
+ });
4385
+ scope.get("/policies", async (req, reply) => {
4386
+ const proj = resolveProject(registry, req, reply);
4387
+ if (!proj) return;
4388
+ const policyPath = ctx.policyFilePathFor(proj);
4389
+ if (!policyPath) {
4390
+ return { version: 1, policies: [] };
4391
+ }
4392
+ try {
4393
+ const policies = await loadPolicyFile(policyPath);
4394
+ return { version: 1, policies };
4395
+ } catch (err) {
4396
+ return reply.code(400).send({
4397
+ error: "policy.json failed to parse",
4398
+ details: err.message
4399
+ });
4400
+ }
4401
+ });
4402
+ scope.get("/policies/violations", async (req, reply) => {
4403
+ const proj = resolveProject(registry, req, reply);
4404
+ if (!proj) return;
4405
+ const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
4406
+ let violations = await log.readAll();
4407
+ if (req.query.severity) {
4408
+ const sev = PolicySeveritySchema.safeParse(req.query.severity);
4409
+ if (!sev.success) {
4410
+ return reply.code(400).send({
4411
+ error: "invalid severity",
4412
+ details: sev.error.format()
4413
+ });
4414
+ }
4415
+ violations = violations.filter((v) => v.severity === sev.data);
4416
+ }
4417
+ if (req.query.policyId) {
4418
+ violations = violations.filter((v) => v.policyId === req.query.policyId);
4419
+ }
4420
+ return { violations };
4421
+ });
4422
+ scope.post("/policies/check", async (req, reply) => {
4423
+ const proj = resolveProject(registry, req, reply);
4424
+ if (!proj) return;
4425
+ const parsed = PoliciesCheckBodySchema.safeParse(req.body ?? {});
4426
+ if (!parsed.success) {
4427
+ return reply.code(400).send({
4428
+ error: "invalid /policies/check body",
4429
+ details: parsed.error.format()
4430
+ });
4431
+ }
4432
+ const policyPath = ctx.policyFilePathFor(proj);
4433
+ let policies = [];
4434
+ if (policyPath) {
4435
+ try {
4436
+ policies = await loadPolicyFile(policyPath);
4437
+ } catch (err) {
4438
+ return reply.code(400).send({
4439
+ error: "policy.json failed to parse",
4440
+ details: err.message
4441
+ });
4442
+ }
4443
+ }
4444
+ const evalCtx = { now: () => Date.now() };
4445
+ if (!parsed.data.hypotheticalAction) {
4446
+ const violations2 = evaluateAllPolicies(proj.graph, policies, evalCtx);
4447
+ const blocking2 = violations2.filter((v) => v.onViolation === "block");
4448
+ return { allowed: blocking2.length === 0, violations: violations2 };
4449
+ }
4450
+ const violations = evaluateAllPolicies(proj.graph, policies, evalCtx);
4451
+ const blocking = violations.filter((v) => v.onViolation === "block");
4452
+ return {
4453
+ allowed: blocking.length === 0,
4454
+ hypotheticalAction: parsed.data.hypotheticalAction,
4455
+ violations
4456
+ };
4457
+ });
4458
+ }
4459
+ async function buildApi(opts) {
4460
+ const app = Fastify({ logger: false });
4461
+ await app.register(cors, { origin: true });
4462
+ const startedAt = opts.startedAt ?? Date.now();
4463
+ const registry = buildLegacyRegistry(opts);
4464
+ const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
4465
+ const legacyStaleExplicit = !opts.projects && opts.staleEventsPath !== void 0;
4466
+ const errorsPathFor = (proj) => {
4467
+ if (proj.name === DEFAULT_PROJECT && !opts.projects) {
4468
+ return legacyErrorsExplicit ? opts.errorsPath : void 0;
4469
+ }
4470
+ return proj.paths.errorsPath;
4471
+ };
4472
+ const staleEventsPathFor = (proj) => {
4473
+ if (proj.name === DEFAULT_PROJECT && !opts.projects) {
4474
+ return legacyStaleExplicit ? opts.staleEventsPath : void 0;
4475
+ }
4476
+ return proj.paths.staleEventsPath;
4477
+ };
4478
+ const policyFilePathFor = (proj) => {
4479
+ if (!proj.scanPath) return void 0;
4480
+ return `${proj.scanPath}/policy.json`;
4481
+ };
4482
+ const routeCtx = {
4483
+ registry,
4484
+ startedAt,
4485
+ errorsPathFor,
4486
+ staleEventsPathFor,
4487
+ policyFilePathFor
4488
+ };
4489
+ app.get("/projects", async (_req, reply) => {
4490
+ try {
4491
+ return await listProjects();
4492
+ } catch (err) {
4493
+ return reply.code(500).send({
4494
+ error: "failed to read project registry",
4495
+ details: err.message
4496
+ });
4497
+ }
4498
+ });
4499
+ app.get("/projects/:project", async (req, reply) => {
4500
+ try {
4501
+ const entry = await getProject(req.params.project);
4502
+ if (!entry) {
4503
+ return reply.code(404).send({ error: "project not found", project: req.params.project });
4504
+ }
4505
+ return { project: entry };
4506
+ } catch (err) {
4507
+ return reply.code(500).send({
4508
+ error: "failed to read project registry",
4509
+ details: err.message
4510
+ });
4511
+ }
4512
+ });
4513
+ registerRoutes(app, routeCtx);
4514
+ await app.register(
4515
+ async (scope) => {
4516
+ registerRoutes(scope, routeCtx);
4517
+ },
4518
+ { prefix: "/projects/:project" }
4519
+ );
4520
+ return app;
4521
+ }
4522
+
3719
4523
  export {
3720
4524
  DEFAULT_PROJECT,
3721
4525
  getGraph,
3722
4526
  resetGraph,
3723
4527
  checkCompatibility,
3724
- checkDeprecatedApi,
3725
4528
  ensureCompatLoaded,
3726
4529
  compatPairs,
3727
- deprecatedApis,
3728
- EVENT_BUS_CHANNEL,
3729
- eventBus,
3730
4530
  emitNeatEvent,
3731
4531
  attachGraphToEventBus,
3732
4532
  confidenceForEdge,
3733
4533
  getRootCause,
3734
4534
  getBlastRadius,
3735
- TRANSITIVE_DEPENDENCIES_DEFAULT_DEPTH,
3736
- TRANSITIVE_DEPENDENCIES_MAX_DEPTH,
3737
- getTransitiveDependencies,
3738
4535
  evaluateAllPolicies,
3739
4536
  loadPolicyFile,
3740
4537
  PolicyViolationsLog,
@@ -3759,6 +4556,8 @@ export {
3759
4556
  saveGraphToDisk,
3760
4557
  loadGraphFromDisk,
3761
4558
  startPersistLoop,
4559
+ loadSnapshotForDiff,
4560
+ computeGraphDiff,
3762
4561
  pathsForProject,
3763
4562
  Projects,
3764
4563
  parseExtraProjects,
@@ -3773,6 +4572,7 @@ export {
3773
4572
  listProjects,
3774
4573
  setStatus,
3775
4574
  touchLastSeen,
3776
- removeProject
4575
+ removeProject,
4576
+ buildApi
3777
4577
  };
3778
- //# sourceMappingURL=chunk-B7UUGIXB.js.map
4578
+ //# sourceMappingURL=chunk-FIXKIYNF.js.map