@neat.is/core 0.4.20-dev.20260630 → 0.4.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.
package/dist/server.cjs CHANGED
@@ -40,13 +40,13 @@ var init_cjs_shims = __esm({
40
40
  });
41
41
 
42
42
  // src/auth.ts
43
- function isLoopbackHost(host) {
43
+ function isLoopbackHost2(host) {
44
44
  if (!host) return false;
45
45
  return LOOPBACK_HOSTS.has(host);
46
46
  }
47
47
  function assertBindAuthority(host, token) {
48
48
  if (token && token.length > 0) return;
49
- if (isLoopbackHost(host)) return;
49
+ if (isLoopbackHost2(host)) return;
50
50
  throw new BindAuthorityError(host);
51
51
  }
52
52
  function mountBearerAuth(app, opts) {
@@ -617,7 +617,7 @@ function getGraph(project = DEFAULT_PROJECT) {
617
617
  init_cjs_shims();
618
618
  var import_fastify = __toESM(require("fastify"), 1);
619
619
  var import_cors = __toESM(require("@fastify/cors"), 1);
620
- var import_types25 = require("@neat.is/types");
620
+ var import_types26 = require("@neat.is/types");
621
621
 
622
622
  // src/extend/index.ts
623
623
  init_cjs_shims();
@@ -1458,22 +1458,164 @@ var rootCauseShapes = {
1458
1458
  [import_types.NodeType.ServiceNode]: serviceRootCauseShape,
1459
1459
  [import_types.NodeType.FileNode]: fileRootCauseShape
1460
1460
  };
1461
- function getRootCause(graph, errorNodeId, errorEvent) {
1461
+ function getRootCause(graph, errorNodeId, errorEvent, incidents) {
1462
1462
  if (!graph.hasNode(errorNodeId)) return null;
1463
1463
  const origin = graph.getNodeAttributes(errorNodeId);
1464
1464
  const shape = rootCauseShapes[origin.type];
1465
- if (!shape) return null;
1466
- const walk = longestIncomingWalk(graph, errorNodeId, ROOT_CAUSE_MAX_DEPTH);
1467
- const match = shape(graph, origin, walk);
1468
- if (!match) return null;
1469
- const reason = errorEvent ? `${match.rootCauseReason} (observed error: ${errorEvent.errorMessage})` : match.rootCauseReason;
1465
+ if (shape) {
1466
+ const walk = longestIncomingWalk(graph, errorNodeId, ROOT_CAUSE_MAX_DEPTH);
1467
+ const match = shape(graph, origin, walk);
1468
+ if (match) {
1469
+ const reason = errorEvent ? `${match.rootCauseReason} (observed error: ${errorEvent.errorMessage})` : match.rootCauseReason;
1470
+ return import_types.RootCauseResultSchema.parse({
1471
+ rootCauseNode: match.rootCauseNode,
1472
+ rootCauseReason: reason,
1473
+ traversalPath: walk.path,
1474
+ edgeProvenances: walk.edges.map((e) => e.provenance),
1475
+ confidence: confidenceFromMix(walk.edges),
1476
+ fixRecommendation: match.fixRecommendation
1477
+ });
1478
+ }
1479
+ }
1480
+ if (origin.type === import_types.NodeType.ServiceNode) {
1481
+ const crossService = crossServiceRootCause(graph, errorNodeId, incidents, errorEvent);
1482
+ if (crossService) return crossService;
1483
+ }
1484
+ return rootCauseFromIncidents(errorNodeId, incidents, errorEvent);
1485
+ }
1486
+ var INCIDENT_ROOT_CAUSE_CONFIDENCE = 0.6;
1487
+ function incidentMatchesNode(ev, nodeId) {
1488
+ return ev.affectedNode === nodeId || ev.service === nodeId.replace(/^service:/, "");
1489
+ }
1490
+ function localizeFromIncidents(nodeId, incidents, errorEvent) {
1491
+ const pool = incidents && incidents.length > 0 ? incidents : errorEvent ? [errorEvent] : [];
1492
+ const relevant = pool.filter((ev) => incidentMatchesNode(ev, nodeId));
1493
+ if (relevant.length === 0) return null;
1494
+ const latest = [...relevant].sort((a, b) => b.timestamp.localeCompare(a.timestamp))[0];
1495
+ const attrs = latest.attributes ?? {};
1496
+ const filepath = typeof attrs["code.filepath"] === "string" ? attrs["code.filepath"] : void 0;
1497
+ const lineno = typeof attrs["code.lineno"] === "number" ? attrs["code.lineno"] : void 0;
1498
+ const route = typeof attrs["http.route"] === "string" ? attrs["http.route"] : void 0;
1499
+ const location = filepath ? `${filepath}${lineno !== void 0 ? `:${lineno}` : ""}` : void 0;
1500
+ const sameMode = relevant.filter((ev) => ev.errorMessage === latest.errorMessage);
1501
+ const count = sameMode.length;
1502
+ const tail = count > 1 ? ` (${count} recorded incidents)` : " (1 recorded incident)";
1503
+ const reasonParts = [`${latest.service}: ${latest.errorMessage}`];
1504
+ if (location) reasonParts.push(`surfaced at ${location}`);
1505
+ const rootCauseReason = `${reasonParts.join(" \u2014 ")}${tail}`;
1506
+ const localizesToFile = latest.affectedNode !== nodeId && latest.affectedNode.startsWith("file:");
1507
+ const fileNode = localizesToFile ? latest.affectedNode : void 0;
1508
+ const fixRecommendation = location ? `Inspect ${location}${route ? ` handling ${route}` : ""}` : route ? `Inspect ${latest.service}'s handler for ${route}` : void 0;
1509
+ return {
1510
+ rootCauseNode: fileNode ?? nodeId,
1511
+ rootCauseReason,
1512
+ ...fileNode ? { fileNode } : {},
1513
+ ...fixRecommendation ? { fixRecommendation } : {}
1514
+ };
1515
+ }
1516
+ function rootCauseFromIncidents(nodeId, incidents, errorEvent) {
1517
+ const loc = localizeFromIncidents(nodeId, incidents, errorEvent);
1518
+ if (!loc) return null;
1519
+ const traversalPath = loc.fileNode ? [nodeId, loc.fileNode] : [nodeId];
1520
+ const edgeProvenances = loc.fileNode ? [import_types.Provenance.OBSERVED] : [];
1521
+ return import_types.RootCauseResultSchema.parse({
1522
+ rootCauseNode: loc.rootCauseNode,
1523
+ rootCauseReason: loc.rootCauseReason,
1524
+ traversalPath,
1525
+ edgeProvenances,
1526
+ confidence: INCIDENT_ROOT_CAUSE_CONFIDENCE,
1527
+ ...loc.fixRecommendation ? { fixRecommendation: loc.fixRecommendation } : {}
1528
+ });
1529
+ }
1530
+ function isFailingCallEdge(e) {
1531
+ return e.type === import_types.EdgeType.CALLS && (e.signal?.errorCount ?? 0) > 0;
1532
+ }
1533
+ function callSourcesForService(graph, serviceId3) {
1534
+ const ids = [serviceId3];
1535
+ for (const edgeId of graph.outboundEdges(serviceId3)) {
1536
+ const e = graph.getEdgeAttributes(edgeId);
1537
+ if (e.type !== import_types.EdgeType.CONTAINS) continue;
1538
+ const tgt = graph.getNodeAttributes(e.target);
1539
+ if (tgt.type === import_types.NodeType.FileNode) ids.push(e.target);
1540
+ }
1541
+ return ids;
1542
+ }
1543
+ function failingCallDominates(e, id, curEdge, curId) {
1544
+ const ec = e.signal?.errorCount ?? 0;
1545
+ const cc = curEdge.signal?.errorCount ?? 0;
1546
+ if (ec !== cc) return ec > cc;
1547
+ if (import_types.PROV_RANK[e.provenance] !== import_types.PROV_RANK[curEdge.provenance]) {
1548
+ return import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[curEdge.provenance];
1549
+ }
1550
+ return id < curId;
1551
+ }
1552
+ function dominantFailingCall(graph, serviceId3, visited) {
1553
+ let best = null;
1554
+ for (const src of callSourcesForService(graph, serviceId3)) {
1555
+ for (const edgeId of graph.outboundEdges(src)) {
1556
+ const e = graph.getEdgeAttributes(edgeId);
1557
+ if (!isFailingCallEdge(e)) continue;
1558
+ if (isFrontierNode(graph, e.target)) continue;
1559
+ const owner = resolveOwningService(graph, e.target);
1560
+ if (!owner || visited.has(owner.id)) continue;
1561
+ if (!best || failingCallDominates(e, owner.id, best.edge, best.nextService)) {
1562
+ best = { nextService: owner.id, edge: e };
1563
+ }
1564
+ }
1565
+ }
1566
+ return best;
1567
+ }
1568
+ function followFailingCallChain(graph, originServiceId, maxDepth) {
1569
+ const path43 = [originServiceId];
1570
+ const edges = [];
1571
+ const visited = /* @__PURE__ */ new Set([originServiceId]);
1572
+ let current = originServiceId;
1573
+ for (let depth = 0; depth < maxDepth; depth++) {
1574
+ const hop = dominantFailingCall(graph, current, visited);
1575
+ if (!hop) break;
1576
+ path43.push(hop.nextService);
1577
+ edges.push(hop.edge);
1578
+ visited.add(hop.nextService);
1579
+ current = hop.nextService;
1580
+ }
1581
+ if (edges.length === 0) return null;
1582
+ return { path: path43, edges, culprit: current };
1583
+ }
1584
+ function crossServiceRootCause(graph, originId, incidents, errorEvent) {
1585
+ const chain = followFailingCallChain(graph, originId, ROOT_CAUSE_MAX_DEPTH);
1586
+ if (!chain) return null;
1587
+ const culprit = chain.culprit;
1588
+ const path43 = [...chain.path];
1589
+ const edgeProvenances = chain.edges.map((e) => e.provenance);
1590
+ const baseConfidence = confidenceFromMix(chain.edges);
1591
+ const confidence = Math.max(0, Math.min(1, baseConfidence * INCIDENT_ROOT_CAUSE_CONFIDENCE));
1592
+ const loc = localizeFromIncidents(culprit, incidents, errorEvent);
1593
+ if (loc) {
1594
+ let rootCauseNode = culprit;
1595
+ if (loc.fileNode) {
1596
+ path43.push(loc.fileNode);
1597
+ edgeProvenances.push(import_types.Provenance.OBSERVED);
1598
+ rootCauseNode = loc.fileNode;
1599
+ }
1600
+ return import_types.RootCauseResultSchema.parse({
1601
+ rootCauseNode,
1602
+ rootCauseReason: loc.rootCauseReason,
1603
+ traversalPath: path43,
1604
+ edgeProvenances,
1605
+ confidence,
1606
+ ...loc.fixRecommendation ? { fixRecommendation: loc.fixRecommendation } : {}
1607
+ });
1608
+ }
1609
+ const lastEdge = chain.edges[chain.edges.length - 1];
1610
+ const errs = lastEdge.signal?.errorCount ?? 0;
1611
+ const culpritName = culprit.replace(/^service:/, "");
1470
1612
  return import_types.RootCauseResultSchema.parse({
1471
- rootCauseNode: match.rootCauseNode,
1472
- rootCauseReason: reason,
1473
- traversalPath: walk.path,
1474
- edgeProvenances: walk.edges.map((e) => e.provenance),
1475
- confidence: confidenceFromMix(walk.edges),
1476
- fixRecommendation: match.fixRecommendation
1613
+ rootCauseNode: culprit,
1614
+ rootCauseReason: `${culpritName} is failing downstream calls (${errs} observed error${errs === 1 ? "" : "s"})`,
1615
+ traversalPath: path43,
1616
+ edgeProvenances,
1617
+ confidence,
1618
+ fixRecommendation: `Inspect ${culpritName}'s failing handler`
1477
1619
  });
1478
1620
  }
1479
1621
  function getBlastRadius(graph, nodeId, maxDepth = BLAST_RADIUS_DEFAULT_DEPTH) {
@@ -1496,14 +1638,14 @@ function getBlastRadius(graph, nodeId, maxDepth = BLAST_RADIUS_DEFAULT_DEPTH) {
1496
1638
  });
1497
1639
  }
1498
1640
  if (frame.distance >= maxDepth) continue;
1499
- const outgoing = bestEdgeByTarget(graph, graph.outboundEdges(frame.nodeId));
1500
- for (const [tgtId, edge] of outgoing) {
1501
- if (enqueued.has(tgtId)) continue;
1502
- enqueued.add(tgtId);
1641
+ const incoming = bestEdgeBySource(graph, graph.inboundEdges(frame.nodeId));
1642
+ for (const [srcId, edge] of incoming) {
1643
+ if (enqueued.has(srcId)) continue;
1644
+ enqueued.add(srcId);
1503
1645
  queue.push({
1504
- nodeId: tgtId,
1646
+ nodeId: srcId,
1505
1647
  distance: frame.distance + 1,
1506
- path: [...frame.path, tgtId],
1648
+ path: [...frame.path, srcId],
1507
1649
  pathEdges: [...frame.pathEdges, edge]
1508
1650
  });
1509
1651
  }
@@ -1559,6 +1701,62 @@ function getTransitiveDependencies(graph, nodeId, depth = TRANSITIVE_DEPENDENCIE
1559
1701
  total: dependencies.length
1560
1702
  });
1561
1703
  }
1704
+ function getObservedDependencies(graph, nodeId) {
1705
+ if (!graph.hasNode(nodeId)) {
1706
+ return import_types.ObservedDependenciesResultSchema.parse({
1707
+ origin: nodeId,
1708
+ dependencies: [],
1709
+ observed: false,
1710
+ inboundObservedCount: 0,
1711
+ hasExtractedOutbound: false
1712
+ });
1713
+ }
1714
+ const attrs = graph.getNodeAttributes(nodeId);
1715
+ const scope = [nodeId];
1716
+ if (attrs.type === import_types.NodeType.ServiceNode) {
1717
+ for (const edgeId of graph.outboundEdges(nodeId)) {
1718
+ const e = graph.getEdgeAttributes(edgeId);
1719
+ if (e.type !== import_types.EdgeType.CONTAINS) continue;
1720
+ const owned = graph.getNodeAttributes(e.target);
1721
+ if (owned.type === import_types.NodeType.FileNode) scope.push(e.target);
1722
+ }
1723
+ }
1724
+ const dependencies = [];
1725
+ const seenEdge = /* @__PURE__ */ new Set();
1726
+ let hasExtractedOutbound = false;
1727
+ for (const src of scope) {
1728
+ for (const edgeId of graph.outboundEdges(src)) {
1729
+ const e = graph.getEdgeAttributes(edgeId);
1730
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1731
+ if (e.provenance === import_types.Provenance.OBSERVED) {
1732
+ if (!seenEdge.has(e.id)) {
1733
+ seenEdge.add(e.id);
1734
+ dependencies.push(e);
1735
+ }
1736
+ } else if (e.provenance === import_types.Provenance.EXTRACTED) {
1737
+ hasExtractedOutbound = true;
1738
+ }
1739
+ }
1740
+ }
1741
+ let inboundObservedCount = 0;
1742
+ for (const tgt of scope) {
1743
+ for (const edgeId of graph.inboundEdges(tgt)) {
1744
+ const e = graph.getEdgeAttributes(edgeId);
1745
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1746
+ if (e.provenance === import_types.Provenance.OBSERVED) inboundObservedCount += 1;
1747
+ }
1748
+ }
1749
+ dependencies.sort(
1750
+ (a, b) => a.target.localeCompare(b.target) || a.source.localeCompare(b.source) || a.id.localeCompare(b.id)
1751
+ );
1752
+ return import_types.ObservedDependenciesResultSchema.parse({
1753
+ origin: nodeId,
1754
+ dependencies,
1755
+ observed: dependencies.length > 0 || inboundObservedCount > 0,
1756
+ inboundObservedCount,
1757
+ hasExtractedOutbound
1758
+ });
1759
+ }
1562
1760
 
1563
1761
  // src/divergences.ts
1564
1762
  function bucketKey(source, target, type) {
@@ -1611,10 +1809,16 @@ function gradedConfidence(edge) {
1611
1809
  if (typeof edge.confidence === "number") return clampConfidence(edge.confidence);
1612
1810
  return clampConfidence(confidenceForEdge(edge));
1613
1811
  }
1812
+ var OBSERVABLE_EDGE_TYPES = /* @__PURE__ */ new Set([
1813
+ import_types2.EdgeType.CALLS,
1814
+ import_types2.EdgeType.CONNECTS_TO,
1815
+ import_types2.EdgeType.PUBLISHES_TO,
1816
+ import_types2.EdgeType.CONSUMES_FROM
1817
+ ]);
1614
1818
  function detectMissingDivergences(graph, bucket) {
1615
1819
  const out = [];
1616
1820
  if (bucket.type === import_types2.EdgeType.CONTAINS) return out;
1617
- if (bucket.extracted && !bucket.observed) {
1821
+ if (bucket.extracted && !bucket.observed && OBSERVABLE_EDGE_TYPES.has(bucket.type)) {
1618
1822
  if (!nodeIsFrontier(graph, bucket.target)) {
1619
1823
  out.push({
1620
1824
  type: "missing-observed",
@@ -1748,6 +1952,23 @@ function detectCompatDivergences(graph, svcId, svc) {
1748
1952
  function involvesNode(d, nodeId) {
1749
1953
  return d.source === nodeId || d.target === nodeId;
1750
1954
  }
1955
+ function suppressHostMismatchHalves(all) {
1956
+ const observedHalf = /* @__PURE__ */ new Set();
1957
+ const declaredHalf = /* @__PURE__ */ new Set();
1958
+ for (const d of all) {
1959
+ if (d.type !== "host-mismatch") continue;
1960
+ observedHalf.add(`${d.source}->${d.target}`);
1961
+ declaredHalf.add((0, import_types2.databaseId)(d.extractedHost));
1962
+ }
1963
+ if (observedHalf.size === 0) return all;
1964
+ return all.filter((d) => {
1965
+ if (d.type === "missing-extracted" && observedHalf.has(`${d.source}->${d.target}`)) {
1966
+ return false;
1967
+ }
1968
+ if (d.type === "missing-observed" && declaredHalf.has(d.target)) return false;
1969
+ return true;
1970
+ });
1971
+ }
1751
1972
  function computeDivergences(graph, opts = {}) {
1752
1973
  const all = [];
1753
1974
  const buckets = bucketEdges(graph);
@@ -1761,7 +1982,8 @@ function computeDivergences(graph, opts = {}) {
1761
1982
  for (const d of detectHostMismatch(graph, nodeId, svc)) all.push(d);
1762
1983
  for (const d of detectCompatDivergences(graph, nodeId, svc)) all.push(d);
1763
1984
  });
1764
- let filtered = all;
1985
+ const reconciled = suppressHostMismatchHalves(all);
1986
+ let filtered = reconciled;
1765
1987
  if (opts.type) {
1766
1988
  const allowed = opts.type;
1767
1989
  filtered = filtered.filter((d) => allowed.has(d.type));
@@ -2082,6 +2304,130 @@ function evaluateAllPolicies(graph, policies, ctx) {
2082
2304
  }
2083
2305
  return out;
2084
2306
  }
2307
+ function selectApplicablePolicies(graph, policies, nodeId) {
2308
+ if (!graph.hasNode(nodeId)) return [];
2309
+ const node = graph.getNodeAttributes(nodeId);
2310
+ const out = [];
2311
+ for (const policy of policies) {
2312
+ const m = matchPolicyToNode(graph, policy, nodeId, node);
2313
+ if (!m) continue;
2314
+ out.push({
2315
+ policyId: policy.id,
2316
+ policyName: policy.name,
2317
+ ...policy.description !== void 0 ? { description: policy.description } : {},
2318
+ severity: policy.severity,
2319
+ onViolation: resolveOnViolation(policy),
2320
+ ruleType: policy.rule.type,
2321
+ match: m.match,
2322
+ reason: m.reason
2323
+ });
2324
+ }
2325
+ return out;
2326
+ }
2327
+ function requiredProvenanceList(required) {
2328
+ if (Array.isArray(required)) return required.join(" | ");
2329
+ return String(required);
2330
+ }
2331
+ function nodeTouchesEdgeType(graph, nodeId, edgeType, requiredOtherEnd) {
2332
+ const incident = [...graph.outboundEdges(nodeId), ...graph.inboundEdges(nodeId)];
2333
+ for (const edgeId of incident) {
2334
+ const e = graph.getEdgeAttributes(edgeId);
2335
+ if (e.type !== edgeType) continue;
2336
+ if (requiredOtherEnd === void 0) return true;
2337
+ if (e.source === requiredOtherEnd || e.target === requiredOtherEnd) return true;
2338
+ }
2339
+ return false;
2340
+ }
2341
+ function blastRadiusSubjectReaching(graph, rule, nodeId) {
2342
+ let found = null;
2343
+ graph.forEachNode((subjId, attrs) => {
2344
+ if (found !== null) return;
2345
+ if (subjId === nodeId) return;
2346
+ if (attrs.type !== rule.nodeType) return;
2347
+ const radius = rule.depth !== void 0 ? getBlastRadius(graph, subjId, rule.depth) : getBlastRadius(graph, subjId);
2348
+ if (radius.affectedNodes.some((n) => n.nodeId === nodeId)) found = subjId;
2349
+ });
2350
+ return found;
2351
+ }
2352
+ function matchPolicyToNode(graph, policy, nodeId, node) {
2353
+ const rule = policy.rule;
2354
+ switch (rule.type) {
2355
+ case "structural": {
2356
+ if (node.type === rule.fromNodeType) {
2357
+ return {
2358
+ match: "subject",
2359
+ reason: `every ${rule.fromNodeType} must have a ${rule.edgeType} edge to a ${rule.toNodeType}`
2360
+ };
2361
+ }
2362
+ if (node.type === rule.toNodeType) {
2363
+ return {
2364
+ match: "region",
2365
+ reason: `${rule.fromNodeType} nodes must reach a ${rule.toNodeType} like this one via a ${rule.edgeType} edge`
2366
+ };
2367
+ }
2368
+ return null;
2369
+ }
2370
+ case "ownership": {
2371
+ if (node.type === rule.nodeType) {
2372
+ return {
2373
+ match: "subject",
2374
+ reason: `every ${rule.nodeType} must declare a non-empty "${rule.field}" field`
2375
+ };
2376
+ }
2377
+ return null;
2378
+ }
2379
+ case "blast-radius": {
2380
+ const depthLabel = rule.depth !== void 0 ? ` at depth ${rule.depth}` : "";
2381
+ if (node.type === rule.nodeType) {
2382
+ return {
2383
+ match: "subject",
2384
+ reason: `no ${rule.nodeType} may exceed a blast radius of ${rule.maxAffected}${depthLabel}`
2385
+ };
2386
+ }
2387
+ const subject = blastRadiusSubjectReaching(graph, rule, nodeId);
2388
+ if (subject) {
2389
+ return {
2390
+ match: "region",
2391
+ reason: `this node is in the blast radius of ${subject} (a ${rule.nodeType} held to a blast radius of ${rule.maxAffected}${depthLabel}) \u2014 it breaks if that ${rule.nodeType} changes`
2392
+ };
2393
+ }
2394
+ return null;
2395
+ }
2396
+ case "compatibility": {
2397
+ const kindLabel = rule.kind ?? "all compat shapes";
2398
+ if (node.type === import_types3.NodeType.ServiceNode) {
2399
+ return {
2400
+ match: "subject",
2401
+ reason: `this service's dependencies are compatibility-checked (${kindLabel})`
2402
+ };
2403
+ }
2404
+ const reachesDriverEngine = rule.kind === void 0 || rule.kind === "driver-engine";
2405
+ if (reachesDriverEngine && node.type === import_types3.NodeType.DatabaseNode && nodeTouchesEdgeType(graph, nodeId, import_types3.EdgeType.CONNECTS_TO)) {
2406
+ return {
2407
+ match: "region",
2408
+ reason: "services connecting to this database have their driver/engine compatibility checked against it"
2409
+ };
2410
+ }
2411
+ return null;
2412
+ }
2413
+ case "provenance": {
2414
+ const requiredList = requiredProvenanceList(rule.required);
2415
+ if (rule.targetNodeId !== void 0 && nodeId === rule.targetNodeId) {
2416
+ return {
2417
+ match: "subject",
2418
+ reason: `every ${rule.edgeType} edge into ${rule.targetNodeId} must carry ${requiredList} provenance`
2419
+ };
2420
+ }
2421
+ if (nodeTouchesEdgeType(graph, nodeId, rule.edgeType, rule.targetNodeId)) {
2422
+ return {
2423
+ match: "region",
2424
+ reason: rule.targetNodeId !== void 0 ? `this node sits on a ${rule.edgeType} edge to ${rule.targetNodeId}, which must carry ${requiredList} provenance` : `this node sits on a ${rule.edgeType} edge, which must carry ${requiredList} provenance`
2425
+ };
2426
+ }
2427
+ return null;
2428
+ }
2429
+ }
2430
+ }
2085
2431
  async function loadPolicyFile(policyPath) {
2086
2432
  let raw;
2087
2433
  try {
@@ -2200,9 +2546,9 @@ function loadIncidentThresholdsFromEnv() {
2200
2546
  return DEFAULT_INCIDENT_THRESHOLDS;
2201
2547
  }
2202
2548
  }
2203
- function httpResponseStatus(span) {
2549
+ function httpResponseStatusFromAttrs(attrs) {
2204
2550
  for (const key of ["http.response.status_code", "http.status_code"]) {
2205
- const v = span.attributes[key];
2551
+ const v = attrs[key];
2206
2552
  if (typeof v === "number" && Number.isFinite(v)) return v;
2207
2553
  if (typeof v === "string") {
2208
2554
  const n = Number(v);
@@ -2211,6 +2557,63 @@ function httpResponseStatus(span) {
2211
2557
  }
2212
2558
  return void 0;
2213
2559
  }
2560
+ function httpResponseStatus(span) {
2561
+ return httpResponseStatusFromAttrs(span.attributes);
2562
+ }
2563
+ function httpFailureMessageFromAttrs(attrs) {
2564
+ const status2 = httpResponseStatusFromAttrs(attrs);
2565
+ const route = pickAttrFrom(attrs, "http.route", "http.target", "url.path");
2566
+ const method = pickAttrFrom(attrs, "http.request.method", "http.method");
2567
+ const where = route ? `${method ? `${method} ` : ""}${route}` : void 0;
2568
+ if (status2 !== void 0 && where) return `${status2} on ${where}`;
2569
+ if (status2 !== void 0) return `HTTP ${status2}`;
2570
+ if (where) return `error on ${where}`;
2571
+ return void 0;
2572
+ }
2573
+ var GRPC_STATUS_NAMES = {
2574
+ 1: "CANCELLED",
2575
+ 2: "UNKNOWN",
2576
+ 3: "INVALID_ARGUMENT",
2577
+ 4: "DEADLINE_EXCEEDED",
2578
+ 5: "NOT_FOUND",
2579
+ 6: "ALREADY_EXISTS",
2580
+ 7: "PERMISSION_DENIED",
2581
+ 8: "RESOURCE_EXHAUSTED",
2582
+ 9: "FAILED_PRECONDITION",
2583
+ 10: "ABORTED",
2584
+ 11: "OUT_OF_RANGE",
2585
+ 12: "UNIMPLEMENTED",
2586
+ 13: "INTERNAL",
2587
+ 14: "UNAVAILABLE",
2588
+ 15: "DATA_LOSS",
2589
+ 16: "UNAUTHENTICATED"
2590
+ };
2591
+ function grpcStatusCodeFromAttrs(attrs) {
2592
+ const v = attrs["rpc.grpc.status_code"];
2593
+ if (typeof v === "number" && Number.isFinite(v)) return v;
2594
+ if (typeof v === "string") {
2595
+ const n = Number(v);
2596
+ if (Number.isFinite(n)) return n;
2597
+ }
2598
+ return void 0;
2599
+ }
2600
+ function nonHttpFailureMessageFromAttrs(attrs) {
2601
+ const grpc2 = grpcStatusCodeFromAttrs(attrs);
2602
+ if (grpc2 !== void 0 && grpc2 !== 0) {
2603
+ const name = GRPC_STATUS_NAMES[grpc2] ?? `status ${grpc2}`;
2604
+ const detail = pickAttrFrom(attrs, "rpc.grpc.status_message");
2605
+ return detail ? `gRPC ${name}: ${detail}` : `gRPC ${name}`;
2606
+ }
2607
+ const errType = pickAttrFrom(attrs, "error.type");
2608
+ if (errType && errType !== "_OTHER" && !/^\d+$/.test(errType)) {
2609
+ const peer = pickAttrFrom(attrs, "server.address", "net.peer.name", "net.host.name");
2610
+ return peer ? `${errType} connecting to ${peer}` : errType;
2611
+ }
2612
+ return void 0;
2613
+ }
2614
+ function incidentMessage(span) {
2615
+ return span.exception?.message ?? httpFailureMessageFromAttrs(span.attributes) ?? nonHttpFailureMessageFromAttrs(span.attributes) ?? "unknown error";
2616
+ }
2214
2617
  function nowIso(ctx) {
2215
2618
  return new Date(ctx.now ? ctx.now() : Date.now()).toISOString();
2216
2619
  }
@@ -2230,13 +2633,16 @@ function warnNoSourceMaps(serviceName) {
2230
2633
  `[neat] ${serviceName}: no .map files found under dist/; observed file edges will land on dist paths, not src. Set sourceMap: true in tsconfig to enable file-level reconciliation.`
2231
2634
  );
2232
2635
  }
2233
- function pickAttr(span, ...keys) {
2636
+ function pickAttrFrom(attrs, ...keys) {
2234
2637
  for (const k of keys) {
2235
- const v = span.attributes[k];
2638
+ const v = attrs[k];
2236
2639
  if (typeof v === "string" && v.length > 0) return v;
2237
2640
  }
2238
2641
  return void 0;
2239
2642
  }
2643
+ function pickAttr(span, ...keys) {
2644
+ return pickAttrFrom(span.attributes, ...keys);
2645
+ }
2240
2646
  function hostFromUrl(u) {
2241
2647
  if (!u) return void 0;
2242
2648
  try {
@@ -2248,6 +2654,10 @@ function hostFromUrl(u) {
2248
2654
  function pickAddress(span) {
2249
2655
  return pickAttr(span, "server.address", "net.peer.name", "net.host.name") ?? hostFromUrl(pickAttr(span, "url.full", "http.url"));
2250
2656
  }
2657
+ function isLoopbackHost(host) {
2658
+ const h = host.toLowerCase();
2659
+ return h === "localhost" || h === "ip6-localhost" || h === "::1" || h === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(h);
2660
+ }
2251
2661
  var CODE_FILEPATH_ATTR = "code.filepath";
2252
2662
  var CODE_LINENO_ATTR = "code.lineno";
2253
2663
  var CODE_FUNCTION_ATTR = "code.function";
@@ -2355,15 +2765,31 @@ function callSiteFromSpan(span, serviceNode, scanPath) {
2355
2765
  ...originalRelPath && originalRelPath !== relPath ? { originalRelPath } : {}
2356
2766
  };
2357
2767
  }
2768
+ function reconcileObservedRelPath(graph, serviceName, relPath) {
2769
+ if (graph.hasNode((0, import_types4.fileId)(serviceName, relPath))) return relPath;
2770
+ let best = null;
2771
+ graph.forEachNode((_id, attrs) => {
2772
+ const a = attrs;
2773
+ if (a.type !== import_types4.NodeType.FileNode || a.service !== serviceName) return;
2774
+ if (a.discoveredVia === "otel") return;
2775
+ const p = a.path;
2776
+ if (!p) return;
2777
+ if ((relPath === p || relPath.endsWith(`/${p}`)) && (!best || p.length > best.length)) {
2778
+ best = p;
2779
+ }
2780
+ });
2781
+ return best ?? relPath;
2782
+ }
2358
2783
  function ensureObservedFileNode(graph, serviceName, serviceNodeId, callSite) {
2359
- const fileNodeId = (0, import_types4.fileId)(serviceName, callSite.relPath);
2784
+ const relPath = reconcileObservedRelPath(graph, serviceName, callSite.relPath);
2785
+ const fileNodeId = (0, import_types4.fileId)(serviceName, relPath);
2360
2786
  if (!graph.hasNode(fileNodeId)) {
2361
- const language = languageForExt(callSite.relPath);
2787
+ const language = languageForExt(relPath);
2362
2788
  const node = {
2363
2789
  id: fileNodeId,
2364
2790
  type: import_types4.NodeType.FileNode,
2365
2791
  service: serviceName,
2366
- path: callSite.relPath,
2792
+ path: relPath,
2367
2793
  ...language ? { language } : {},
2368
2794
  ...callSite.originalRelPath ? { originalPath: callSite.originalRelPath } : {},
2369
2795
  discoveredVia: "otel"
@@ -2391,6 +2817,11 @@ function makeInferredEdgeId(type, source, target) {
2391
2817
  }
2392
2818
  var INFERRED_CONFIDENCE = 0.6;
2393
2819
  var STITCH_MAX_DEPTH = 2;
2820
+ var STITCH_EDGE_TYPES = /* @__PURE__ */ new Set([
2821
+ import_types4.EdgeType.CALLS,
2822
+ import_types4.EdgeType.CONNECTS_TO,
2823
+ import_types4.EdgeType.DEPENDS_ON
2824
+ ]);
2394
2825
  var WIRE_SPAN_KIND_CLIENT = 3;
2395
2826
  var WIRE_SPAN_KIND_PRODUCER = 4;
2396
2827
  function spanMintsObservedEdge(kind) {
@@ -2564,6 +2995,7 @@ function stitchTrace(graph, sourceServiceId, ts) {
2564
2995
  for (const edgeId of outbound) {
2565
2996
  const edge = graph.getEdgeAttributes(edgeId);
2566
2997
  if (edge.provenance !== import_types4.Provenance.EXTRACTED) continue;
2998
+ if (!STITCH_EDGE_TYPES.has(edge.type)) continue;
2567
2999
  if (graph.hasEdge((0, import_types4.observedEdgeId)(edge.source, edge.target, edge.type))) continue;
2568
3000
  upsertInferredEdge(graph, edge.type, edge.source, edge.target, ts);
2569
3001
  if (!visited.has(edge.target)) {
@@ -2596,6 +3028,16 @@ async function appendErrorEvent(ctx, ev) {
2596
3028
  await import_node_fs5.promises.mkdir(import_node_path5.default.dirname(ctx.errorsPath), { recursive: true });
2597
3029
  await import_node_fs5.promises.appendFile(ctx.errorsPath, JSON.stringify(ev) + "\n", "utf8");
2598
3030
  }
3031
+ function incidentAffectedNode(span, graph, scanPath) {
3032
+ const sid = (0, import_types4.serviceId)(span.service, span.env);
3033
+ const serviceNode = graph && graph.hasNode(sid) ? graph.getNodeAttributes(sid) : void 0;
3034
+ const callSite = callSiteFromSpan(span, serviceNode, scanPath);
3035
+ if (callSite) {
3036
+ const relPath = graph ? reconcileObservedRelPath(graph, span.service, callSite.relPath) : callSite.relPath;
3037
+ return (0, import_types4.fileId)(span.service, relPath);
3038
+ }
3039
+ return sid;
3040
+ }
2599
3041
  function sanitizeAttributes(attrs) {
2600
3042
  const out = {};
2601
3043
  for (const [k, v] of Object.entries(attrs)) {
@@ -2626,6 +3068,22 @@ async function recordFailingResponseIncident(ctx, span, affectedNode, timestamp,
2626
3068
  };
2627
3069
  await appendErrorEvent(ctx, ev);
2628
3070
  }
3071
+ async function recordExceptionIncident(ctx, span, ts) {
3072
+ const attrs = sanitizeAttributes(span.attributes);
3073
+ const ev = {
3074
+ id: `${span.traceId}:${span.spanId}`,
3075
+ timestamp: ts,
3076
+ service: span.service,
3077
+ traceId: span.traceId,
3078
+ spanId: span.spanId,
3079
+ errorMessage: incidentMessage(span),
3080
+ ...span.exception?.type ? { exceptionType: span.exception.type } : {},
3081
+ ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
3082
+ ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
3083
+ affectedNode: incidentAffectedNode(span, ctx.graph, ctx.scanPath)
3084
+ };
3085
+ await appendErrorEvent(ctx, ev);
3086
+ }
2629
3087
  async function advance4xxBurst(ctx, span, affectedNode, ts, nowMs, status2) {
2630
3088
  const { threshold, windowMs } = loadIncidentThresholdsFromEnv();
2631
3089
  if (!ctx.burstState) ctx.burstState = /* @__PURE__ */ new Map();
@@ -2682,7 +3140,10 @@ async function handleSpan(ctx, span) {
2682
3140
  const callSite = callSiteFromSpan(span, sourceServiceNode, ctx.scanPath);
2683
3141
  cacheSpanService(span, nowMs, callSite);
2684
3142
  const observedSource = () => callSite ? ensureObservedFileNode(ctx.graph, span.service, sourceId, callSite) : sourceId;
2685
- const callSiteEvidence = callSite ? { file: callSite.relPath, ...callSite.line !== void 0 ? { line: callSite.line } : {} } : void 0;
3143
+ const callSiteEvidence = callSite ? {
3144
+ file: reconcileObservedRelPath(ctx.graph, span.service, callSite.relPath),
3145
+ ...callSite.line !== void 0 ? { line: callSite.line } : {}
3146
+ } : void 0;
2686
3147
  let affectedNode = sourceId;
2687
3148
  const mintsFromCallerSide = spanMintsObservedEdge(span.kind);
2688
3149
  if (span.dbSystem) {
@@ -2704,7 +3165,7 @@ async function handleSpan(ctx, span) {
2704
3165
  } else {
2705
3166
  const host = pickAddress(span);
2706
3167
  let resolvedViaAddress = false;
2707
- if (mintsFromCallerSide && host && host !== span.service) {
3168
+ if (mintsFromCallerSide && host && host !== span.service && !isLoopbackHost(host)) {
2708
3169
  const targetId = resolveServiceId(ctx.graph, host, env);
2709
3170
  if (targetId && targetId !== sourceId) {
2710
3171
  upsertObservedEdge(
@@ -2739,7 +3200,11 @@ async function handleSpan(ctx, span) {
2739
3200
  const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
2740
3201
  const fallbackSource = parent.callSite ? ensureObservedFileNode(ctx.graph, parent.service, parentId, parent.callSite) : parentId;
2741
3202
  const fallbackEvidence = parent.callSite ? {
2742
- file: parent.callSite.relPath,
3203
+ file: reconcileObservedRelPath(
3204
+ ctx.graph,
3205
+ parent.service,
3206
+ parent.callSite.relPath
3207
+ ),
2743
3208
  ...parent.callSite.line !== void 0 ? { line: parent.callSite.line } : {}
2744
3209
  } : void 0;
2745
3210
  upsertObservedEdge(
@@ -2764,7 +3229,7 @@ async function handleSpan(ctx, span) {
2764
3229
  service: span.service,
2765
3230
  traceId: span.traceId,
2766
3231
  spanId: span.spanId,
2767
- errorMessage: span.exception?.message ?? "unknown error",
3232
+ errorMessage: incidentMessage(span),
2768
3233
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
2769
3234
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
2770
3235
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
@@ -2775,7 +3240,9 @@ async function handleSpan(ctx, span) {
2775
3240
  }
2776
3241
  if (span.statusCode !== 2) {
2777
3242
  const status2 = httpResponseStatus(span);
2778
- if (status2 !== void 0 && status2 >= 500) {
3243
+ if (span.exception) {
3244
+ await recordExceptionIncident(ctx, span, ts);
3245
+ } else if (status2 !== void 0 && status2 >= 500) {
2779
3246
  await recordFailingResponseIncident(ctx, span, sourceId, ts, status2, 1);
2780
3247
  } else if (status2 !== void 0 && status2 >= 400 && spanMintsObservedEdge(span.kind)) {
2781
3248
  await advance4xxBurst(ctx, span, sourceId, ts, nowMs, status2);
@@ -2940,12 +3407,43 @@ function startStalenessLoop(graph, options = {}) {
2940
3407
  async function readErrorEvents(errorsPath) {
2941
3408
  try {
2942
3409
  const raw = await import_node_fs5.promises.readFile(errorsPath, "utf8");
2943
- return raw.split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
3410
+ const events = raw.split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
3411
+ return dedupeIncidents(events);
2944
3412
  } catch (err) {
2945
3413
  if (err.code === "ENOENT") return [];
2946
3414
  throw err;
2947
3415
  }
2948
3416
  }
3417
+ function isSynthesizedHttpIncident(ev) {
3418
+ if (ev.exceptionType || ev.exceptionStacktrace) return false;
3419
+ if (ev.errorType) return false;
3420
+ if (!ev.attributes) return false;
3421
+ const synth = httpFailureMessageFromAttrs(ev.attributes);
3422
+ return synth !== void 0 && synth === ev.errorMessage;
3423
+ }
3424
+ function dedupeIncidents(events) {
3425
+ const seen = /* @__PURE__ */ new Set();
3426
+ const once = [];
3427
+ for (const ev of events) {
3428
+ const key = ev.id ?? (ev.traceId && ev.spanId ? `${ev.traceId}:${ev.spanId}` : void 0);
3429
+ if (key === void 0) {
3430
+ once.push(ev);
3431
+ continue;
3432
+ }
3433
+ if (seen.has(key)) continue;
3434
+ seen.add(key);
3435
+ once.push(ev);
3436
+ }
3437
+ const groupKey = (ev) => `${ev.traceId}\0${ev.affectedNode}`;
3438
+ const hasRealFailure = /* @__PURE__ */ new Set();
3439
+ for (const ev of once) {
3440
+ if (ev.traceId && !isSynthesizedHttpIncident(ev)) hasRealFailure.add(groupKey(ev));
3441
+ }
3442
+ return once.filter((ev) => {
3443
+ if (!ev.traceId || !isSynthesizedHttpIncident(ev)) return true;
3444
+ return !hasRealFailure.has(groupKey(ev));
3445
+ });
3446
+ }
2949
3447
  function mergeSnapshot(graph, snapshot) {
2950
3448
  const exported = snapshot.graph;
2951
3449
  let nodesAdded = 0;
@@ -3026,10 +3524,17 @@ function isConfigFile(name) {
3026
3524
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
3027
3525
  if (name === ".env" || name.startsWith(".env.")) {
3028
3526
  if (isEnvTemplateFile(name)) return { match: false, fileType: "" };
3527
+ if (isNeatAuthoredEnvFile(name)) return { match: false, fileType: "" };
3029
3528
  return { match: true, fileType: "env" };
3030
3529
  }
3031
3530
  return { match: false, fileType: "" };
3032
3531
  }
3532
+ function isNeatAuthoredEnvFile(name) {
3533
+ return name === ".env.neat";
3534
+ }
3535
+ function isNeatAuthoredSourceFile(name) {
3536
+ return /^otel-init\.(?:js|cjs|mjs|ts|tsx)$/i.test(name);
3537
+ }
3033
3538
  function isTestPath(filePath) {
3034
3539
  const normalised = filePath.replace(/\\/g, "/");
3035
3540
  const segments = normalised.split("/");
@@ -3723,7 +4228,9 @@ async function walkSourceFiles(dir) {
3723
4228
  if (IGNORED_DIRS.has(entry.name)) continue;
3724
4229
  if (await isPythonVenvDir(full)) continue;
3725
4230
  await walk(full);
3726
- } else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(import_node_path12.default.extname(entry.name))) {
4231
+ } else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(import_node_path12.default.extname(entry.name)) && // Skip NEAT's own generated `otel-init.*` bootstrap — extracting it
4232
+ // would attribute our instrumentation imports to the user's service.
4233
+ !isNeatAuthoredSourceFile(entry.name)) {
3727
4234
  out.push(full);
3728
4235
  }
3729
4236
  }
@@ -3893,11 +4400,28 @@ function collectJsImports(node, out) {
3893
4400
  if (child) collectJsImports(child, out);
3894
4401
  }
3895
4402
  }
4403
+ function collectImportedNames(node, out) {
4404
+ if (node.type === "aliased_import") {
4405
+ const nameNode = node.childForFieldName("name");
4406
+ if (nameNode) out.push(nameNode.text);
4407
+ return;
4408
+ }
4409
+ if (node.type === "dotted_name") {
4410
+ out.push(node.text);
4411
+ return;
4412
+ }
4413
+ for (let i = 0; i < node.namedChildCount; i++) {
4414
+ const child = node.namedChild(i);
4415
+ if (child) collectImportedNames(child, out);
4416
+ }
4417
+ }
3896
4418
  function collectPyImports(node, out) {
3897
4419
  if (node.type === "import_from_statement") {
3898
4420
  let level = 0;
3899
4421
  let modulePath = "";
4422
+ const names = [];
3900
4423
  let pastFrom = false;
4424
+ let pastImport = false;
3901
4425
  for (let i = 0; i < node.childCount; i++) {
3902
4426
  const child = node.child(i);
3903
4427
  if (!child) continue;
@@ -3905,26 +4429,30 @@ function collectPyImports(node, out) {
3905
4429
  if (child.type === "from") pastFrom = true;
3906
4430
  continue;
3907
4431
  }
3908
- if (child.type === "import") break;
3909
- if (child.type === "relative_import") {
3910
- for (let j = 0; j < child.childCount; j++) {
3911
- const rc = child.child(j);
3912
- if (!rc) continue;
3913
- if (rc.type === "import_prefix") {
3914
- for (let k = 0; k < rc.childCount; k++) {
3915
- if (rc.child(k)?.type === ".") level++;
3916
- }
3917
- } else if (rc.type === "dotted_name") modulePath = rc.text;
4432
+ if (!pastImport) {
4433
+ if (child.type === "import") {
4434
+ pastImport = true;
4435
+ continue;
3918
4436
  }
3919
- break;
3920
- }
3921
- if (child.type === "dotted_name") {
3922
- modulePath = child.text;
3923
- break;
4437
+ if (child.type === "relative_import") {
4438
+ for (let j = 0; j < child.childCount; j++) {
4439
+ const rc = child.child(j);
4440
+ if (!rc) continue;
4441
+ if (rc.type === "import_prefix") {
4442
+ for (let k = 0; k < rc.childCount; k++) {
4443
+ if (rc.child(k)?.type === ".") level++;
4444
+ }
4445
+ } else if (rc.type === "dotted_name") modulePath = rc.text;
4446
+ }
4447
+ } else if (child.type === "dotted_name") {
4448
+ modulePath = child.text;
4449
+ }
4450
+ continue;
3924
4451
  }
4452
+ collectImportedNames(child, names);
3925
4453
  }
3926
4454
  if (level > 0 || modulePath) {
3927
- out.push({ modulePath, level, line: node.startPosition.row + 1, snippet: clipSnippet(node.text) });
4455
+ out.push({ modulePath, level, names, line: node.startPosition.row + 1, snippet: clipSnippet(node.text) });
3928
4456
  }
3929
4457
  }
3930
4458
  for (let i = 0; i < node.namedChildCount; i++) {
@@ -4026,8 +4554,6 @@ async function resolveJsImport(specifier, importerDir, serviceDir, tsPaths) {
4026
4554
  return null;
4027
4555
  }
4028
4556
  async function resolvePyImport(imp, importerPath, serviceDir) {
4029
- if (!imp.modulePath) return null;
4030
- const relPath = imp.modulePath.split(".").join("/");
4031
4557
  let baseDir;
4032
4558
  if (imp.level > 0) {
4033
4559
  baseDir = import_node_path14.default.dirname(importerPath);
@@ -4035,13 +4561,30 @@ async function resolvePyImport(imp, importerPath, serviceDir) {
4035
4561
  } else {
4036
4562
  baseDir = serviceDir;
4037
4563
  }
4038
- const candidates = [import_node_path14.default.join(baseDir, `${relPath}.py`), import_node_path14.default.join(baseDir, relPath, "__init__.py")];
4039
- for (const candidate of candidates) {
4040
- if (isWithinServiceDir(candidate, serviceDir) && await fileExists2(candidate)) {
4041
- return toPosix2(import_node_path14.default.relative(serviceDir, candidate));
4564
+ const moduleBase = imp.modulePath ? import_node_path14.default.join(baseDir, imp.modulePath.split(".").join("/")) : baseDir;
4565
+ const resolved = /* @__PURE__ */ new Set();
4566
+ let needModuleFile = imp.names.length === 0;
4567
+ for (const name of imp.names) {
4568
+ const submoduleFile = import_node_path14.default.join(moduleBase, `${name}.py`);
4569
+ const subpackageInit = import_node_path14.default.join(moduleBase, name, "__init__.py");
4570
+ if (isWithinServiceDir(submoduleFile, serviceDir) && await fileExists2(submoduleFile)) {
4571
+ resolved.add(toPosix2(import_node_path14.default.relative(serviceDir, submoduleFile)));
4572
+ } else if (isWithinServiceDir(subpackageInit, serviceDir) && await fileExists2(subpackageInit)) {
4573
+ resolved.add(toPosix2(import_node_path14.default.relative(serviceDir, subpackageInit)));
4574
+ } else {
4575
+ needModuleFile = true;
4576
+ }
4577
+ }
4578
+ if (needModuleFile) {
4579
+ const moduleFileCandidates = imp.modulePath ? [`${moduleBase}.py`, import_node_path14.default.join(moduleBase, "__init__.py")] : [import_node_path14.default.join(moduleBase, "__init__.py")];
4580
+ for (const candidate of moduleFileCandidates) {
4581
+ if (isWithinServiceDir(candidate, serviceDir) && await fileExists2(candidate)) {
4582
+ resolved.add(toPosix2(import_node_path14.default.relative(serviceDir, candidate)));
4583
+ break;
4584
+ }
4042
4585
  }
4043
4586
  }
4044
- return null;
4587
+ return [...resolved];
4045
4588
  }
4046
4589
  function emitImportEdge(graph, serviceName, importerFileId, importerRelPath, importeeRelPath, line, snippet2) {
4047
4590
  const importeeFileId = (0, import_types9.fileId)(serviceName, importeeRelPath);
@@ -4082,17 +4625,18 @@ async function addImports(graph, services) {
4082
4625
  continue;
4083
4626
  }
4084
4627
  for (const imp of pyImports) {
4085
- const resolved = await resolvePyImport(imp, file.path, service.dir);
4086
- if (!resolved) continue;
4087
- edgesAdded += emitImportEdge(
4088
- graph,
4089
- service.pkg.name,
4090
- importerFileId,
4091
- relFile,
4092
- resolved,
4093
- imp.line,
4094
- imp.snippet
4095
- );
4628
+ const resolvedPaths = await resolvePyImport(imp, file.path, service.dir);
4629
+ for (const resolved of resolvedPaths) {
4630
+ edgesAdded += emitImportEdge(
4631
+ graph,
4632
+ service.pkg.name,
4633
+ importerFileId,
4634
+ relFile,
4635
+ resolved,
4636
+ imp.line,
4637
+ imp.snippet
4638
+ );
4639
+ }
4096
4640
  }
4097
4641
  continue;
4098
4642
  }
@@ -4728,6 +5272,36 @@ async function addDatabasesAndCompat(graph, services, scanPath) {
4728
5272
  edgesAdded++;
4729
5273
  }
4730
5274
  }
5275
+ if (allConfigs.length === 1) {
5276
+ const primary = allConfigs[0];
5277
+ service.node.dbConnectionTarget = primary.port ? `${primary.host}:${primary.port}` : primary.host;
5278
+ const relPath = import_node_path22.default.relative(scanPath, primary.sourceFile);
5279
+ const cfgId = (0, import_types10.configId)(relPath);
5280
+ if (!graph.hasNode(cfgId)) {
5281
+ const cfgNode = {
5282
+ id: cfgId,
5283
+ type: import_types10.NodeType.ConfigNode,
5284
+ name: import_node_path22.default.basename(primary.sourceFile),
5285
+ path: relPath,
5286
+ fileType: isConfigFile(import_node_path22.default.basename(primary.sourceFile)).fileType || "config"
5287
+ };
5288
+ graph.addNode(cfgId, cfgNode);
5289
+ nodesAdded++;
5290
+ }
5291
+ const cfgEdge = {
5292
+ id: (0, import_types5.extractedEdgeId)(service.node.id, cfgId, import_types10.EdgeType.CONFIGURED_BY),
5293
+ source: service.node.id,
5294
+ target: cfgId,
5295
+ type: import_types10.EdgeType.CONFIGURED_BY,
5296
+ provenance: import_types10.Provenance.EXTRACTED,
5297
+ confidence: (0, import_types10.confidenceForExtracted)("structural"),
5298
+ evidence: { file: toPosix2(relPath) }
5299
+ };
5300
+ if (!graph.hasEdge(cfgEdge.id)) {
5301
+ graph.addEdgeWithKey(cfgEdge.id, cfgEdge.source, cfgEdge.target, cfgEdge);
5302
+ edgesAdded++;
5303
+ }
5304
+ }
4731
5305
  attachIncompatibilities(service, allConfigs);
4732
5306
  if (graph.hasNode(service.node.id)) {
4733
5307
  const current = graph.getNodeAttributes(service.node.id);
@@ -4739,6 +5313,9 @@ async function addDatabasesAndCompat(graph, services, scanPath) {
4739
5313
  if (!service.node.incompatibilities || service.node.incompatibilities.length === 0) {
4740
5314
  delete updated.incompatibilities;
4741
5315
  }
5316
+ if (!service.node.dbConnectionTarget) {
5317
+ delete updated.dbConnectionTarget;
5318
+ }
4742
5319
  graph.replaceNodeAttributes(service.node.id, updated);
4743
5320
  }
4744
5321
  }
@@ -4916,7 +5493,7 @@ async function addHttpCallEdges(graph, services) {
4916
5493
  const dedupKey = `${relFile}|${targetId}`;
4917
5494
  if (seen.has(dedupKey)) continue;
4918
5495
  seen.add(dedupKey);
4919
- const confidence = (0, import_types12.confidenceForExtracted)("hostname-shape-match");
5496
+ const confidence = (0, import_types12.confidenceForExtracted)("url-literal-service-target");
4920
5497
  const ev = {
4921
5498
  file: relFile,
4922
5499
  line: site.line,
@@ -4936,7 +5513,7 @@ async function addHttpCallEdges(graph, services) {
4936
5513
  target: targetId,
4937
5514
  type: import_types12.EdgeType.CALLS,
4938
5515
  confidence,
4939
- confidenceKind: "hostname-shape-match",
5516
+ confidenceKind: "url-literal-service-target",
4940
5517
  evidence: ev
4941
5518
  });
4942
5519
  continue;
@@ -5442,19 +6019,29 @@ init_cjs_shims();
5442
6019
  var import_node_path31 = __toESM(require("path"), 1);
5443
6020
  var import_node_fs17 = require("fs");
5444
6021
  var import_types21 = require("@neat.is/types");
5445
- function runtimeImage(content) {
5446
- const lines = content.split("\n");
5447
- let last = null;
5448
- for (const raw of lines) {
6022
+ function readDockerfile(content) {
6023
+ let image = null;
6024
+ const ports = [];
6025
+ let cmd = null;
6026
+ let entrypoint = null;
6027
+ for (const raw of content.split("\n")) {
5449
6028
  const line = raw.trim();
5450
6029
  if (!line || line.startsWith("#")) continue;
5451
- if (!/^from\s+/i.test(line)) continue;
5452
- const tokens = line.split(/\s+/);
5453
- const image = tokens[1];
5454
- if (!image || image.toLowerCase() === "scratch") continue;
5455
- last = image;
6030
+ if (/^from\s+/i.test(line)) {
6031
+ const candidate = line.split(/\s+/)[1];
6032
+ if (candidate && candidate.toLowerCase() !== "scratch") image = candidate;
6033
+ } else if (/^expose\s+/i.test(line)) {
6034
+ for (const token of line.split(/\s+/).slice(1)) {
6035
+ const port = Number.parseInt(token.split("/")[0], 10);
6036
+ if (Number.isInteger(port) && !ports.includes(port)) ports.push(port);
6037
+ }
6038
+ } else if (/^entrypoint\s+/i.test(line)) {
6039
+ entrypoint = line;
6040
+ } else if (/^cmd\s+/i.test(line)) {
6041
+ cmd = line;
6042
+ }
5456
6043
  }
5457
- return last;
6044
+ return { image, ports, entrypoint: entrypoint ?? cmd };
5458
6045
  }
5459
6046
  async function addDockerfileRuntimes(graph, services, scanPath) {
5460
6047
  let nodesAdded = 0;
@@ -5473,14 +6060,15 @@ async function addDockerfileRuntimes(graph, services, scanPath) {
5473
6060
  );
5474
6061
  continue;
5475
6062
  }
5476
- const image = runtimeImage(content);
5477
- if (!image) continue;
5478
- const node = makeInfraNode("container-image", image);
6063
+ const facts = readDockerfile(content);
6064
+ if (!facts.image) continue;
6065
+ const node = makeInfraNode("container-image", facts.image);
5479
6066
  if (!graph.hasNode(node.id)) {
5480
6067
  graph.addNode(node.id, node);
5481
6068
  nodesAdded++;
5482
6069
  }
5483
6070
  const relDockerfile = toPosix2(import_node_path31.default.relative(service.dir, dockerfilePath));
6071
+ const evidenceFile = toPosix2(import_node_path31.default.relative(scanPath, dockerfilePath));
5484
6072
  const { fileNodeId, nodesAdded: fn, edgesAdded: fe } = ensureFileNode(
5485
6073
  graph,
5486
6074
  service.pkg.name,
@@ -5499,12 +6087,33 @@ async function addDockerfileRuntimes(graph, services, scanPath) {
5499
6087
  provenance: import_types21.Provenance.EXTRACTED,
5500
6088
  confidence: (0, import_types21.confidenceForExtracted)("structural"),
5501
6089
  evidence: {
5502
- file: toPosix2(import_node_path31.default.relative(scanPath, dockerfilePath))
6090
+ file: evidenceFile,
6091
+ ...facts.entrypoint ? { snippet: facts.entrypoint.slice(0, 120) } : {}
5503
6092
  }
5504
6093
  };
5505
6094
  graph.addEdgeWithKey(edgeId, edge.source, edge.target, edge);
5506
6095
  edgesAdded++;
5507
6096
  }
6097
+ for (const port of facts.ports) {
6098
+ const portNode = makeInfraNode("port", String(port));
6099
+ if (!graph.hasNode(portNode.id)) {
6100
+ graph.addNode(portNode.id, portNode);
6101
+ nodesAdded++;
6102
+ }
6103
+ const portEdgeId = (0, import_types5.extractedEdgeId)(fileNodeId, portNode.id, import_types21.EdgeType.CONNECTS_TO);
6104
+ if (graph.hasEdge(portEdgeId)) continue;
6105
+ const portEdge = {
6106
+ id: portEdgeId,
6107
+ source: fileNodeId,
6108
+ target: portNode.id,
6109
+ type: import_types21.EdgeType.CONNECTS_TO,
6110
+ provenance: import_types21.Provenance.EXTRACTED,
6111
+ confidence: (0, import_types21.confidenceForExtracted)("structural"),
6112
+ evidence: { file: evidenceFile, snippet: `EXPOSE ${port}` }
6113
+ };
6114
+ graph.addEdgeWithKey(portEdgeId, portEdge.source, portEdge.target, portEdge);
6115
+ edgesAdded++;
6116
+ }
5508
6117
  }
5509
6118
  return { nodesAdded, edgesAdded };
5510
6119
  }
@@ -5513,7 +6122,9 @@ async function addDockerfileRuntimes(graph, services, scanPath) {
5513
6122
  init_cjs_shims();
5514
6123
  var import_node_fs18 = require("fs");
5515
6124
  var import_node_path32 = __toESM(require("path"), 1);
6125
+ var import_types22 = require("@neat.is/types");
5516
6126
  var RESOURCE_RE = /resource\s+"(aws_[A-Za-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/g;
6127
+ var REFERENCE_RE = /(?<![\w.])(aws_[A-Za-z0-9_]+)\.([A-Za-z0-9_-]+)/g;
5517
6128
  async function walkTfFiles(start, depth = 0, max = 5) {
5518
6129
  if (depth > max) return [];
5519
6130
  const out = [];
@@ -5530,24 +6141,86 @@ async function walkTfFiles(start, depth = 0, max = 5) {
5530
6141
  }
5531
6142
  return out;
5532
6143
  }
6144
+ function blockBody(content, from) {
6145
+ const open = content.indexOf("{", from);
6146
+ if (open === -1) return null;
6147
+ let depth = 0;
6148
+ for (let i = open; i < content.length; i++) {
6149
+ const ch = content[i];
6150
+ if (ch === "{") depth++;
6151
+ else if (ch === "}") {
6152
+ depth--;
6153
+ if (depth === 0) return { body: content.slice(open + 1, i), offset: open + 1 };
6154
+ }
6155
+ }
6156
+ return null;
6157
+ }
6158
+ function lineAt(content, index) {
6159
+ let line = 1;
6160
+ for (let i = 0; i < index && i < content.length; i++) {
6161
+ if (content[i] === "\n") line++;
6162
+ }
6163
+ return line;
6164
+ }
5533
6165
  async function addTerraformResources(graph, scanPath) {
5534
6166
  let nodesAdded = 0;
6167
+ let edgesAdded = 0;
5535
6168
  const files = await walkTfFiles(scanPath);
5536
6169
  for (const file of files) {
5537
6170
  const content = await import_node_fs18.promises.readFile(file, "utf8");
6171
+ const evidenceFile = toPosix2(import_node_path32.default.relative(scanPath, file));
6172
+ const resources = [];
6173
+ const byKey = /* @__PURE__ */ new Map();
5538
6174
  RESOURCE_RE.lastIndex = 0;
5539
6175
  let m;
5540
6176
  while ((m = RESOURCE_RE.exec(content)) !== null) {
5541
- const kind = m[1];
6177
+ const type = m[1];
5542
6178
  const name = m[2];
5543
- const node = makeInfraNode(kind, name, "aws");
6179
+ const node = makeInfraNode(type, name, "aws");
5544
6180
  if (!graph.hasNode(node.id)) {
5545
6181
  graph.addNode(node.id, node);
5546
6182
  nodesAdded++;
5547
6183
  }
6184
+ const span = blockBody(content, RESOURCE_RE.lastIndex);
6185
+ const resource = {
6186
+ type,
6187
+ name,
6188
+ nodeId: node.id,
6189
+ body: span?.body ?? "",
6190
+ bodyOffset: span?.offset ?? RESOURCE_RE.lastIndex
6191
+ };
6192
+ resources.push(resource);
6193
+ byKey.set(`${type}.${name}`, resource);
6194
+ }
6195
+ for (const resource of resources) {
6196
+ const seen = /* @__PURE__ */ new Set();
6197
+ REFERENCE_RE.lastIndex = 0;
6198
+ let ref;
6199
+ while ((ref = REFERENCE_RE.exec(resource.body)) !== null) {
6200
+ const key = `${ref[1]}.${ref[2]}`;
6201
+ if (key === `${resource.type}.${resource.name}`) continue;
6202
+ const target = byKey.get(key);
6203
+ if (!target) continue;
6204
+ if (seen.has(target.nodeId)) continue;
6205
+ seen.add(target.nodeId);
6206
+ const edgeId = (0, import_types5.extractedEdgeId)(resource.nodeId, target.nodeId, import_types22.EdgeType.DEPENDS_ON);
6207
+ if (graph.hasEdge(edgeId)) continue;
6208
+ const line = lineAt(content, resource.bodyOffset + ref.index);
6209
+ const edge = {
6210
+ id: edgeId,
6211
+ source: resource.nodeId,
6212
+ target: target.nodeId,
6213
+ type: import_types22.EdgeType.DEPENDS_ON,
6214
+ provenance: import_types22.Provenance.EXTRACTED,
6215
+ confidence: (0, import_types22.confidenceForExtracted)("structural"),
6216
+ evidence: { file: evidenceFile, line, snippet: key }
6217
+ };
6218
+ graph.addEdgeWithKey(edgeId, edge.source, edge.target, edge);
6219
+ edgesAdded++;
6220
+ }
5548
6221
  }
5549
6222
  }
5550
- return { nodesAdded, edgesAdded: 0 };
6223
+ return { nodesAdded, edgesAdded };
5551
6224
  }
5552
6225
 
5553
6226
  // src/extract/infra/k8s.ts
@@ -5625,11 +6298,11 @@ var import_node_path35 = __toESM(require("path"), 1);
5625
6298
  init_cjs_shims();
5626
6299
  var import_node_fs20 = require("fs");
5627
6300
  var import_node_path34 = __toESM(require("path"), 1);
5628
- var import_types22 = require("@neat.is/types");
6301
+ var import_types23 = require("@neat.is/types");
5629
6302
  function dropOrphanedFileNodes(graph) {
5630
6303
  const orphans = [];
5631
6304
  graph.forEachNode((id, attrs) => {
5632
- if (attrs.type !== import_types22.NodeType.FileNode) return;
6305
+ if (attrs.type !== import_types23.NodeType.FileNode) return;
5633
6306
  if (graph.inboundEdges(id).length === 0 && graph.outboundEdges(id).length === 0) {
5634
6307
  orphans.push(id);
5635
6308
  }
@@ -5642,7 +6315,7 @@ function retireExtractedEdgesByMissingFile(graph, scanPath, serviceDirs = []) {
5642
6315
  const bases = [scanPath, ...serviceDirs];
5643
6316
  graph.forEachEdge((id, attrs) => {
5644
6317
  const edge = attrs;
5645
- if (edge.provenance !== import_types22.Provenance.EXTRACTED) return;
6318
+ if (edge.provenance !== import_types23.Provenance.EXTRACTED) return;
5646
6319
  const evidenceFile = edge.evidence?.file;
5647
6320
  if (!evidenceFile) return;
5648
6321
  if (import_node_path34.default.isAbsolute(evidenceFile)) {
@@ -5802,7 +6475,7 @@ function canonicalJson(value) {
5802
6475
  init_cjs_shims();
5803
6476
  var import_node_fs22 = require("fs");
5804
6477
  var import_node_path36 = __toESM(require("path"), 1);
5805
- var import_types23 = require("@neat.is/types");
6478
+ var import_types24 = require("@neat.is/types");
5806
6479
  var SCHEMA_VERSION = 4;
5807
6480
  function migrateV1ToV2(payload) {
5808
6481
  const nodes = payload.graph.nodes;
@@ -5824,12 +6497,12 @@ function migrateV2ToV3(payload) {
5824
6497
  for (const edge of edges) {
5825
6498
  const attrs = edge.attributes;
5826
6499
  if (!attrs || attrs.provenance !== "FRONTIER") continue;
5827
- attrs.provenance = import_types23.Provenance.OBSERVED;
6500
+ attrs.provenance = import_types24.Provenance.OBSERVED;
5828
6501
  const type = typeof attrs.type === "string" ? attrs.type : void 0;
5829
6502
  const source = typeof attrs.source === "string" ? attrs.source : void 0;
5830
6503
  const target = typeof attrs.target === "string" ? attrs.target : void 0;
5831
6504
  if (type && source && target) {
5832
- const newId = (0, import_types23.observedEdgeId)(source, target, type);
6505
+ const newId = (0, import_types24.observedEdgeId)(source, target, type);
5833
6506
  attrs.id = newId;
5834
6507
  if (edge.key) edge.key = newId;
5835
6508
  }
@@ -5978,7 +6651,7 @@ init_cjs_shims();
5978
6651
  var import_node_fs23 = require("fs");
5979
6652
  var import_node_os3 = __toESM(require("os"), 1);
5980
6653
  var import_node_path38 = __toESM(require("path"), 1);
5981
- var import_types24 = require("@neat.is/types");
6654
+ var import_types25 = require("@neat.is/types");
5982
6655
  function neatHome() {
5983
6656
  const override = process.env.NEAT_HOME;
5984
6657
  if (override && override.length > 0) return import_node_path38.default.resolve(override);
@@ -5999,7 +6672,7 @@ async function readRegistry() {
5999
6672
  throw err;
6000
6673
  }
6001
6674
  const parsed = JSON.parse(raw);
6002
- return import_types24.RegistryFileSchema.parse(parsed);
6675
+ return import_types25.RegistryFileSchema.parse(parsed);
6003
6676
  }
6004
6677
  async function getProject(name) {
6005
6678
  const reg = await readRegistry();
@@ -6200,6 +6873,18 @@ function registerRoutes(scope, ctx) {
6200
6873
  }
6201
6874
  return getTransitiveDependencies(proj.graph, nodeId, depth);
6202
6875
  });
6876
+ scope.get(
6877
+ "/graph/observed-dependencies/:nodeId",
6878
+ async (req, reply) => {
6879
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6880
+ if (!proj) return;
6881
+ const { nodeId } = req.params;
6882
+ if (!proj.graph.hasNode(nodeId)) {
6883
+ return reply.code(404).send({ error: "node not found", id: nodeId });
6884
+ }
6885
+ return getObservedDependencies(proj.graph, nodeId);
6886
+ }
6887
+ );
6203
6888
  scope.get("/graph/divergences", async (req, reply) => {
6204
6889
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6205
6890
  if (!proj) return;
@@ -6208,11 +6893,11 @@ function registerRoutes(scope, ctx) {
6208
6893
  const candidates = req.query.type.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
6209
6894
  const parsed = [];
6210
6895
  for (const c of candidates) {
6211
- const r = import_types25.DivergenceTypeSchema.safeParse(c);
6896
+ const r = import_types26.DivergenceTypeSchema.safeParse(c);
6212
6897
  if (!r.success) {
6213
6898
  return reply.code(400).send({
6214
6899
  error: `unknown divergence type "${c}"`,
6215
- allowed: import_types25.DivergenceTypeSchema.options
6900
+ allowed: import_types26.DivergenceTypeSchema.options
6216
6901
  });
6217
6902
  }
6218
6903
  parsed.push(r.data);
@@ -6260,23 +6945,29 @@ function registerRoutes(scope, ctx) {
6260
6945
  const sliced = ordered.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 50);
6261
6946
  return { count: sliced.length, total, events: sliced };
6262
6947
  });
6948
+ const incidentHistoryHandler = async (req, reply) => {
6949
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6950
+ if (!proj) return;
6951
+ const { nodeId } = req.params;
6952
+ if (!proj.graph.hasNode(nodeId)) {
6953
+ reply.code(404).send({ error: "node not found", id: nodeId });
6954
+ return;
6955
+ }
6956
+ const epath = errorsPathFor(proj);
6957
+ if (!epath) return { count: 0, total: 0, events: [] };
6958
+ const events = await readErrorEvents(epath);
6959
+ const filtered = events.filter(
6960
+ (e) => e.affectedNode === nodeId || e.service === nodeId.replace(/^service:/, "")
6961
+ );
6962
+ return { count: filtered.length, total: filtered.length, events: filtered };
6963
+ };
6263
6964
  scope.get(
6264
6965
  "/incidents/:nodeId",
6265
- async (req, reply) => {
6266
- const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6267
- if (!proj) return;
6268
- const { nodeId } = req.params;
6269
- if (!proj.graph.hasNode(nodeId)) {
6270
- return reply.code(404).send({ error: "node not found", id: nodeId });
6271
- }
6272
- const epath = errorsPathFor(proj);
6273
- if (!epath) return { count: 0, total: 0, events: [] };
6274
- const events = await readErrorEvents(epath);
6275
- const filtered = events.filter(
6276
- (e) => e.affectedNode === nodeId || e.service === nodeId.replace(/^service:/, "")
6277
- );
6278
- return { count: filtered.length, total: filtered.length, events: filtered };
6279
- }
6966
+ incidentHistoryHandler
6967
+ );
6968
+ scope.get(
6969
+ "/graph/incident-history/:nodeId",
6970
+ incidentHistoryHandler
6280
6971
  );
6281
6972
  scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
6282
6973
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
@@ -6285,16 +6976,16 @@ function registerRoutes(scope, ctx) {
6285
6976
  if (!proj.graph.hasNode(nodeId)) {
6286
6977
  return reply.code(404).send({ error: "node not found", id: nodeId });
6287
6978
  }
6288
- let errorEvent;
6289
6979
  const epath = errorsPathFor(proj);
6290
- if (req.query.errorId && epath) {
6291
- const events = await readErrorEvents(epath);
6292
- errorEvent = events.find((e) => e.id === req.query.errorId);
6980
+ const incidents = epath ? await readErrorEvents(epath) : [];
6981
+ let errorEvent;
6982
+ if (req.query.errorId) {
6983
+ errorEvent = incidents.find((e) => e.id === req.query.errorId);
6293
6984
  if (!errorEvent) {
6294
6985
  return reply.code(404).send({ error: "error event not found", id: req.query.errorId });
6295
6986
  }
6296
6987
  }
6297
- const result = getRootCause(proj.graph, nodeId, errorEvent);
6988
+ const result = getRootCause(proj.graph, nodeId, errorEvent, incidents);
6298
6989
  if (!result) return reply.code(404).send({ error: "no root cause found", id: nodeId });
6299
6990
  return result;
6300
6991
  });
@@ -6425,7 +7116,7 @@ function registerRoutes(scope, ctx) {
6425
7116
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
6426
7117
  let violations = await log.readAll();
6427
7118
  if (req.query.severity) {
6428
- const sev = import_types25.PolicySeveritySchema.safeParse(req.query.severity);
7119
+ const sev = import_types26.PolicySeveritySchema.safeParse(req.query.severity);
6429
7120
  if (!sev.success) {
6430
7121
  return reply.code(400).send({
6431
7122
  error: "invalid severity",
@@ -6439,10 +7130,32 @@ function registerRoutes(scope, ctx) {
6439
7130
  }
6440
7131
  return { violations };
6441
7132
  });
7133
+ scope.get("/policies/applicable", async (req, reply) => {
7134
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
7135
+ if (!proj) return;
7136
+ const nodeId = req.query.node;
7137
+ if (!nodeId) {
7138
+ return reply.code(400).send({ error: 'missing required query param "node"' });
7139
+ }
7140
+ const policyPath = ctx.policyFilePathFor(proj);
7141
+ let policies = [];
7142
+ if (policyPath) {
7143
+ try {
7144
+ policies = await loadPolicyFile(policyPath);
7145
+ } catch (err) {
7146
+ return reply.code(400).send({
7147
+ error: "policy.json failed to parse",
7148
+ details: err.message
7149
+ });
7150
+ }
7151
+ }
7152
+ const applicable = selectApplicablePolicies(proj.graph, policies, nodeId);
7153
+ return { node: nodeId, applicable };
7154
+ });
6442
7155
  scope.post("/policies/check", async (req, reply) => {
6443
7156
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6444
7157
  if (!proj) return;
6445
- const parsed = import_types25.PoliciesCheckBodySchema.safeParse(req.body ?? {});
7158
+ const parsed = import_types26.PoliciesCheckBodySchema.safeParse(req.body ?? {});
6446
7159
  if (!parsed.success) {
6447
7160
  return reply.code(400).send({
6448
7161
  error: "invalid /policies/check body",