@neat.is/core 0.7.9 → 0.8.0-rc.1

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/neatd.cjs CHANGED
@@ -1400,7 +1400,7 @@ var rootCauseShapes = {
1400
1400
  [import_types.NodeType.FileNode]: fileRootCauseShape,
1401
1401
  [import_types.NodeType.SymbolNode]: symbolRootCauseShape
1402
1402
  };
1403
- function getRootCause(graph, errorNodeId, errorEvent, incidents) {
1403
+ function legacyRootCause(graph, errorNodeId, errorEvent, incidents) {
1404
1404
  if (!graph.hasNode(errorNodeId)) return null;
1405
1405
  const origin = graph.getNodeAttributes(errorNodeId);
1406
1406
  const shape = rootCauseShapes[origin.type];
@@ -1657,7 +1657,9 @@ function getObservedDependencies(graph, nodeId) {
1657
1657
  dependencies: [],
1658
1658
  observed: false,
1659
1659
  inboundObservedCount: 0,
1660
- hasExtractedOutbound: false
1660
+ hasExtractedOutbound: false,
1661
+ inboundVolume: 0,
1662
+ window: "lifetime"
1661
1663
  });
1662
1664
  }
1663
1665
  const attrs = graph.getNodeAttributes(nodeId);
@@ -1688,11 +1690,19 @@ function getObservedDependencies(graph, nodeId) {
1688
1690
  }
1689
1691
  }
1690
1692
  let inboundObservedCount = 0;
1693
+ let inboundVolume = 0;
1694
+ let inboundLastObserved;
1691
1695
  for (const tgt of scope) {
1692
1696
  for (const edgeId of graph.inboundEdges(tgt)) {
1693
1697
  const e = graph.getEdgeAttributes(edgeId);
1694
1698
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1695
- if (e.provenance === import_types.Provenance.OBSERVED) inboundObservedCount += 1;
1699
+ if (e.provenance === import_types.Provenance.OBSERVED) {
1700
+ inboundObservedCount += 1;
1701
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 1;
1702
+ if (e.lastObserved && (!inboundLastObserved || e.lastObserved > inboundLastObserved)) {
1703
+ inboundLastObserved = e.lastObserved;
1704
+ }
1705
+ }
1696
1706
  }
1697
1707
  }
1698
1708
  dependencies.sort(
@@ -1703,7 +1713,291 @@ function getObservedDependencies(graph, nodeId) {
1703
1713
  dependencies,
1704
1714
  observed: dependencies.length > 0 || inboundObservedCount > 0,
1705
1715
  inboundObservedCount,
1706
- hasExtractedOutbound
1716
+ hasExtractedOutbound,
1717
+ // The signal is cumulative, so the honest window label is "lifetime" (ADR-190).
1718
+ inboundVolume,
1719
+ window: "lifetime",
1720
+ ...inboundLastObserved ? { inboundLastObserved } : {}
1721
+ });
1722
+ }
1723
+ var SATURATION_P95_MS = 1e3;
1724
+ function nodeScope(graph, nodeId) {
1725
+ const scope = [nodeId];
1726
+ if (!graph.hasNode(nodeId)) return scope;
1727
+ const attrs = graph.getNodeAttributes(nodeId);
1728
+ if (attrs.type === import_types.NodeType.ServiceNode) {
1729
+ for (const edgeId of graph.outboundEdges(nodeId)) {
1730
+ const e = graph.getEdgeAttributes(edgeId);
1731
+ if (e.type !== import_types.EdgeType.CONTAINS) continue;
1732
+ const owned = graph.getNodeAttributes(e.target);
1733
+ if (owned.type === import_types.NodeType.FileNode) scope.push(e.target);
1734
+ }
1735
+ }
1736
+ return scope;
1737
+ }
1738
+ function incidentCountForNode(nodeId, incidents) {
1739
+ if (!incidents || incidents.length === 0) return 0;
1740
+ return incidents.filter((ev) => incidentMatchesNode(ev, nodeId)).length;
1741
+ }
1742
+ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1743
+ const scope = nodeScope(graph, nodeId);
1744
+ let errorsFromCallers = 0;
1745
+ let inboundVolume = 0;
1746
+ let outboundVolume = 0;
1747
+ let outboundErrors = 0;
1748
+ let latestInboundMs;
1749
+ let latencyP95Ms;
1750
+ let stale = false;
1751
+ for (const n of scope) {
1752
+ if (!graph.hasNode(n)) continue;
1753
+ for (const edgeId of graph.inboundEdges(n)) {
1754
+ const e = graph.getEdgeAttributes(edgeId);
1755
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1756
+ errorsFromCallers += e.signal?.errorCount ?? 0;
1757
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1758
+ if (e.provenance === import_types.Provenance.STALE) stale = true;
1759
+ const p95 = e.signal?.latencyMs?.p95;
1760
+ if (p95 !== void 0) latencyP95Ms = Math.max(latencyP95Ms ?? 0, p95);
1761
+ if (e.lastObserved) {
1762
+ const t = Date.parse(e.lastObserved);
1763
+ if (Number.isFinite(t)) latestInboundMs = Math.max(latestInboundMs ?? 0, t);
1764
+ }
1765
+ }
1766
+ for (const edgeId of graph.outboundEdges(n)) {
1767
+ const e = graph.getEdgeAttributes(edgeId);
1768
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1769
+ outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1770
+ if (e.type === import_types.EdgeType.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
1771
+ if (e.provenance === import_types.Provenance.STALE) stale = true;
1772
+ }
1773
+ }
1774
+ const errorsEmittedHere = incidentCountForNode(nodeId, incidents) + outboundErrors;
1775
+ const lastObservedAgeMs = latestInboundMs !== void 0 ? Math.max(0, now - latestInboundMs) : void 0;
1776
+ return {
1777
+ errorsEmittedHere,
1778
+ errorsFromCallers,
1779
+ callCount: inboundVolume,
1780
+ outboundVolume,
1781
+ ...lastObservedAgeMs !== void 0 ? { lastObservedAgeMs } : {},
1782
+ ...latencyP95Ms !== void 0 ? { latencyP95Ms } : {},
1783
+ stale
1784
+ };
1785
+ }
1786
+ function isSaturated(ctx) {
1787
+ return ctx.latencyP95Ms !== void 0 && ctx.latencyP95Ms >= SATURATION_P95_MS;
1788
+ }
1789
+ function classifyNode(ctx) {
1790
+ if (ctx.errorsEmittedHere > 0) {
1791
+ if ((ctx.stale || isSaturated(ctx)) && ctx.errorsFromCallers >= ctx.errorsEmittedHere) {
1792
+ return "symptom-only";
1793
+ }
1794
+ return "primary-failure";
1795
+ }
1796
+ if (ctx.errorsFromCallers > 0) return "symptom-only";
1797
+ return "unrelated";
1798
+ }
1799
+ function isVictimSeed(ctx) {
1800
+ return ctx.errorsFromCallers > 0 && (ctx.stale || isSaturated(ctx)) && ctx.errorsEmittedHere <= ctx.errorsFromCallers;
1801
+ }
1802
+ function grainOf(graph, nodeId) {
1803
+ if (!graph.hasNode(nodeId)) return "unknown";
1804
+ const t = graph.getNodeAttributes(nodeId).type;
1805
+ if (t === import_types.NodeType.ServiceNode) return "service";
1806
+ if (t === import_types.NodeType.FileNode) return "file";
1807
+ if (t === import_types.NodeType.SymbolNode) return "symbol";
1808
+ return t;
1809
+ }
1810
+ function findPath(graph, from, to, direction, maxDepth) {
1811
+ if (!graph.hasNode(from) || !graph.hasNode(to)) return null;
1812
+ if (from === to) return { nodes: [from], edges: [] };
1813
+ const queue = [{ nodeId: from, depth: 0, nodes: [from], edges: [] }];
1814
+ const enqueued = /* @__PURE__ */ new Set([from]);
1815
+ while (queue.length > 0) {
1816
+ const frame = queue.shift();
1817
+ if (frame.depth >= maxDepth) continue;
1818
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(frame.nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(frame.nodeId));
1819
+ const neighbours = [...best.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1820
+ for (const [nid, edge] of neighbours) {
1821
+ if (nid === to) return { nodes: [...frame.nodes, nid], edges: [...frame.edges, edge] };
1822
+ if (enqueued.has(nid)) continue;
1823
+ enqueued.add(nid);
1824
+ queue.push({
1825
+ nodeId: nid,
1826
+ depth: frame.depth + 1,
1827
+ nodes: [...frame.nodes, nid],
1828
+ edges: [...frame.edges, edge]
1829
+ });
1830
+ }
1831
+ }
1832
+ return null;
1833
+ }
1834
+ var EMPTY_CONTEXT = {
1835
+ errorsEmittedHere: 0,
1836
+ errorsFromCallers: 0,
1837
+ callCount: 0,
1838
+ outboundVolume: 0,
1839
+ stale: false
1840
+ };
1841
+ function expandNode(graph, nodeId, direction, incidents, now = Date.now()) {
1842
+ if (!graph.hasNode(nodeId)) {
1843
+ return import_types.ExpandResultSchema.parse({
1844
+ origin: nodeId,
1845
+ direction,
1846
+ node: { id: nodeId, classification: "unrelated", context: EMPTY_CONTEXT },
1847
+ neighbours: []
1848
+ });
1849
+ }
1850
+ const ctx = nodeContext(graph, nodeId, incidents, now);
1851
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(nodeId));
1852
+ const neighbours = [];
1853
+ for (const [nid, edge] of best) {
1854
+ if (edge.type === import_types.EdgeType.CONTAINS) continue;
1855
+ const nctx = nodeContext(graph, nid, incidents, now);
1856
+ neighbours.push({
1857
+ node: nid,
1858
+ edgeType: edge.type,
1859
+ provenance: edge.provenance,
1860
+ classification: classifyNode(nctx),
1861
+ context: nctx
1862
+ });
1863
+ }
1864
+ neighbours.sort((a, b) => a.node.localeCompare(b.node));
1865
+ return import_types.ExpandResultSchema.parse({
1866
+ origin: nodeId,
1867
+ direction,
1868
+ node: { id: nodeId, classification: classifyNode(ctx), context: ctx },
1869
+ neighbours
1870
+ });
1871
+ }
1872
+ function relate(graph, a, b, maxDepth = ROOT_CAUSE_MAX_DEPTH) {
1873
+ const buildPath = (fp) => ({
1874
+ nodes: fp.nodes,
1875
+ edgeTypes: fp.edges.map((e) => e.type),
1876
+ provenance: fp.edges.map((e) => e.provenance),
1877
+ grain: fp.nodes.map((n) => grainOf(graph, n)),
1878
+ // The failure runs end to end when every hop carries error / latency / alert
1879
+ // signal — that is what turns reachability into cause-confirmation.
1880
+ carriesSignal: fp.edges.length > 0 && fp.edges.every(
1881
+ (e) => (e.signal?.errorCount ?? 0) > 0 || e.signal?.latencyMs !== void 0 || e.signal?.anomalous !== void 0
1882
+ )
1883
+ });
1884
+ if (!graph.hasNode(a) || !graph.hasNode(b)) {
1885
+ return import_types.RelateResultSchema.parse({
1886
+ a,
1887
+ b,
1888
+ related: false,
1889
+ direction: null,
1890
+ paths: [],
1891
+ note: !graph.hasNode(a) ? `node not found: ${a}` : `node not found: ${b}`
1892
+ });
1893
+ }
1894
+ const down = findPath(graph, a, b, "down", maxDepth);
1895
+ const up = findPath(graph, a, b, "up", maxDepth);
1896
+ if (!down && !up) {
1897
+ return import_types.RelateResultSchema.parse({
1898
+ a,
1899
+ b,
1900
+ related: false,
1901
+ direction: null,
1902
+ paths: [],
1903
+ note: `no path within ${maxDepth} hops`
1904
+ });
1905
+ }
1906
+ const paths = [];
1907
+ const direction = down ? "a->b" : "b->a";
1908
+ if (down) paths.push(buildPath(down));
1909
+ if (up) paths.push(buildPath(up));
1910
+ const endpointsFine = grainOf(graph, a) !== "service" && grainOf(graph, b) !== "service";
1911
+ const grainGap = endpointsFine && paths[0].grain.some((g) => g === "service");
1912
+ return import_types.RelateResultSchema.parse({
1913
+ a,
1914
+ b,
1915
+ related: true,
1916
+ direction,
1917
+ paths,
1918
+ ...grainGap ? { grainGap: true } : {}
1919
+ });
1920
+ }
1921
+ function findLoadOrigin(graph, alertNodeId, incidents, now) {
1922
+ const upstream = getBlastRadius(graph, alertNodeId).affectedNodes.map((n) => n.nodeId);
1923
+ let best = null;
1924
+ for (const nid of upstream) {
1925
+ if (nid === alertNodeId) continue;
1926
+ const ctx = nodeContext(graph, nid, incidents, now);
1927
+ if (ctx.outboundVolume === 0) continue;
1928
+ const isSource = ctx.callCount === 0;
1929
+ const better = !best || (isSource !== best.isSource ? isSource : ctx.outboundVolume !== best.ctx.outboundVolume ? ctx.outboundVolume > best.ctx.outboundVolume : nid < best.node);
1930
+ if (better) best = { node: nid, ctx, isSource };
1931
+ }
1932
+ return best ? { node: best.node, ctx: best.ctx } : null;
1933
+ }
1934
+ function getRootCause(graph, errorNodeId, errorEvent, incidents, opts) {
1935
+ const legacy = legacyRootCause(graph, errorNodeId, errorEvent, incidents);
1936
+ if (!legacy) return null;
1937
+ const navigation = opts?.navigation ?? process.env.NEAT_RCA_NAVIGATION !== "0";
1938
+ if (!navigation) return legacy;
1939
+ return enrichWithNavigation(graph, errorNodeId, legacy, incidents, opts?.now ?? Date.now());
1940
+ }
1941
+ function enrichWithNavigation(graph, errorNodeId, legacy, incidents, now) {
1942
+ const seedNode = legacy.rootCauseNode;
1943
+ const seedCtx = graph.hasNode(seedNode) ? nodeContext(graph, seedNode, incidents, now) : null;
1944
+ const lastProv = legacy.edgeProvenances[legacy.edgeProvenances.length - 1];
1945
+ const candidates = [];
1946
+ if (seedCtx && isVictimSeed(seedCtx)) {
1947
+ const origin = findLoadOrigin(graph, errorNodeId, incidents, now);
1948
+ if (origin) {
1949
+ const path70 = findPath(graph, errorNodeId, origin.node, "up", ROOT_CAUSE_MAX_DEPTH);
1950
+ const originConfidence = confidenceFromMix(path70?.edges ?? [], now);
1951
+ candidates.push({
1952
+ node: origin.node,
1953
+ classification: "primary-failure",
1954
+ reason: `Highest-volume upstream source (${origin.ctx.outboundVolume} observed outbound calls) driving a saturated/stale subgraph; the alerting path decays downstream into a starved victim rather than a fault at the callee.`,
1955
+ context: origin.ctx,
1956
+ confidence: Math.max(0.3, Math.min(0.8, originConfidence || 0.5)),
1957
+ provenance: import_types.Provenance.OBSERVED
1958
+ });
1959
+ }
1960
+ const staleNote = seedCtx.stale ? "; the node has gone STALE" : "";
1961
+ const satNote = isSaturated(seedCtx) ? `; inbound p95 ${Math.round(seedCtx.latencyP95Ms)}ms is saturated` : "";
1962
+ candidates.push({
1963
+ node: seedNode,
1964
+ classification: "symptom-only",
1965
+ reason: `Errors arrive from callers (${seedCtx.errorsFromCallers}) but none originate here${staleNote}${satNote} \u2014 a downstream victim of load, not the fault.`,
1966
+ context: seedCtx,
1967
+ confidence: Math.min(legacy.confidence, 0.4),
1968
+ ...lastProv ? { provenance: lastProv } : {}
1969
+ });
1970
+ } else {
1971
+ candidates.push({
1972
+ node: seedNode,
1973
+ classification: "primary-failure",
1974
+ reason: legacy.rootCauseReason,
1975
+ context: seedCtx ?? EMPTY_CONTEXT,
1976
+ confidence: legacy.confidence,
1977
+ ...lastProv ? { provenance: lastProv } : {}
1978
+ });
1979
+ }
1980
+ const top = candidates[0];
1981
+ let traversalPath = legacy.traversalPath;
1982
+ let edgeProvenances = legacy.edgeProvenances;
1983
+ if (top.node !== seedNode) {
1984
+ const path70 = findPath(graph, errorNodeId, top.node, "up", ROOT_CAUSE_MAX_DEPTH);
1985
+ if (path70) {
1986
+ traversalPath = path70.nodes;
1987
+ edgeProvenances = path70.edges.map((e) => e.provenance);
1988
+ } else {
1989
+ traversalPath = [errorNodeId, top.node];
1990
+ edgeProvenances = [top.provenance ?? import_types.Provenance.OBSERVED];
1991
+ }
1992
+ }
1993
+ return import_types.RootCauseResultSchema.parse({
1994
+ rootCauseNode: top.node,
1995
+ rootCauseReason: top.reason,
1996
+ traversalPath,
1997
+ edgeProvenances,
1998
+ confidence: top.confidence,
1999
+ ...legacy.fixRecommendation ? { fixRecommendation: legacy.fixRecommendation } : {},
2000
+ candidates
1707
2001
  });
1708
2002
  }
1709
2003
 
@@ -2229,6 +2523,7 @@ var import_yaml = require("yaml");
2229
2523
  var import_types3 = require("@neat.is/types");
2230
2524
  var SERVICE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".py", ".go", ".rb", ".php"]);
2231
2525
  var CONFIG_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".yaml", ".yml"]);
2526
+ var JSON_CONFIG_FILENAMES = /* @__PURE__ */ new Set(["app.json", "app.config.json", "eas.json"]);
2232
2527
  var IGNORED_DIRS = /* @__PURE__ */ new Set([
2233
2528
  "node_modules",
2234
2529
  ".git",
@@ -2268,6 +2563,7 @@ async function isPythonVenvDir(dir) {
2268
2563
  function isConfigFile(name) {
2269
2564
  const ext = import_node_path3.default.extname(name);
2270
2565
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
2566
+ if (JSON_CONFIG_FILENAMES.has(name)) return { match: true, fileType: "json" };
2271
2567
  if (name === ".env" || name.startsWith(".env.")) {
2272
2568
  if (isEnvTemplateFile(name)) return { match: false, fileType: "" };
2273
2569
  if (isNeatAuthoredEnvFile(name)) return { match: false, fileType: "" };
@@ -4584,6 +4880,63 @@ function columnIsObserved(col) {
4584
4880
  return col.provenances.includes(import_types7.Provenance.OBSERVED);
4585
4881
  }
4586
4882
 
4883
+ // src/latency-digest.ts
4884
+ init_cjs_shims();
4885
+ var SUB = 16;
4886
+ var MIN_EXP = -10;
4887
+ var MAX_EXP = 22;
4888
+ var OCTAVES = MAX_EXP - MIN_EXP + 1;
4889
+ var OVERFLOW_INDEX = OCTAVES * SUB + 1;
4890
+ function latencyBucketIndex(ms) {
4891
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
4892
+ const e = Math.floor(Math.log2(ms));
4893
+ if (e < MIN_EXP) return 0;
4894
+ if (e > MAX_EXP) return OVERFLOW_INDEX;
4895
+ const base = 2 ** e;
4896
+ const raw = Math.floor((ms / base - 1) * SUB);
4897
+ const s = raw < 0 ? 0 : raw >= SUB ? SUB - 1 : raw;
4898
+ return (e - MIN_EXP) * SUB + s + 1;
4899
+ }
4900
+ function bucketRepresentativeMs(index) {
4901
+ if (index <= 0) return 0;
4902
+ if (index >= OVERFLOW_INDEX) return 2 ** (MAX_EXP + 1);
4903
+ const zeroBased = index - 1;
4904
+ const e = MIN_EXP + Math.floor(zeroBased / SUB);
4905
+ const s = zeroBased % SUB;
4906
+ const base = 2 ** e;
4907
+ const lower = base * (1 + s / SUB);
4908
+ const upper = base * (1 + (s + 1) / SUB);
4909
+ return (lower + upper) / 2;
4910
+ }
4911
+ function recordLatency(hist, ms) {
4912
+ const key = String(latencyBucketIndex(ms));
4913
+ hist[key] = (hist[key] ?? 0) + 1;
4914
+ return hist;
4915
+ }
4916
+ function quantile(hist, q) {
4917
+ const entries = Object.entries(hist).map(([k, c]) => [Number(k), c]).filter(([idx, c]) => Number.isFinite(idx) && c > 0).sort((a, b) => a[0] - b[0]);
4918
+ let total = 0;
4919
+ for (const [, c] of entries) total += c;
4920
+ if (total === 0) return 0;
4921
+ const rank = Math.max(1, Math.ceil(q * total));
4922
+ let cumulative = 0;
4923
+ for (const [idx, c] of entries) {
4924
+ cumulative += c;
4925
+ if (cumulative >= rank) return bucketRepresentativeMs(idx);
4926
+ }
4927
+ return bucketRepresentativeMs(entries[entries.length - 1][0]);
4928
+ }
4929
+ function round(ms) {
4930
+ return Math.round(ms * 100) / 100;
4931
+ }
4932
+ function latencyPercentiles(hist) {
4933
+ if (!hist) return void 0;
4934
+ let total = 0;
4935
+ for (const c of Object.values(hist)) total += c;
4936
+ if (total === 0) return void 0;
4937
+ return { p50: round(quantile(hist, 0.5)), p95: round(quantile(hist, 0.95)) };
4938
+ }
4939
+
4587
4940
  // src/ingest.ts
4588
4941
  var HOUR_MS = 60 * 60 * 1e3;
4589
4942
  var DAY_MS = 24 * HOUR_MS;
@@ -5279,7 +5632,7 @@ function ensureFrontierNode(graph, host, ts) {
5279
5632
  graph.addNode(id, node);
5280
5633
  return id;
5281
5634
  }
5282
- function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence) {
5635
+ function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence, durationMs) {
5283
5636
  if (!graph.hasNode(source) || !graph.hasNode(target)) return null;
5284
5637
  const grain = source.startsWith("file:") || source.startsWith("symbol:") ? "file" : "service";
5285
5638
  const id = makeObservedEdgeId(type, source, target);
@@ -5287,10 +5640,15 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5287
5640
  const existing = graph.getEdgeAttributes(id);
5288
5641
  const newSpanCount = (existing.signal?.spanCount ?? existing.callCount ?? 0) + 1;
5289
5642
  const newErrorCount = (existing.signal?.errorCount ?? 0) + (isError ? 1 : 0);
5643
+ const latencyHist2 = durationMs !== void 0 ? recordLatency({ ...existing.signal?.latencyHist ?? {} }, durationMs) : existing.signal?.latencyHist;
5644
+ const latencyMs2 = latencyPercentiles(latencyHist2) ?? existing.signal?.latencyMs;
5290
5645
  const newSignal = {
5291
5646
  spanCount: newSpanCount,
5292
5647
  errorCount: newErrorCount,
5293
- lastObservedAgeMs: 0
5648
+ lastObservedAgeMs: 0,
5649
+ ...latencyHist2 ? { latencyHist: latencyHist2 } : {},
5650
+ ...latencyMs2 ? { latencyMs: latencyMs2 } : {},
5651
+ ...existing.signal?.anomalous !== void 0 ? { anomalous: existing.signal.anomalous } : {}
5294
5652
  };
5295
5653
  const updated = {
5296
5654
  ...existing,
@@ -5305,10 +5663,14 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5305
5663
  graph.replaceEdgeAttributes(id, updated);
5306
5664
  return { edge: updated, created: false };
5307
5665
  }
5666
+ const latencyHist = durationMs !== void 0 ? recordLatency({}, durationMs) : void 0;
5667
+ const latencyMs = latencyHist ? latencyPercentiles(latencyHist) : void 0;
5308
5668
  const signal = {
5309
5669
  spanCount: 1,
5310
5670
  errorCount: isError ? 1 : 0,
5311
- lastObservedAgeMs: 0
5671
+ lastObservedAgeMs: 0,
5672
+ ...latencyHist ? { latencyHist } : {},
5673
+ ...latencyMs ? { latencyMs } : {}
5312
5674
  };
5313
5675
  const edge = {
5314
5676
  id,
@@ -5372,6 +5734,21 @@ async function appendErrorEvent(ctx, ev) {
5372
5734
  await import_node_fs7.promises.mkdir(import_node_path8.default.dirname(ctx.errorsPath), { recursive: true });
5373
5735
  await import_node_fs7.promises.appendFile(ctx.errorsPath, JSON.stringify(ev) + "\n", "utf8");
5374
5736
  }
5737
+ async function appendConnectorIncident(errorsPath, input) {
5738
+ const ev = {
5739
+ id: input.id,
5740
+ timestamp: input.timestamp,
5741
+ service: input.service,
5742
+ traceId: input.id,
5743
+ spanId: input.id,
5744
+ errorType: input.errorType,
5745
+ errorMessage: input.errorMessage,
5746
+ ...input.attributes && Object.keys(input.attributes).length > 0 ? { attributes: input.attributes } : {},
5747
+ affectedNode: input.affectedNode
5748
+ };
5749
+ await import_node_fs7.promises.mkdir(import_node_path8.default.dirname(errorsPath), { recursive: true });
5750
+ await import_node_fs7.promises.appendFile(errorsPath, JSON.stringify(ev) + "\n", "utf8");
5751
+ }
5375
5752
  function incidentAffectedNode(span, graph, scanPath) {
5376
5753
  const sid = graph ? resolveFusedServiceId(graph, span.service, span.env) : (0, import_types8.serviceId)(span.service, span.env);
5377
5754
  const serviceNode = graph && graph.hasNode(sid) ? graph.getNodeAttributes(sid) : void 0;
@@ -5518,6 +5895,7 @@ async function handleSpan(ctx, span) {
5518
5895
  }
5519
5896
  const sourceId = ensureServiceNode(ctx.graph, span.service, env);
5520
5897
  const isError = span.statusCode === 2;
5898
+ const durationMs = span.durationNanos > 0n ? Number(span.durationNanos) / 1e6 : void 0;
5521
5899
  const sourceServiceNode = ctx.graph.getNodeAttributes(sourceId);
5522
5900
  const callSite = callSiteFromSpan(span, sourceServiceNode, ctx.scanPath);
5523
5901
  cacheSpanService(span, nowMs, callSite);
@@ -5556,7 +5934,8 @@ async function handleSpan(ctx, span) {
5556
5934
  targetId,
5557
5935
  ts,
5558
5936
  isError,
5559
- callSiteEvidence
5937
+ callSiteEvidence,
5938
+ durationMs
5560
5939
  );
5561
5940
  if (result) affectedNode = targetId;
5562
5941
  if (span.dbSystem === "mongodb" && span.dbCollection) {
@@ -5568,7 +5947,8 @@ async function handleSpan(ctx, span) {
5568
5947
  collectionId,
5569
5948
  ts,
5570
5949
  isError,
5571
- callSiteEvidence
5950
+ callSiteEvidence,
5951
+ durationMs
5572
5952
  );
5573
5953
  }
5574
5954
  if (span.dbTable) {
@@ -5580,7 +5960,8 @@ async function handleSpan(ctx, span) {
5580
5960
  tableId,
5581
5961
  ts,
5582
5962
  isError,
5583
- callSiteEvidence
5963
+ callSiteEvidence,
5964
+ durationMs
5584
5965
  );
5585
5966
  mergeObservedColumns(ctx.graph, tableId, span.dbColumns);
5586
5967
  }
@@ -5599,7 +5980,8 @@ async function handleSpan(ctx, span) {
5599
5980
  targetId,
5600
5981
  ts,
5601
5982
  isError,
5602
- callSiteEvidence
5983
+ callSiteEvidence,
5984
+ durationMs
5603
5985
  );
5604
5986
  if (result) affectedNode = targetId;
5605
5987
  } else if (span.graphqlOperationName && span.graphqlOperationType && spanServesGraphqlOperation(span.kind)) {
@@ -5616,7 +5998,8 @@ async function handleSpan(ctx, span) {
5616
5998
  targetId,
5617
5999
  ts,
5618
6000
  isError,
5619
- callSiteEvidence
6001
+ callSiteEvidence,
6002
+ durationMs
5620
6003
  );
5621
6004
  if (result) affectedNode = targetId;
5622
6005
  } else if (span.rpcSystem === "grpc" && span.rpcService && span.rpcMethod && spanServesGrpcMethod(span.kind)) {
@@ -5628,7 +6011,8 @@ async function handleSpan(ctx, span) {
5628
6011
  targetId,
5629
6012
  ts,
5630
6013
  isError,
5631
- callSiteEvidence
6014
+ callSiteEvidence,
6015
+ durationMs
5632
6016
  );
5633
6017
  if (result) affectedNode = targetId;
5634
6018
  } else if (span.websocketChannel && spanServesWebsocketChannel(span.kind)) {
@@ -5644,7 +6028,8 @@ async function handleSpan(ctx, span) {
5644
6028
  targetId,
5645
6029
  ts,
5646
6030
  isError,
5647
- callSiteEvidence
6031
+ callSiteEvidence,
6032
+ durationMs
5648
6033
  );
5649
6034
  if (result) affectedNode = targetId;
5650
6035
  } else {
@@ -5660,7 +6045,8 @@ async function handleSpan(ctx, span) {
5660
6045
  targetId,
5661
6046
  ts,
5662
6047
  isError,
5663
- callSiteEvidence
6048
+ callSiteEvidence,
6049
+ durationMs
5664
6050
  );
5665
6051
  affectedNode = targetId;
5666
6052
  resolvedViaAddress = true;
@@ -5673,7 +6059,8 @@ async function handleSpan(ctx, span) {
5673
6059
  frontierNodeId,
5674
6060
  ts,
5675
6061
  isError,
5676
- callSiteEvidence
6062
+ callSiteEvidence,
6063
+ durationMs
5677
6064
  );
5678
6065
  affectedNode = frontierNodeId;
5679
6066
  resolvedViaAddress = true;
@@ -5699,7 +6086,8 @@ async function handleSpan(ctx, span) {
5699
6086
  sourceId,
5700
6087
  ts,
5701
6088
  isError,
5702
- fallbackEvidence
6089
+ fallbackEvidence,
6090
+ durationMs
5703
6091
  );
5704
6092
  }
5705
6093
  }
@@ -5713,7 +6101,7 @@ async function handleSpan(ctx, span) {
5713
6101
  );
5714
6102
  if (routeNodeId) {
5715
6103
  const routeSvc = ctx.graph.getNodeAttributes(routeNodeId).service;
5716
- upsertObservedEdge(ctx.graph, import_types8.EdgeType.CONTAINS, (0, import_types8.serviceId)(routeSvc), routeNodeId, ts, isError);
6104
+ upsertObservedEdge(ctx.graph, import_types8.EdgeType.CONTAINS, (0, import_types8.serviceId)(routeSvc), routeNodeId, ts, isError, void 0, durationMs);
5717
6105
  }
5718
6106
  }
5719
6107
  if (span.statusCode === 2) {
@@ -11182,8 +11570,41 @@ var import_tree_sitter14 = __toESM(require("tree-sitter"), 1);
11182
11570
  var import_tree_sitter_go3 = __toESM(require("tree-sitter-go"), 1);
11183
11571
  var import_types35 = require("@neat.is/types");
11184
11572
  init_otel();
11185
- var SQL_METHODS = /* @__PURE__ */ new Set(["Exec", "ExecContext", "Query", "QueryContext", "QueryRow", "QueryRowContext"]);
11186
11573
  var PARSE_CHUNK10 = 16384;
11574
+ var DATABASE_SQL_METHODS = /* @__PURE__ */ new Set([
11575
+ "Query",
11576
+ "QueryContext",
11577
+ "QueryRow",
11578
+ "QueryRowContext",
11579
+ "Exec",
11580
+ "ExecContext",
11581
+ "Prepare",
11582
+ "PrepareContext"
11583
+ ]);
11584
+ var SQLX_METHODS = /* @__PURE__ */ new Set([
11585
+ "Get",
11586
+ "Select",
11587
+ "Queryx",
11588
+ "QueryRowx",
11589
+ "NamedExec",
11590
+ "NamedQuery",
11591
+ "MustExec",
11592
+ "Preparex",
11593
+ "GetContext",
11594
+ "SelectContext"
11595
+ ]);
11596
+ var DATABASE_SQL_IMPORT = "database/sql";
11597
+ var SQLX_IMPORT = "github.com/jmoiron/sqlx";
11598
+ function makeGoParser3() {
11599
+ const p = new import_tree_sitter14.default();
11600
+ p.setLanguage(import_tree_sitter_go3.default);
11601
+ return p;
11602
+ }
11603
+ function parseSource10(parser, source) {
11604
+ return parser.parse(
11605
+ (index) => index >= source.length ? "" : source.slice(index, index + PARSE_CHUNK10)
11606
+ );
11607
+ }
11187
11608
  function walk7(node, visit) {
11188
11609
  visit(node);
11189
11610
  for (let i = 0; i < node.namedChildCount; i++) {
@@ -11191,25 +11612,54 @@ function walk7(node, visit) {
11191
11612
  if (child) walk7(child, visit);
11192
11613
  }
11193
11614
  }
11615
+ function goStringLiteralValue(node) {
11616
+ if (!node) return null;
11617
+ if (node.type === "interpreted_string_literal" || node.type === "raw_string_literal") {
11618
+ const t = node.text;
11619
+ return t.length >= 2 ? t.slice(1, -1) : "";
11620
+ }
11621
+ return null;
11622
+ }
11623
+ function goImportsAny(root, names) {
11624
+ let found = false;
11625
+ walk7(root, (node) => {
11626
+ if (found || node.type !== "import_spec") return;
11627
+ for (let i = 0; i < node.namedChildCount; i++) {
11628
+ const value = goStringLiteralValue(node.namedChild(i));
11629
+ if (value !== null && names.has(value)) found = true;
11630
+ }
11631
+ });
11632
+ return found;
11633
+ }
11634
+ function firstStringLiteralArg(argsNode) {
11635
+ if (!argsNode) return null;
11636
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
11637
+ const value = goStringLiteralValue(argsNode.namedChild(i));
11638
+ if (value !== null) return value;
11639
+ }
11640
+ return null;
11641
+ }
11194
11642
  function goSqlEndpointsFromFile(file, serviceDir) {
11195
11643
  if (import_node_path47.default.extname(file.path) !== ".go") return [];
11196
- const parser = new import_tree_sitter14.default();
11197
- parser.setLanguage(import_tree_sitter_go3.default);
11198
- const tree = parser.parse(
11199
- (index) => index >= file.content.length ? "" : file.content.slice(index, index + PARSE_CHUNK10)
11200
- );
11644
+ if (!file.content.includes(DATABASE_SQL_IMPORT) && !file.content.includes(SQLX_IMPORT)) return [];
11645
+ const tree = parseSource10(makeGoParser3(), file.content);
11646
+ const importsDatabaseSql = goImportsAny(tree.rootNode, /* @__PURE__ */ new Set([DATABASE_SQL_IMPORT]));
11647
+ const importsSqlx = goImportsAny(tree.rootNode, /* @__PURE__ */ new Set([SQLX_IMPORT]));
11648
+ if (!importsDatabaseSql && !importsSqlx) return [];
11201
11649
  const out = [];
11202
11650
  walk7(tree.rootNode, (node) => {
11203
11651
  if (node.type !== "call_expression") return;
11204
11652
  const fn = node.childForFieldName("function");
11205
11653
  if (fn?.type !== "selector_expression") return;
11206
11654
  const method = fn.childForFieldName("field")?.text;
11207
- if (!method || !SQL_METHODS.has(method)) return;
11208
- const arg = node.childForFieldName("arguments")?.namedChild(0);
11209
- if (!arg || arg.type !== "interpreted_string_literal" && arg.type !== "raw_string_literal") return;
11210
- const sql = arg.text.slice(1, -1);
11655
+ if (!method) return;
11656
+ const recognized = DATABASE_SQL_METHODS.has(method) || importsSqlx && SQLX_METHODS.has(method);
11657
+ if (!recognized) return;
11658
+ const sql = firstStringLiteralArg(node.childForFieldName("arguments"));
11659
+ if (sql === null) return;
11211
11660
  const table = tableFromSqlStatement(sql);
11212
11661
  if (!table) return;
11662
+ const columns = columnsFromSqlStatement(sql);
11213
11663
  const line = node.startPosition.row + 1;
11214
11664
  out.push({
11215
11665
  infraId: (0, import_types35.infraId)("sql-table", table),
@@ -11217,7 +11667,12 @@ function goSqlEndpointsFromFile(file, serviceDir) {
11217
11667
  kind: "sql-table",
11218
11668
  edgeType: "CALLS",
11219
11669
  confidenceKind: "verified-call-site",
11220
- evidence: { file: toPosix(import_node_path47.default.relative(serviceDir, file.path)), line, snippet: snippet(file.content, line) }
11670
+ ...columns.length > 0 ? { columns } : {},
11671
+ evidence: {
11672
+ file: toPosix(import_node_path47.default.relative(serviceDir, file.path)),
11673
+ line,
11674
+ snippet: snippet(file.content, line)
11675
+ }
11221
11676
  });
11222
11677
  });
11223
11678
  return out;
@@ -11231,12 +11686,12 @@ var import_tree_sitter_go4 = __toESM(require("tree-sitter-go"), 1);
11231
11686
  var import_types36 = require("@neat.is/types");
11232
11687
  var GORM_IMPORT_RE = /gorm\.io\/gorm/;
11233
11688
  var PARSE_CHUNK11 = 16384;
11234
- function makeGoParser3() {
11689
+ function makeGoParser4() {
11235
11690
  const p = new import_tree_sitter15.default();
11236
11691
  p.setLanguage(import_tree_sitter_go4.default);
11237
11692
  return p;
11238
11693
  }
11239
- function parseSource10(parser, source) {
11694
+ function parseSource11(parser, source) {
11240
11695
  return parser.parse(
11241
11696
  (index) => index >= source.length ? "" : source.slice(index, index + PARSE_CHUNK11)
11242
11697
  );
@@ -11660,7 +12115,7 @@ function collectColumns(struct, structs, seen, prefix, out, emitted) {
11660
12115
  function gormEndpointsFromFile(file, serviceDir) {
11661
12116
  if (import_node_path48.default.extname(file.path) !== ".go") return [];
11662
12117
  if (!GORM_IMPORT_RE.test(file.content)) return [];
11663
- const tree = parseSource10(makeGoParser3(), file.content);
12118
+ const tree = parseSource11(makeGoParser4(), file.content);
11664
12119
  const { structs, models, tableFor } = analyze(tree);
11665
12120
  const out = [];
11666
12121
  const seenTables = /* @__PURE__ */ new Set();
@@ -11691,7 +12146,7 @@ function gormEndpointsFromFile(file, serviceDir) {
11691
12146
  function gormForeignKeys(file, serviceDir) {
11692
12147
  if (import_node_path48.default.extname(file.path) !== ".go") return [];
11693
12148
  if (!GORM_IMPORT_RE.test(file.content)) return [];
11694
- const tree = parseSource10(makeGoParser3(), file.content);
12149
+ const tree = parseSource11(makeGoParser4(), file.content);
11695
12150
  const { structs, models, tableFor } = analyze(tree);
11696
12151
  const out = [];
11697
12152
  const seen = /* @__PURE__ */ new Set();
@@ -13486,7 +13941,7 @@ var Projects = class {
13486
13941
  init_cjs_shims();
13487
13942
  var import_fastify2 = __toESM(require("fastify"), 1);
13488
13943
  var import_cors = __toESM(require("@fastify/cors"), 1);
13489
- var import_types80 = require("@neat.is/types");
13944
+ var import_types85 = require("@neat.is/types");
13490
13945
 
13491
13946
  // src/extend/index.ts
13492
13947
  init_cjs_shims();
@@ -14882,6 +15337,23 @@ async function runConnectorPoll(connector, ctx, graph, resolveTarget) {
14882
15337
  unresolved++;
14883
15338
  continue;
14884
15339
  }
15340
+ if (signal.incident) {
15341
+ ensureServiceNode(graph, resolved.serviceName, NO_ENV);
15342
+ if (!ctx.errorsPath || !graph.hasNode(resolved.targetNodeId)) {
15343
+ unresolved++;
15344
+ continue;
15345
+ }
15346
+ await appendConnectorIncident(ctx.errorsPath, {
15347
+ id: signal.incident.id,
15348
+ timestamp: signal.incident.timestamp,
15349
+ service: signal.incident.service,
15350
+ errorType: signal.incident.errorType,
15351
+ errorMessage: signal.incident.errorMessage,
15352
+ ...signal.incident.attributes ? { attributes: signal.incident.attributes } : {},
15353
+ affectedNode: resolved.targetNodeId
15354
+ });
15355
+ continue;
15356
+ }
14885
15357
  if (resolved.ensureInfraNode) {
14886
15358
  const { kind, name, provider } = resolved.ensureInfraNode;
14887
15359
  ensureInfraNode(graph, kind, name, provider);
@@ -17116,6 +17588,338 @@ function createPlanetscaleConnector(graph, config, deps = {}) {
17116
17588
  };
17117
17589
  }
17118
17590
 
17591
+ // src/connectors/eas/index.ts
17592
+ init_cjs_shims();
17593
+
17594
+ // src/connectors/eas/client.ts
17595
+ init_cjs_shims();
17596
+
17597
+ // src/connectors/eas/types.ts
17598
+ init_cjs_shims();
17599
+ function readEasCredentials(raw) {
17600
+ const token = raw["token"];
17601
+ if (typeof token !== "string" || token.length === 0) {
17602
+ throw new Error("eas connector: credentials.token (EXPO_TOKEN) must be a non-empty string");
17603
+ }
17604
+ return { token };
17605
+ }
17606
+ var EAS_STATUS_ERRORED = "ERRORED";
17607
+ var TRANSIENT_BUILD_PHASES = /* @__PURE__ */ new Set([
17608
+ "SPIN_UP_BUILDER",
17609
+ "PREPARE_CREDENTIALS",
17610
+ "RESTORE_CACHE",
17611
+ "UPLOAD_APPLICATION_ARCHIVE"
17612
+ ]);
17613
+ var INTERNAL_ERROR_CODE = /INTERNAL_SERVER_ERROR|EAS_BUILD_.*INTERNAL|_INTERNAL_ERROR|UNKNOWN_ERROR/i;
17614
+ function isTransientFailure(err) {
17615
+ if (!err) return false;
17616
+ const phase = typeof err.buildPhase === "string" ? err.buildPhase : void 0;
17617
+ if (phase) {
17618
+ if (TRANSIENT_BUILD_PHASES.has(phase)) return true;
17619
+ if (/^SPIN_UP|_CREDENTIALS$|^RESTORE_CACHE/i.test(phase)) return true;
17620
+ }
17621
+ const code = typeof err.errorCode === "string" ? err.errorCode : void 0;
17622
+ if (code && INTERNAL_ERROR_CODE.test(code)) return true;
17623
+ return false;
17624
+ }
17625
+ var FIELD_SEP3 = "\0";
17626
+ var EAS_TARGET_KIND = "eas-build";
17627
+ function packEasTargetName(identity) {
17628
+ return [identity.serviceName, identity.phase].join(FIELD_SEP3);
17629
+ }
17630
+ function parseEasTargetName(targetName) {
17631
+ const sep = targetName.indexOf(FIELD_SEP3);
17632
+ if (sep === -1) return null;
17633
+ const serviceName = targetName.slice(0, sep);
17634
+ const phase = targetName.slice(sep + 1);
17635
+ if (!serviceName) return null;
17636
+ return { serviceName, phase };
17637
+ }
17638
+
17639
+ // src/connectors/eas/client.ts
17640
+ var DEFAULT_EAS_API_URL = "https://api.expo.dev/graphql";
17641
+ var DEFAULT_PAGE_SIZE = 50;
17642
+ var DEFAULT_MAX_PAGES = 10;
17643
+ var DEFAULT_MAX_LOG_BYTES = 16 * 1024;
17644
+ var DEFAULT_MAX_LOOKBACK_MS6 = 7 * 24 * 60 * 60 * 1e3;
17645
+ var BUILDS_QUERY = `
17646
+ query NeatEasErroredBuilds($appId: String!, $offset: Int!, $limit: Int!) {
17647
+ app {
17648
+ byId(appId: $appId) {
17649
+ builds(offset: $offset, limit: $limit, filter: { status: ERRORED }) {
17650
+ id
17651
+ status
17652
+ platform
17653
+ buildProfile
17654
+ gitCommitHash
17655
+ gitCommitMessage
17656
+ gitRef
17657
+ isGitWorkingTreeDirty
17658
+ createdAt
17659
+ completedAt
17660
+ error {
17661
+ buildPhase
17662
+ errorCode
17663
+ message
17664
+ docsUrl
17665
+ }
17666
+ logFileUrls
17667
+ }
17668
+ }
17669
+ }
17670
+ }
17671
+ `;
17672
+ async function easGraphQL(apiUrl, token, query, variables, accountKey, fetchImpl) {
17673
+ const res = await junctionFetch(
17674
+ apiUrl,
17675
+ {
17676
+ method: "POST",
17677
+ headers: {
17678
+ "Content-Type": "application/json",
17679
+ ...bearerAuthHeader(token)
17680
+ },
17681
+ body: JSON.stringify({ query, variables })
17682
+ },
17683
+ // accountKey: the Expo app id — the per-(provider, accountKey) rate-limit
17684
+ // bucket (ADR-131), the closest thing this connector carries to "one account".
17685
+ { provider: "eas", accountKey, ...fetchImpl ? { fetchImpl } : {} }
17686
+ );
17687
+ if (!res.ok) {
17688
+ throw new Error(`Expo GraphQL request failed: ${res.status} ${res.statusText}`);
17689
+ }
17690
+ const body = await res.json();
17691
+ if (body.errors && body.errors.length > 0) {
17692
+ throw new Error(`Expo GraphQL errors: ${body.errors.map((e) => e.message).join("; ")}`);
17693
+ }
17694
+ if (!body.data) throw new Error("Expo GraphQL response carried no data");
17695
+ return body.data;
17696
+ }
17697
+ async function fetchErroredBuilds(token, config, fetchImpl) {
17698
+ const apiUrl = config.apiUrl ?? DEFAULT_EAS_API_URL;
17699
+ const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE;
17700
+ const maxPages = config.maxPages ?? DEFAULT_MAX_PAGES;
17701
+ const out = [];
17702
+ const seen = /* @__PURE__ */ new Set();
17703
+ for (let page = 0; page < maxPages; page++) {
17704
+ const data = await easGraphQL(
17705
+ apiUrl,
17706
+ token,
17707
+ BUILDS_QUERY,
17708
+ { appId: config.appId, offset: page * pageSize, limit: pageSize },
17709
+ config.appId,
17710
+ fetchImpl
17711
+ );
17712
+ const builds = data.app?.byId?.builds;
17713
+ if (!Array.isArray(builds)) break;
17714
+ let added = 0;
17715
+ for (const b of builds) {
17716
+ if (!b || typeof b.id !== "string" || b.id.length === 0) continue;
17717
+ if (b.status !== EAS_STATUS_ERRORED) continue;
17718
+ if (seen.has(b.id)) continue;
17719
+ seen.add(b.id);
17720
+ out.push(b);
17721
+ added++;
17722
+ }
17723
+ if (builds.length < pageSize) break;
17724
+ if (added === 0) break;
17725
+ }
17726
+ return out;
17727
+ }
17728
+ async function fetchBuildLogs(logFileUrls, maxBytes = DEFAULT_MAX_LOG_BYTES, fetchImpl) {
17729
+ if (!Array.isArray(logFileUrls) || logFileUrls.length === 0) return "";
17730
+ const doFetch = fetchImpl ?? fetch;
17731
+ const chunks = [];
17732
+ for (const url of logFileUrls) {
17733
+ if (typeof url !== "string" || url.length === 0) continue;
17734
+ try {
17735
+ const res = await doFetch(url);
17736
+ if (!res.ok) continue;
17737
+ chunks.push(await res.text());
17738
+ } catch {
17739
+ }
17740
+ }
17741
+ const joined = chunks.join("\n");
17742
+ return joined.length > maxBytes ? joined.slice(joined.length - maxBytes) : joined;
17743
+ }
17744
+
17745
+ // src/connectors/eas/map.ts
17746
+ init_cjs_shims();
17747
+ function buildEventTime(build) {
17748
+ if (typeof build.completedAt === "string" && build.completedAt.length > 0) return build.completedAt;
17749
+ if (typeof build.createdAt === "string" && build.createdAt.length > 0) return build.createdAt;
17750
+ return (/* @__PURE__ */ new Date()).toISOString();
17751
+ }
17752
+ function incidentMessage2(build) {
17753
+ const err = build.error ?? {};
17754
+ const phase = typeof err.buildPhase === "string" && err.buildPhase.length > 0 ? ` at ${err.buildPhase}` : "";
17755
+ const detail = typeof err.message === "string" && err.message.trim().length > 0 && err.message.trim() || typeof err.errorCode === "string" && err.errorCode.length > 0 && err.errorCode || "no error detail reported";
17756
+ let msg = `EAS build failed${phase}: ${detail}`;
17757
+ if (build.isGitWorkingTreeDirty === true) {
17758
+ msg += " (built from a dirty working tree \u2014 the commit may not represent what built)";
17759
+ }
17760
+ return msg;
17761
+ }
17762
+ function incidentAttributes(build) {
17763
+ const attrs = {};
17764
+ const err = build.error ?? {};
17765
+ const put = (k, v) => {
17766
+ if (typeof v === "string" && v.length === 0) return;
17767
+ if (v !== void 0 && v !== null) attrs[k] = v;
17768
+ };
17769
+ put("eas.buildId", build.id);
17770
+ put("eas.platform", build.platform ?? void 0);
17771
+ put("eas.buildProfile", build.buildProfile ?? void 0);
17772
+ put("eas.buildPhase", err.buildPhase ?? void 0);
17773
+ put("eas.errorCode", err.errorCode ?? void 0);
17774
+ put("eas.docsUrl", err.docsUrl ?? void 0);
17775
+ put("eas.gitCommitHash", build.gitCommitHash ?? void 0);
17776
+ put("eas.gitRef", build.gitRef ?? void 0);
17777
+ put("eas.gitCommitMessage", build.gitCommitMessage ?? void 0);
17778
+ if (typeof build.isGitWorkingTreeDirty === "boolean") {
17779
+ attrs["eas.gitWorkingTreeDirty"] = build.isGitWorkingTreeDirty;
17780
+ if (build.isGitWorkingTreeDirty) attrs["eas.confidence"] = "low";
17781
+ }
17782
+ put("eas.createdAt", build.createdAt ?? void 0);
17783
+ put("eas.completedAt", build.completedAt ?? void 0);
17784
+ if (typeof build.logsText === "string" && build.logsText.length > 0) {
17785
+ attrs["eas.logs"] = build.logsText;
17786
+ }
17787
+ return attrs;
17788
+ }
17789
+ function mapBuildToSignal(build, serviceName) {
17790
+ if (!build || typeof build !== "object") return null;
17791
+ if (build.status !== EAS_STATUS_ERRORED) return null;
17792
+ if (!build.error) return null;
17793
+ if (isTransientFailure(build.error)) return null;
17794
+ const timestamp = buildEventTime(build);
17795
+ const phase = typeof build.error.buildPhase === "string" ? build.error.buildPhase : "";
17796
+ return {
17797
+ targetKind: EAS_TARGET_KIND,
17798
+ targetName: packEasTargetName({ serviceName, phase }),
17799
+ // Incident-only — no edge, so no call/error count to replay.
17800
+ callCount: 0,
17801
+ errorCount: 0,
17802
+ lastObservedIso: timestamp,
17803
+ incident: {
17804
+ id: `eas:build:${build.id}`,
17805
+ timestamp,
17806
+ service: serviceName,
17807
+ errorType: "eas-build-failure",
17808
+ errorMessage: incidentMessage2(build),
17809
+ attributes: incidentAttributes(build)
17810
+ }
17811
+ };
17812
+ }
17813
+ function mapBuildsToSignals(builds, serviceName) {
17814
+ const out = [];
17815
+ for (const build of builds) {
17816
+ const signal = mapBuildToSignal(build, serviceName);
17817
+ if (signal) out.push(signal);
17818
+ }
17819
+ return out;
17820
+ }
17821
+
17822
+ // src/connectors/eas/resolve.ts
17823
+ init_cjs_shims();
17824
+ var import_types82 = require("@neat.is/types");
17825
+ var NO_ENV2 = "unknown";
17826
+ var EAS_JSON_PHASES = /* @__PURE__ */ new Set(["READ_EAS_JSON"]);
17827
+ var APP_CONFIG_PHASES = /* @__PURE__ */ new Set([
17828
+ "READ_APP_CONFIG",
17829
+ "CONFIGURE_EXPO_UPDATES",
17830
+ "CALCULATE_EXPO_UPDATES_RUNTIME_VERSION"
17831
+ ]);
17832
+ function configBasenamesForPhase(phase) {
17833
+ if (EAS_JSON_PHASES.has(phase)) return ["eas.json"];
17834
+ if (APP_CONFIG_PHASES.has(phase)) return ["app.json", "app.config.json"];
17835
+ return [];
17836
+ }
17837
+ function configNodeService(graph, configNodeId) {
17838
+ for (const edgeId of graph.inboundEdges(configNodeId)) {
17839
+ const edge = graph.getEdgeAttributes(edgeId);
17840
+ if (edge.type !== import_types82.EdgeType.CONFIGURED_BY) continue;
17841
+ const parsed = (0, import_types82.parseFileId)(edge.source);
17842
+ if (parsed) return parsed.service;
17843
+ }
17844
+ return null;
17845
+ }
17846
+ function findConfigNode(graph, basenames, serviceName) {
17847
+ let scoped = null;
17848
+ let anyMatch = null;
17849
+ graph.forEachNode((id, attrs) => {
17850
+ if (scoped) return;
17851
+ const node = attrs;
17852
+ if (node.type !== import_types82.NodeType.ConfigNode) return;
17853
+ if (typeof node.name !== "string" || !basenames.includes(node.name)) return;
17854
+ if (anyMatch === null) anyMatch = id;
17855
+ if (configNodeService(graph, id) === serviceName) scoped = id;
17856
+ });
17857
+ return scoped ?? anyMatch;
17858
+ }
17859
+ function createEasResolveTarget(graph) {
17860
+ return (signal) => {
17861
+ if (signal.targetKind !== EAS_TARGET_KIND) return null;
17862
+ const identity = parseEasTargetName(signal.targetName);
17863
+ if (!identity) return null;
17864
+ const { serviceName, phase } = identity;
17865
+ const basenames = configBasenamesForPhase(phase);
17866
+ if (basenames.length > 0) {
17867
+ const configNodeId = findConfigNode(graph, basenames, serviceName);
17868
+ if (configNodeId) {
17869
+ return { targetNodeId: configNodeId, serviceName, edgeType: import_types82.EdgeType.CALLS };
17870
+ }
17871
+ }
17872
+ return {
17873
+ targetNodeId: resolveFusedServiceId(graph, serviceName, NO_ENV2),
17874
+ serviceName,
17875
+ edgeType: import_types82.EdgeType.CALLS
17876
+ };
17877
+ };
17878
+ }
17879
+
17880
+ // src/connectors/eas/index.ts
17881
+ function isBuildSince(build, sinceIso) {
17882
+ const t = Date.parse(buildEventTime(build));
17883
+ const s = Date.parse(sinceIso);
17884
+ if (Number.isNaN(t) || Number.isNaN(s)) return true;
17885
+ return t > s;
17886
+ }
17887
+ function boundedSinceIso2(since, now, maxLookbackMs) {
17888
+ const floor = new Date(now.getTime() - maxLookbackMs);
17889
+ if (!since) return floor.toISOString();
17890
+ const sinceMs = new Date(since).getTime();
17891
+ if (Number.isNaN(sinceMs)) return floor.toISOString();
17892
+ return sinceMs < floor.getTime() ? floor.toISOString() : new Date(sinceMs).toISOString();
17893
+ }
17894
+ var EasConnector = class {
17895
+ constructor(config, fetchImpl) {
17896
+ this.config = config;
17897
+ this.fetchImpl = fetchImpl;
17898
+ }
17899
+ config;
17900
+ fetchImpl;
17901
+ provider = "eas";
17902
+ async poll(ctx) {
17903
+ const creds = readEasCredentials(ctx.credentials);
17904
+ const serviceName = this.config.serviceName ?? this.config.appId;
17905
+ const maxLookbackMs = this.config.maxLookbackMs ?? DEFAULT_MAX_LOOKBACK_MS6;
17906
+ const sinceIso = boundedSinceIso2(ctx.since, /* @__PURE__ */ new Date(), maxLookbackMs);
17907
+ const builds = await fetchErroredBuilds(creds.token, this.config, this.fetchImpl);
17908
+ const fresh = builds.filter((b) => isBuildSince(b, sinceIso));
17909
+ const maxLogBytes = this.config.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES;
17910
+ for (const build of fresh) {
17911
+ build.logsText = await fetchBuildLogs(build.logFileUrls, maxLogBytes, this.fetchImpl);
17912
+ }
17913
+ return mapBuildsToSignals(fresh, serviceName);
17914
+ }
17915
+ };
17916
+ function createEasConnector(graph, config, fetchImpl) {
17917
+ return {
17918
+ connector: new EasConnector(config, fetchImpl),
17919
+ resolveTarget: createEasResolveTarget(graph)
17920
+ };
17921
+ }
17922
+
17119
17923
  // src/connectors/registry.ts
17120
17924
  var CLOUDFLARE_API_BASE_URL = "https://api.cloudflare.com/client/v4";
17121
17925
  async function authProbe(input) {
@@ -17403,6 +18207,41 @@ var PROVIDER_DISPATCH = {
17403
18207
  ...fetchImpl ? { fetchImpl } : {}
17404
18208
  });
17405
18209
  }
18210
+ },
18211
+ eas: {
18212
+ provider: "eas",
18213
+ // The secret is a single robot-user EXPO_TOKEN; a single-string credential
18214
+ // maps to `token`. `appId` is non-secret config (connector-config.md §7.1).
18215
+ primaryCredentialKey: "token",
18216
+ requiredCredentialFields: ["token"],
18217
+ requiredOptionFields: ["appId"],
18218
+ build(graph, options) {
18219
+ return createEasConnector(graph, options);
18220
+ },
18221
+ // Runs the connector's real `builds` query at limit 1 — the exact read poll()
18222
+ // performs, minus the pages — so the probe checks both that the EXPO_TOKEN
18223
+ // authenticates and that this app id is reachable, the same probe-the-real-
18224
+ // query discipline Railway and Cloud Run use over a trivial `{ __typename }`.
18225
+ // A bad or wrong-scoped token comes back as an Expo GraphQL error, which
18226
+ // `fetchErroredBuilds` throws on, so it fails honestly here rather than
18227
+ // silently at the first poll.
18228
+ async validate({ credentials, options, fetchImpl }) {
18229
+ const cfg = options;
18230
+ const appId = String(cfg.appId ?? "");
18231
+ if (!appId) return { ok: false, reason: "eas: appId is required to validate" };
18232
+ const probeConfig = {
18233
+ appId,
18234
+ pageSize: 1,
18235
+ maxPages: 1,
18236
+ ...cfg.apiUrl ? { apiUrl: cfg.apiUrl } : {}
18237
+ };
18238
+ try {
18239
+ await fetchErroredBuilds(String(credentials.token ?? ""), probeConfig, fetchImpl);
18240
+ return { ok: true };
18241
+ } catch (err) {
18242
+ return { ok: false, reason: `eas auth check failed: ${err.message}` };
18243
+ }
18244
+ }
17406
18245
  }
17407
18246
  };
17408
18247
  function vercelCredsFrom(credentials) {
@@ -17584,7 +18423,11 @@ async function startConnectorPolling(input) {
17584
18423
  const stopFns = all.map(
17585
18424
  (registration) => startConnectorPollLoop(
17586
18425
  registration.connector,
17587
- { projectDir: input.projectDir, credentials: registration.credentials },
18426
+ {
18427
+ projectDir: input.projectDir,
18428
+ credentials: registration.credentials,
18429
+ ...input.errorsPath ? { errorsPath: input.errorsPath } : {}
18430
+ },
17588
18431
  input.graph,
17589
18432
  registration.resolveTarget,
17590
18433
  { intervalMs: registration.intervalMs, connectorId: registration.id }
@@ -17754,11 +18597,11 @@ function registerRoutes(scope, ctx) {
17754
18597
  const candidates = req2.query.type.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
17755
18598
  const parsed = [];
17756
18599
  for (const c of candidates) {
17757
- const r = import_types80.DivergenceTypeSchema.safeParse(c);
18600
+ const r = import_types85.DivergenceTypeSchema.safeParse(c);
17758
18601
  if (!r.success) {
17759
18602
  return reply.code(400).send({
17760
18603
  error: `unknown divergence type "${c}"`,
17761
- allowed: import_types80.DivergenceTypeSchema.options
18604
+ allowed: import_types85.DivergenceTypeSchema.options
17762
18605
  });
17763
18606
  }
17764
18607
  parsed.push(r.data);
@@ -17865,10 +18708,15 @@ function registerRoutes(scope, ctx) {
17865
18708
  }
17866
18709
  const reg = built.registration;
17867
18710
  const at = (/* @__PURE__ */ new Date()).toISOString();
18711
+ const incidentsPath = errorsPathFor(proj);
17868
18712
  try {
17869
18713
  const result = await ctx.runPoll(
17870
18714
  reg.connector,
17871
- { projectDir: proj.scanPath ?? "", credentials: reg.credentials },
18715
+ {
18716
+ projectDir: proj.scanPath ?? "",
18717
+ credentials: reg.credentials,
18718
+ ...incidentsPath ? { errorsPath: incidentsPath } : {}
18719
+ },
17872
18720
  proj.graph,
17873
18721
  reg.resolveTarget
17874
18722
  );
@@ -17938,6 +18786,34 @@ function registerRoutes(scope, ctx) {
17938
18786
  }
17939
18787
  return getBlastRadius(proj.graph, nodeId, depth);
17940
18788
  });
18789
+ scope.get("/graph/expand/:nodeId", async (req2, reply) => {
18790
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
18791
+ if (!proj) return;
18792
+ const { nodeId } = req2.params;
18793
+ if (!proj.graph.hasNode(nodeId)) {
18794
+ return reply.code(404).send({ error: "node not found", id: nodeId });
18795
+ }
18796
+ const direction = req2.query.direction;
18797
+ if (direction !== "up" && direction !== "down") {
18798
+ return reply.code(400).send({ error: 'direction must be "up" or "down"' });
18799
+ }
18800
+ const epath = errorsPathFor(proj);
18801
+ const incidents = epath ? await readErrorEvents(epath) : [];
18802
+ return expandNode(proj.graph, nodeId, direction, incidents);
18803
+ });
18804
+ scope.get("/graph/relate", async (req2, reply) => {
18805
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
18806
+ if (!proj) return;
18807
+ const { a, b } = req2.query;
18808
+ if (!a || !b) {
18809
+ return reply.code(400).send({ error: "both a and b query params are required" });
18810
+ }
18811
+ const maxDepth = req2.query.maxDepth ? Number(req2.query.maxDepth) : void 0;
18812
+ if (maxDepth !== void 0 && (!Number.isFinite(maxDepth) || maxDepth < 1)) {
18813
+ return reply.code(400).send({ error: "maxDepth must be a positive integer" });
18814
+ }
18815
+ return relate(proj.graph, a, b, maxDepth);
18816
+ });
17941
18817
  scope.get("/search", async (req2, reply) => {
17942
18818
  const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
17943
18819
  if (!proj) return;
@@ -18067,7 +18943,7 @@ function registerRoutes(scope, ctx) {
18067
18943
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
18068
18944
  let violations = await log.readAll();
18069
18945
  if (req2.query.severity) {
18070
- const sev = import_types80.PolicySeveritySchema.safeParse(req2.query.severity);
18946
+ const sev = import_types85.PolicySeveritySchema.safeParse(req2.query.severity);
18071
18947
  if (!sev.success) {
18072
18948
  return reply.code(400).send({
18073
18949
  error: "invalid severity",
@@ -18106,7 +18982,7 @@ function registerRoutes(scope, ctx) {
18106
18982
  scope.post("/policies/check", async (req2, reply) => {
18107
18983
  const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
18108
18984
  if (!proj) return;
18109
- const parsed = import_types80.PoliciesCheckBodySchema.safeParse(req2.body ?? {});
18985
+ const parsed = import_types85.PoliciesCheckBodySchema.safeParse(req2.body ?? {});
18110
18986
  if (!parsed.success) {
18111
18987
  return reply.code(400).send({
18112
18988
  error: "invalid /policies/check body",
@@ -18447,7 +19323,7 @@ function unroutedErrorsPath(neatHome4) {
18447
19323
  }
18448
19324
 
18449
19325
  // src/daemon.ts
18450
- var import_types81 = require("@neat.is/types");
19326
+ var import_types86 = require("@neat.is/types");
18451
19327
  function daemonJsonPath(scanPath) {
18452
19328
  return import_node_path67.default.join(scanPath, "neat-out", "daemon.json");
18453
19329
  }
@@ -18586,7 +19462,7 @@ function spanBelongsToSingleProject(graph, project, serviceName) {
18586
19462
  if (!serviceName) return true;
18587
19463
  if (serviceNameMatchesProject(serviceName, project)) return true;
18588
19464
  return graph.someNode(
18589
- (_id, attrs) => attrs.type === import_types81.NodeType.ServiceNode && attrs.name === serviceName
19465
+ (_id, attrs) => attrs.type === import_types86.NodeType.ServiceNode && attrs.name === serviceName
18590
19466
  );
18591
19467
  }
18592
19468
  async function bootstrapProject(entry2, connectors = [], neatHome4) {
@@ -18634,6 +19510,10 @@ async function bootstrapProject(entry2, connectors = [], neatHome4) {
18634
19510
  project: entry2.name,
18635
19511
  graph,
18636
19512
  projectDir: entry2.path,
19513
+ // The slot's incident ledger, so an incident-emitting connector (ADR-185)
19514
+ // writes a build-failure incident onto the same errors.ndjson OTLP-derived
19515
+ // incidents land in.
19516
+ errorsPath: paths.errorsPath,
18637
19517
  ...neatHome4 ? { home: neatHome4 } : {},
18638
19518
  extra: connectors,
18639
19519
  onSkip: (skipped, reason) => console.warn(