@neat.is/core 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -3869,7 +3869,244 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
3869
3869
  init_cjs_shims();
3870
3870
  var import_fastify = __toESM(require("fastify"), 1);
3871
3871
  var import_cors = __toESM(require("@fastify/cors"), 1);
3872
- var import_types19 = require("@neat.is/types");
3872
+ var import_types20 = require("@neat.is/types");
3873
+
3874
+ // src/divergences.ts
3875
+ init_cjs_shims();
3876
+ var import_types18 = require("@neat.is/types");
3877
+ function bucketKey(source, target, type) {
3878
+ return `${type}|${source}|${target}`;
3879
+ }
3880
+ function bucketEdges(graph) {
3881
+ const buckets = /* @__PURE__ */ new Map();
3882
+ graph.forEachEdge((id, attrs) => {
3883
+ const e = attrs;
3884
+ const parsed = (0, import_types18.parseEdgeId)(id);
3885
+ const provenance = parsed?.provenance ?? e.provenance;
3886
+ const key = bucketKey(e.source, e.target, e.type);
3887
+ const cur = buckets.get(key) ?? { source: e.source, target: e.target, type: e.type };
3888
+ switch (provenance) {
3889
+ case import_types18.Provenance.EXTRACTED:
3890
+ cur.extracted = e;
3891
+ break;
3892
+ case import_types18.Provenance.OBSERVED:
3893
+ cur.observed = e;
3894
+ break;
3895
+ case import_types18.Provenance.INFERRED:
3896
+ cur.inferred = e;
3897
+ break;
3898
+ case import_types18.Provenance.FRONTIER:
3899
+ cur.frontier = e;
3900
+ break;
3901
+ default:
3902
+ if (e.provenance === import_types18.Provenance.STALE) cur.stale = e;
3903
+ }
3904
+ buckets.set(key, cur);
3905
+ });
3906
+ return buckets;
3907
+ }
3908
+ function nodeIsFrontier(graph, nodeId) {
3909
+ if (!graph.hasNode(nodeId)) return false;
3910
+ const attrs = graph.getNodeAttributes(nodeId);
3911
+ return attrs.type === import_types18.NodeType.FrontierNode;
3912
+ }
3913
+ function hasAnyObservedFromSource(graph, sourceId) {
3914
+ if (!graph.hasNode(sourceId)) return false;
3915
+ for (const edgeId of graph.outboundEdges(sourceId)) {
3916
+ const e = graph.getEdgeAttributes(edgeId);
3917
+ if (e.provenance === import_types18.Provenance.OBSERVED) return true;
3918
+ }
3919
+ return false;
3920
+ }
3921
+ function clampConfidence(n) {
3922
+ if (!Number.isFinite(n)) return 0;
3923
+ return Math.max(0, Math.min(1, n));
3924
+ }
3925
+ function reasonForMissingObserved(source, target, type) {
3926
+ return `Code declares ${source} \u2192 ${target} (${type}) but no production traffic has been observed for this edge.`;
3927
+ }
3928
+ function reasonForMissingExtracted(source, target, type) {
3929
+ return `Production observed ${source} \u2192 ${target} (${type}) but static analysis did not surface this edge.`;
3930
+ }
3931
+ var RECOMMENDATION_MISSING_OBSERVED = "Verify the code path is exercised in production; check feature flags or conditional branches that might gate the call.";
3932
+ 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.";
3933
+ var RECOMMENDATION_HOST_MISMATCH = "Check environment-specific config overrides \u2014 the runtime host differs from what static configuration declares.";
3934
+ function detectMissingDivergences(graph, bucket) {
3935
+ const out = [];
3936
+ if (bucket.extracted && !bucket.observed) {
3937
+ if (!nodeIsFrontier(graph, bucket.target)) {
3938
+ const sourceHasTraffic = hasAnyObservedFromSource(graph, bucket.source);
3939
+ out.push({
3940
+ type: "missing-observed",
3941
+ source: bucket.source,
3942
+ target: bucket.target,
3943
+ edgeType: bucket.type,
3944
+ extracted: bucket.extracted,
3945
+ confidence: sourceHasTraffic ? 1 : 0.5,
3946
+ reason: reasonForMissingObserved(bucket.source, bucket.target, bucket.type),
3947
+ recommendation: RECOMMENDATION_MISSING_OBSERVED
3948
+ });
3949
+ }
3950
+ }
3951
+ if (bucket.observed && !bucket.extracted) {
3952
+ const cascaded = clampConfidence(confidenceForEdge(bucket.observed));
3953
+ out.push({
3954
+ type: "missing-extracted",
3955
+ source: bucket.source,
3956
+ target: bucket.target,
3957
+ edgeType: bucket.type,
3958
+ observed: bucket.observed,
3959
+ confidence: cascaded,
3960
+ reason: reasonForMissingExtracted(bucket.source, bucket.target, bucket.type),
3961
+ recommendation: RECOMMENDATION_MISSING_EXTRACTED
3962
+ });
3963
+ }
3964
+ return out;
3965
+ }
3966
+ function declaredHostFor(svc) {
3967
+ const raw = svc.dbConnectionTarget?.trim();
3968
+ if (!raw) return null;
3969
+ const colon = raw.lastIndexOf(":");
3970
+ if (colon === -1) return raw;
3971
+ const port = raw.slice(colon + 1);
3972
+ if (/^\d+$/.test(port)) return raw.slice(0, colon);
3973
+ return raw;
3974
+ }
3975
+ function hasExtractedConfiguredBy(graph, svcId) {
3976
+ for (const edgeId of graph.outboundEdges(svcId)) {
3977
+ const e = graph.getEdgeAttributes(edgeId);
3978
+ if (e.type === import_types18.EdgeType.CONFIGURED_BY && e.provenance === import_types18.Provenance.EXTRACTED) {
3979
+ return true;
3980
+ }
3981
+ }
3982
+ return false;
3983
+ }
3984
+ function detectHostMismatch(graph, svcId, svc) {
3985
+ const declaredHost = declaredHostFor(svc);
3986
+ if (!declaredHost) return [];
3987
+ if (!hasExtractedConfiguredBy(graph, svcId)) return [];
3988
+ const out = [];
3989
+ for (const edgeId of graph.outboundEdges(svcId)) {
3990
+ const edge = graph.getEdgeAttributes(edgeId);
3991
+ if (edge.type !== import_types18.EdgeType.CONNECTS_TO) continue;
3992
+ if (edge.provenance !== import_types18.Provenance.OBSERVED) continue;
3993
+ const target = graph.getNodeAttributes(edge.target);
3994
+ if (target.type !== import_types18.NodeType.DatabaseNode) continue;
3995
+ const observedHost = target.host?.trim();
3996
+ if (!observedHost) continue;
3997
+ if (observedHost === declaredHost) continue;
3998
+ out.push({
3999
+ type: "host-mismatch",
4000
+ source: svcId,
4001
+ target: edge.target,
4002
+ extractedHost: declaredHost,
4003
+ observedHost,
4004
+ confidence: clampConfidence(confidenceForEdge(edge)),
4005
+ reason: `Config declares ${svcId} connects to ${declaredHost}; production connects to ${observedHost}.`,
4006
+ recommendation: RECOMMENDATION_HOST_MISMATCH
4007
+ });
4008
+ }
4009
+ return out;
4010
+ }
4011
+ function detectCompatDivergences(graph, svcId, svc) {
4012
+ const out = [];
4013
+ const deps = svc.dependencies ?? {};
4014
+ for (const edgeId of graph.outboundEdges(svcId)) {
4015
+ const edge = graph.getEdgeAttributes(edgeId);
4016
+ if (edge.type !== import_types18.EdgeType.CONNECTS_TO) continue;
4017
+ if (edge.provenance !== import_types18.Provenance.OBSERVED) continue;
4018
+ const target = graph.getNodeAttributes(edge.target);
4019
+ if (target.type !== import_types18.NodeType.DatabaseNode) continue;
4020
+ for (const pair of compatPairs()) {
4021
+ if (pair.engine !== target.engine) continue;
4022
+ const declared = deps[pair.driver];
4023
+ if (!declared) continue;
4024
+ const result = checkCompatibility(
4025
+ pair.driver,
4026
+ declared,
4027
+ target.engine,
4028
+ target.engineVersion
4029
+ );
4030
+ if (!result.compatible && result.reason) {
4031
+ out.push({
4032
+ type: "version-mismatch",
4033
+ source: svcId,
4034
+ target: edge.target,
4035
+ extractedVersion: declared,
4036
+ observedVersion: target.engineVersion,
4037
+ compatibility: "incompatible",
4038
+ confidence: 1,
4039
+ reason: result.reason,
4040
+ recommendation: result.minDriverVersion ? `Upgrade ${pair.driver} to >= ${result.minDriverVersion}.` : `Update the ${pair.driver} driver to a version compatible with ${target.engine} ${target.engineVersion}.`
4041
+ });
4042
+ }
4043
+ }
4044
+ for (const rule of deprecatedApis()) {
4045
+ const declared = deps[rule.package];
4046
+ if (!declared) continue;
4047
+ const result = checkDeprecatedApi(rule, declared);
4048
+ if (!result.compatible && result.reason) {
4049
+ const ruleRef = {
4050
+ kind: rule.kind ?? "deprecated-api",
4051
+ reason: result.reason,
4052
+ package: rule.package
4053
+ };
4054
+ out.push({
4055
+ type: "compat-violation",
4056
+ source: svcId,
4057
+ target: edge.target,
4058
+ rule: ruleRef,
4059
+ observed: edge,
4060
+ confidence: 1,
4061
+ reason: result.reason,
4062
+ recommendation: `Replace deprecated ${rule.package}@${declared} with a supported version.`
4063
+ });
4064
+ }
4065
+ }
4066
+ }
4067
+ return out;
4068
+ }
4069
+ function involvesNode(d, nodeId) {
4070
+ return d.source === nodeId || d.target === nodeId;
4071
+ }
4072
+ function computeDivergences(graph, opts = {}) {
4073
+ const all = [];
4074
+ const buckets = bucketEdges(graph);
4075
+ for (const bucket of buckets.values()) {
4076
+ for (const d of detectMissingDivergences(graph, bucket)) all.push(d);
4077
+ }
4078
+ graph.forEachNode((nodeId, attrs) => {
4079
+ const n = attrs;
4080
+ if (n.type !== import_types18.NodeType.ServiceNode) return;
4081
+ const svc = n;
4082
+ for (const d of detectHostMismatch(graph, nodeId, svc)) all.push(d);
4083
+ for (const d of detectCompatDivergences(graph, nodeId, svc)) all.push(d);
4084
+ });
4085
+ let filtered = all;
4086
+ if (opts.type) {
4087
+ const allowed = opts.type;
4088
+ filtered = filtered.filter((d) => allowed.has(d.type));
4089
+ }
4090
+ if (opts.minConfidence !== void 0) {
4091
+ const threshold = opts.minConfidence;
4092
+ filtered = filtered.filter((d) => d.confidence >= threshold);
4093
+ }
4094
+ if (opts.node) {
4095
+ const target = opts.node;
4096
+ filtered = filtered.filter((d) => involvesNode(d, target));
4097
+ }
4098
+ filtered.sort((a, b) => {
4099
+ if (b.confidence !== a.confidence) return b.confidence - a.confidence;
4100
+ if (a.type !== b.type) return a.type.localeCompare(b.type);
4101
+ if (a.source !== b.source) return a.source.localeCompare(b.source);
4102
+ return a.target.localeCompare(b.target);
4103
+ });
4104
+ return import_types18.DivergenceResultSchema.parse({
4105
+ divergences: filtered,
4106
+ totalAffected: filtered.length,
4107
+ computedAt: (/* @__PURE__ */ new Date()).toISOString()
4108
+ });
4109
+ }
3873
4110
 
3874
4111
  // src/diff.ts
3875
4112
  init_cjs_shims();
@@ -4005,7 +4242,7 @@ init_cjs_shims();
4005
4242
  var import_node_fs18 = require("fs");
4006
4243
  var import_node_os2 = __toESM(require("os"), 1);
4007
4244
  var import_node_path30 = __toESM(require("path"), 1);
4008
- var import_types18 = require("@neat.is/types");
4245
+ var import_types19 = require("@neat.is/types");
4009
4246
  var LOCK_TIMEOUT_MS = 5e3;
4010
4247
  var LOCK_RETRY_MS = 50;
4011
4248
  function neatHome() {
@@ -4084,10 +4321,10 @@ async function readRegistry() {
4084
4321
  throw err;
4085
4322
  }
4086
4323
  const parsed = JSON.parse(raw);
4087
- return import_types18.RegistryFileSchema.parse(parsed);
4324
+ return import_types19.RegistryFileSchema.parse(parsed);
4088
4325
  }
4089
4326
  async function writeRegistry(reg) {
4090
- const validated = import_types18.RegistryFileSchema.parse(reg);
4327
+ const validated = import_types19.RegistryFileSchema.parse(reg);
4091
4328
  await writeAtomically(registryPath(), JSON.stringify(validated, null, 2) + "\n");
4092
4329
  }
4093
4330
  var ProjectNameCollisionError = class extends Error {
@@ -4279,9 +4516,15 @@ function registerRoutes(scope, ctx) {
4279
4516
  scope.get("/health", async (req, reply) => {
4280
4517
  const proj = resolveProject(registry, req, reply);
4281
4518
  if (!proj) return;
4519
+ const uptimeMs = Date.now() - startedAt;
4282
4520
  return {
4283
- uptime: Math.floor((Date.now() - startedAt) / 1e3),
4521
+ ok: true,
4284
4522
  project: proj.name,
4523
+ uptimeMs,
4524
+ // Legacy fields kept additively. The web shell's StatusBar reads
4525
+ // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4526
+ // the canonical triple and lets the extras pass through.
4527
+ uptime: Math.floor(uptimeMs / 1e3),
4285
4528
  nodeCount: proj.graph.order,
4286
4529
  edgeCount: proj.graph.size,
4287
4530
  lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
@@ -4301,7 +4544,7 @@ function registerRoutes(scope, ctx) {
4301
4544
  if (!proj.graph.hasNode(id)) {
4302
4545
  return reply.code(404).send({ error: "node not found", id });
4303
4546
  }
4304
- return proj.graph.getNodeAttributes(id);
4547
+ return { node: proj.graph.getNodeAttributes(id) };
4305
4548
  }
4306
4549
  );
4307
4550
  scope.get(
@@ -4318,12 +4561,12 @@ function registerRoutes(scope, ctx) {
4318
4561
  return { inbound, outbound };
4319
4562
  }
4320
4563
  );
4321
- scope.get("/graph/node/:id/dependencies", async (req, reply) => {
4564
+ scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
4322
4565
  const proj = resolveProject(registry, req, reply);
4323
4566
  if (!proj) return;
4324
- const { id } = req.params;
4325
- if (!proj.graph.hasNode(id)) {
4326
- return reply.code(404).send({ error: "node not found", id });
4567
+ const { nodeId } = req.params;
4568
+ if (!proj.graph.hasNode(nodeId)) {
4569
+ return reply.code(404).send({ error: "node not found", id: nodeId });
4327
4570
  }
4328
4571
  const depth = req.query.depth ? Number(req.query.depth) : TRANSITIVE_DEPENDENCIES_DEFAULT_DEPTH;
4329
4572
  if (!Number.isFinite(depth) || depth < 1 || depth > TRANSITIVE_DEPENDENCIES_MAX_DEPTH) {
@@ -4331,25 +4574,67 @@ function registerRoutes(scope, ctx) {
4331
4574
  error: `depth must be an integer in [1, ${TRANSITIVE_DEPENDENCIES_MAX_DEPTH}]`
4332
4575
  });
4333
4576
  }
4334
- return getTransitiveDependencies(proj.graph, id, depth);
4577
+ return getTransitiveDependencies(proj.graph, nodeId, depth);
4578
+ });
4579
+ scope.get("/graph/divergences", async (req, reply) => {
4580
+ const proj = resolveProject(registry, req, reply);
4581
+ if (!proj) return;
4582
+ let typeFilter;
4583
+ if (req.query.type) {
4584
+ const candidates = req.query.type.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
4585
+ const parsed = [];
4586
+ for (const c of candidates) {
4587
+ const r = import_types20.DivergenceTypeSchema.safeParse(c);
4588
+ if (!r.success) {
4589
+ return reply.code(400).send({
4590
+ error: `unknown divergence type "${c}"`,
4591
+ allowed: import_types20.DivergenceTypeSchema.options
4592
+ });
4593
+ }
4594
+ parsed.push(r.data);
4595
+ }
4596
+ typeFilter = new Set(parsed);
4597
+ }
4598
+ let minConfidence;
4599
+ if (req.query.minConfidence !== void 0) {
4600
+ const n = Number(req.query.minConfidence);
4601
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
4602
+ return reply.code(400).send({
4603
+ error: "minConfidence must be a number in [0, 1]"
4604
+ });
4605
+ }
4606
+ minConfidence = n;
4607
+ }
4608
+ return computeDivergences(proj.graph, {
4609
+ ...typeFilter ? { type: typeFilter } : {},
4610
+ ...minConfidence !== void 0 ? { minConfidence } : {},
4611
+ ...req.query.node ? { node: req.query.node } : {}
4612
+ });
4335
4613
  });
4336
4614
  scope.get("/incidents", async (req, reply) => {
4337
4615
  const proj = resolveProject(registry, req, reply);
4338
4616
  if (!proj) return;
4339
4617
  const epath = errorsPathFor(proj);
4340
- if (!epath) return [];
4341
- return readErrorEvents(epath);
4618
+ if (!epath) return { count: 0, total: 0, events: [] };
4619
+ const events = await readErrorEvents(epath);
4620
+ const total = events.length;
4621
+ const limit = req.query.limit ? Number(req.query.limit) : 50;
4622
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 200) : 50;
4623
+ const sliced = events.slice(0, safeLimit);
4624
+ return { count: sliced.length, total, events: sliced };
4342
4625
  });
4343
- scope.get("/incidents/stale", async (req, reply) => {
4626
+ scope.get("/stale-events", async (req, reply) => {
4344
4627
  const proj = resolveProject(registry, req, reply);
4345
4628
  if (!proj) return;
4346
4629
  const spath = staleEventsPathFor(proj);
4347
- if (!spath) return [];
4630
+ if (!spath) return { count: 0, total: 0, events: [] };
4348
4631
  const events = await readStaleEvents(spath);
4349
4632
  const filtered = req.query.edgeType ? events.filter((e) => e.edgeType === req.query.edgeType) : events;
4350
4633
  const ordered = [...filtered].reverse();
4634
+ const total = ordered.length;
4351
4635
  const limit = req.query.limit ? Number(req.query.limit) : 50;
4352
- return ordered.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 50);
4636
+ const sliced = ordered.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 50);
4637
+ return { count: sliced.length, total, events: sliced };
4353
4638
  });
4354
4639
  scope.get(
4355
4640
  "/incidents/:nodeId",
@@ -4361,14 +4646,15 @@ function registerRoutes(scope, ctx) {
4361
4646
  return reply.code(404).send({ error: "node not found", id: nodeId });
4362
4647
  }
4363
4648
  const epath = errorsPathFor(proj);
4364
- if (!epath) return [];
4649
+ if (!epath) return { count: 0, total: 0, events: [] };
4365
4650
  const events = await readErrorEvents(epath);
4366
- return events.filter(
4651
+ const filtered = events.filter(
4367
4652
  (e) => e.affectedNode === nodeId || e.service === nodeId.replace(/^service:/, "")
4368
4653
  );
4654
+ return { count: filtered.length, total: filtered.length, events: filtered };
4369
4655
  }
4370
4656
  );
4371
- scope.get("/traverse/root-cause/:nodeId", async (req, reply) => {
4657
+ scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
4372
4658
  const proj = resolveProject(registry, req, reply);
4373
4659
  if (!proj) return;
4374
4660
  const { nodeId } = req.params;
@@ -4388,7 +4674,7 @@ function registerRoutes(scope, ctx) {
4388
4674
  if (!result) return reply.code(404).send({ error: "no root cause found", id: nodeId });
4389
4675
  return result;
4390
4676
  });
4391
- scope.get("/traverse/blast-radius/:nodeId", async (req, reply) => {
4677
+ scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
4392
4678
  const proj = resolveProject(registry, req, reply);
4393
4679
  if (!proj) return;
4394
4680
  const { nodeId } = req.params;
@@ -4486,7 +4772,7 @@ function registerRoutes(scope, ctx) {
4486
4772
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
4487
4773
  let violations = await log.readAll();
4488
4774
  if (req.query.severity) {
4489
- const sev = import_types19.PolicySeveritySchema.safeParse(req.query.severity);
4775
+ const sev = import_types20.PolicySeveritySchema.safeParse(req.query.severity);
4490
4776
  if (!sev.success) {
4491
4777
  return reply.code(400).send({
4492
4778
  error: "invalid severity",
@@ -4498,12 +4784,12 @@ function registerRoutes(scope, ctx) {
4498
4784
  if (req.query.policyId) {
4499
4785
  violations = violations.filter((v) => v.policyId === req.query.policyId);
4500
4786
  }
4501
- return violations;
4787
+ return { violations };
4502
4788
  });
4503
4789
  scope.post("/policies/check", async (req, reply) => {
4504
4790
  const proj = resolveProject(registry, req, reply);
4505
4791
  if (!proj) return;
4506
- const parsed = import_types19.PoliciesCheckBodySchema.safeParse(req.body ?? {});
4792
+ const parsed = import_types20.PoliciesCheckBodySchema.safeParse(req.body ?? {});
4507
4793
  if (!parsed.success) {
4508
4794
  return reply.code(400).send({
4509
4795
  error: "invalid /policies/check body",
@@ -4577,6 +4863,20 @@ async function buildApi(opts) {
4577
4863
  });
4578
4864
  }
4579
4865
  });
4866
+ app.get("/projects/:project", async (req, reply) => {
4867
+ try {
4868
+ const entry = await getProject(req.params.project);
4869
+ if (!entry) {
4870
+ return reply.code(404).send({ error: "project not found", project: req.params.project });
4871
+ }
4872
+ return { project: entry };
4873
+ } catch (err) {
4874
+ return reply.code(500).send({
4875
+ error: "failed to read project registry",
4876
+ details: err.message
4877
+ });
4878
+ }
4879
+ });
4580
4880
  registerRoutes(app, routeCtx);
4581
4881
  await app.register(
4582
4882
  async (scope) => {