@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/index.cjs CHANGED
@@ -1437,7 +1437,7 @@ var rootCauseShapes = {
1437
1437
  [import_types.NodeType.FileNode]: fileRootCauseShape,
1438
1438
  [import_types.NodeType.SymbolNode]: symbolRootCauseShape
1439
1439
  };
1440
- function getRootCause(graph, errorNodeId, errorEvent, incidents) {
1440
+ function legacyRootCause(graph, errorNodeId, errorEvent, incidents) {
1441
1441
  if (!graph.hasNode(errorNodeId)) return null;
1442
1442
  const origin = graph.getNodeAttributes(errorNodeId);
1443
1443
  const shape = rootCauseShapes[origin.type];
@@ -1694,7 +1694,9 @@ function getObservedDependencies(graph, nodeId) {
1694
1694
  dependencies: [],
1695
1695
  observed: false,
1696
1696
  inboundObservedCount: 0,
1697
- hasExtractedOutbound: false
1697
+ hasExtractedOutbound: false,
1698
+ inboundVolume: 0,
1699
+ window: "lifetime"
1698
1700
  });
1699
1701
  }
1700
1702
  const attrs = graph.getNodeAttributes(nodeId);
@@ -1725,11 +1727,19 @@ function getObservedDependencies(graph, nodeId) {
1725
1727
  }
1726
1728
  }
1727
1729
  let inboundObservedCount = 0;
1730
+ let inboundVolume = 0;
1731
+ let inboundLastObserved;
1728
1732
  for (const tgt of scope) {
1729
1733
  for (const edgeId of graph.inboundEdges(tgt)) {
1730
1734
  const e = graph.getEdgeAttributes(edgeId);
1731
1735
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1732
- if (e.provenance === import_types.Provenance.OBSERVED) inboundObservedCount += 1;
1736
+ if (e.provenance === import_types.Provenance.OBSERVED) {
1737
+ inboundObservedCount += 1;
1738
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 1;
1739
+ if (e.lastObserved && (!inboundLastObserved || e.lastObserved > inboundLastObserved)) {
1740
+ inboundLastObserved = e.lastObserved;
1741
+ }
1742
+ }
1733
1743
  }
1734
1744
  }
1735
1745
  dependencies.sort(
@@ -1740,7 +1750,291 @@ function getObservedDependencies(graph, nodeId) {
1740
1750
  dependencies,
1741
1751
  observed: dependencies.length > 0 || inboundObservedCount > 0,
1742
1752
  inboundObservedCount,
1743
- hasExtractedOutbound
1753
+ hasExtractedOutbound,
1754
+ // The signal is cumulative, so the honest window label is "lifetime" (ADR-190).
1755
+ inboundVolume,
1756
+ window: "lifetime",
1757
+ ...inboundLastObserved ? { inboundLastObserved } : {}
1758
+ });
1759
+ }
1760
+ var SATURATION_P95_MS = 1e3;
1761
+ function nodeScope(graph, nodeId) {
1762
+ const scope = [nodeId];
1763
+ if (!graph.hasNode(nodeId)) return scope;
1764
+ const attrs = graph.getNodeAttributes(nodeId);
1765
+ if (attrs.type === import_types.NodeType.ServiceNode) {
1766
+ for (const edgeId of graph.outboundEdges(nodeId)) {
1767
+ const e = graph.getEdgeAttributes(edgeId);
1768
+ if (e.type !== import_types.EdgeType.CONTAINS) continue;
1769
+ const owned = graph.getNodeAttributes(e.target);
1770
+ if (owned.type === import_types.NodeType.FileNode) scope.push(e.target);
1771
+ }
1772
+ }
1773
+ return scope;
1774
+ }
1775
+ function incidentCountForNode(nodeId, incidents) {
1776
+ if (!incidents || incidents.length === 0) return 0;
1777
+ return incidents.filter((ev) => incidentMatchesNode(ev, nodeId)).length;
1778
+ }
1779
+ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1780
+ const scope = nodeScope(graph, nodeId);
1781
+ let errorsFromCallers = 0;
1782
+ let inboundVolume = 0;
1783
+ let outboundVolume = 0;
1784
+ let outboundErrors = 0;
1785
+ let latestInboundMs;
1786
+ let latencyP95Ms;
1787
+ let stale = false;
1788
+ for (const n of scope) {
1789
+ if (!graph.hasNode(n)) continue;
1790
+ for (const edgeId of graph.inboundEdges(n)) {
1791
+ const e = graph.getEdgeAttributes(edgeId);
1792
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1793
+ errorsFromCallers += e.signal?.errorCount ?? 0;
1794
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1795
+ if (e.provenance === import_types.Provenance.STALE) stale = true;
1796
+ const p95 = e.signal?.latencyMs?.p95;
1797
+ if (p95 !== void 0) latencyP95Ms = Math.max(latencyP95Ms ?? 0, p95);
1798
+ if (e.lastObserved) {
1799
+ const t = Date.parse(e.lastObserved);
1800
+ if (Number.isFinite(t)) latestInboundMs = Math.max(latestInboundMs ?? 0, t);
1801
+ }
1802
+ }
1803
+ for (const edgeId of graph.outboundEdges(n)) {
1804
+ const e = graph.getEdgeAttributes(edgeId);
1805
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1806
+ outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1807
+ if (e.type === import_types.EdgeType.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
1808
+ if (e.provenance === import_types.Provenance.STALE) stale = true;
1809
+ }
1810
+ }
1811
+ const errorsEmittedHere = incidentCountForNode(nodeId, incidents) + outboundErrors;
1812
+ const lastObservedAgeMs = latestInboundMs !== void 0 ? Math.max(0, now - latestInboundMs) : void 0;
1813
+ return {
1814
+ errorsEmittedHere,
1815
+ errorsFromCallers,
1816
+ callCount: inboundVolume,
1817
+ outboundVolume,
1818
+ ...lastObservedAgeMs !== void 0 ? { lastObservedAgeMs } : {},
1819
+ ...latencyP95Ms !== void 0 ? { latencyP95Ms } : {},
1820
+ stale
1821
+ };
1822
+ }
1823
+ function isSaturated(ctx) {
1824
+ return ctx.latencyP95Ms !== void 0 && ctx.latencyP95Ms >= SATURATION_P95_MS;
1825
+ }
1826
+ function classifyNode(ctx) {
1827
+ if (ctx.errorsEmittedHere > 0) {
1828
+ if ((ctx.stale || isSaturated(ctx)) && ctx.errorsFromCallers >= ctx.errorsEmittedHere) {
1829
+ return "symptom-only";
1830
+ }
1831
+ return "primary-failure";
1832
+ }
1833
+ if (ctx.errorsFromCallers > 0) return "symptom-only";
1834
+ return "unrelated";
1835
+ }
1836
+ function isVictimSeed(ctx) {
1837
+ return ctx.errorsFromCallers > 0 && (ctx.stale || isSaturated(ctx)) && ctx.errorsEmittedHere <= ctx.errorsFromCallers;
1838
+ }
1839
+ function grainOf(graph, nodeId) {
1840
+ if (!graph.hasNode(nodeId)) return "unknown";
1841
+ const t = graph.getNodeAttributes(nodeId).type;
1842
+ if (t === import_types.NodeType.ServiceNode) return "service";
1843
+ if (t === import_types.NodeType.FileNode) return "file";
1844
+ if (t === import_types.NodeType.SymbolNode) return "symbol";
1845
+ return t;
1846
+ }
1847
+ function findPath(graph, from, to, direction, maxDepth) {
1848
+ if (!graph.hasNode(from) || !graph.hasNode(to)) return null;
1849
+ if (from === to) return { nodes: [from], edges: [] };
1850
+ const queue = [{ nodeId: from, depth: 0, nodes: [from], edges: [] }];
1851
+ const enqueued = /* @__PURE__ */ new Set([from]);
1852
+ while (queue.length > 0) {
1853
+ const frame = queue.shift();
1854
+ if (frame.depth >= maxDepth) continue;
1855
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(frame.nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(frame.nodeId));
1856
+ const neighbours = [...best.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1857
+ for (const [nid, edge] of neighbours) {
1858
+ if (nid === to) return { nodes: [...frame.nodes, nid], edges: [...frame.edges, edge] };
1859
+ if (enqueued.has(nid)) continue;
1860
+ enqueued.add(nid);
1861
+ queue.push({
1862
+ nodeId: nid,
1863
+ depth: frame.depth + 1,
1864
+ nodes: [...frame.nodes, nid],
1865
+ edges: [...frame.edges, edge]
1866
+ });
1867
+ }
1868
+ }
1869
+ return null;
1870
+ }
1871
+ var EMPTY_CONTEXT = {
1872
+ errorsEmittedHere: 0,
1873
+ errorsFromCallers: 0,
1874
+ callCount: 0,
1875
+ outboundVolume: 0,
1876
+ stale: false
1877
+ };
1878
+ function expandNode(graph, nodeId, direction, incidents, now = Date.now()) {
1879
+ if (!graph.hasNode(nodeId)) {
1880
+ return import_types.ExpandResultSchema.parse({
1881
+ origin: nodeId,
1882
+ direction,
1883
+ node: { id: nodeId, classification: "unrelated", context: EMPTY_CONTEXT },
1884
+ neighbours: []
1885
+ });
1886
+ }
1887
+ const ctx = nodeContext(graph, nodeId, incidents, now);
1888
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(nodeId));
1889
+ const neighbours = [];
1890
+ for (const [nid, edge] of best) {
1891
+ if (edge.type === import_types.EdgeType.CONTAINS) continue;
1892
+ const nctx = nodeContext(graph, nid, incidents, now);
1893
+ neighbours.push({
1894
+ node: nid,
1895
+ edgeType: edge.type,
1896
+ provenance: edge.provenance,
1897
+ classification: classifyNode(nctx),
1898
+ context: nctx
1899
+ });
1900
+ }
1901
+ neighbours.sort((a, b) => a.node.localeCompare(b.node));
1902
+ return import_types.ExpandResultSchema.parse({
1903
+ origin: nodeId,
1904
+ direction,
1905
+ node: { id: nodeId, classification: classifyNode(ctx), context: ctx },
1906
+ neighbours
1907
+ });
1908
+ }
1909
+ function relate(graph, a, b, maxDepth = ROOT_CAUSE_MAX_DEPTH) {
1910
+ const buildPath = (fp) => ({
1911
+ nodes: fp.nodes,
1912
+ edgeTypes: fp.edges.map((e) => e.type),
1913
+ provenance: fp.edges.map((e) => e.provenance),
1914
+ grain: fp.nodes.map((n) => grainOf(graph, n)),
1915
+ // The failure runs end to end when every hop carries error / latency / alert
1916
+ // signal — that is what turns reachability into cause-confirmation.
1917
+ carriesSignal: fp.edges.length > 0 && fp.edges.every(
1918
+ (e) => (e.signal?.errorCount ?? 0) > 0 || e.signal?.latencyMs !== void 0 || e.signal?.anomalous !== void 0
1919
+ )
1920
+ });
1921
+ if (!graph.hasNode(a) || !graph.hasNode(b)) {
1922
+ return import_types.RelateResultSchema.parse({
1923
+ a,
1924
+ b,
1925
+ related: false,
1926
+ direction: null,
1927
+ paths: [],
1928
+ note: !graph.hasNode(a) ? `node not found: ${a}` : `node not found: ${b}`
1929
+ });
1930
+ }
1931
+ const down = findPath(graph, a, b, "down", maxDepth);
1932
+ const up = findPath(graph, a, b, "up", maxDepth);
1933
+ if (!down && !up) {
1934
+ return import_types.RelateResultSchema.parse({
1935
+ a,
1936
+ b,
1937
+ related: false,
1938
+ direction: null,
1939
+ paths: [],
1940
+ note: `no path within ${maxDepth} hops`
1941
+ });
1942
+ }
1943
+ const paths = [];
1944
+ const direction = down ? "a->b" : "b->a";
1945
+ if (down) paths.push(buildPath(down));
1946
+ if (up) paths.push(buildPath(up));
1947
+ const endpointsFine = grainOf(graph, a) !== "service" && grainOf(graph, b) !== "service";
1948
+ const grainGap = endpointsFine && paths[0].grain.some((g) => g === "service");
1949
+ return import_types.RelateResultSchema.parse({
1950
+ a,
1951
+ b,
1952
+ related: true,
1953
+ direction,
1954
+ paths,
1955
+ ...grainGap ? { grainGap: true } : {}
1956
+ });
1957
+ }
1958
+ function findLoadOrigin(graph, alertNodeId, incidents, now) {
1959
+ const upstream = getBlastRadius(graph, alertNodeId).affectedNodes.map((n) => n.nodeId);
1960
+ let best = null;
1961
+ for (const nid of upstream) {
1962
+ if (nid === alertNodeId) continue;
1963
+ const ctx = nodeContext(graph, nid, incidents, now);
1964
+ if (ctx.outboundVolume === 0) continue;
1965
+ const isSource = ctx.callCount === 0;
1966
+ const better = !best || (isSource !== best.isSource ? isSource : ctx.outboundVolume !== best.ctx.outboundVolume ? ctx.outboundVolume > best.ctx.outboundVolume : nid < best.node);
1967
+ if (better) best = { node: nid, ctx, isSource };
1968
+ }
1969
+ return best ? { node: best.node, ctx: best.ctx } : null;
1970
+ }
1971
+ function getRootCause(graph, errorNodeId, errorEvent, incidents, opts) {
1972
+ const legacy = legacyRootCause(graph, errorNodeId, errorEvent, incidents);
1973
+ if (!legacy) return null;
1974
+ const navigation = opts?.navigation ?? process.env.NEAT_RCA_NAVIGATION !== "0";
1975
+ if (!navigation) return legacy;
1976
+ return enrichWithNavigation(graph, errorNodeId, legacy, incidents, opts?.now ?? Date.now());
1977
+ }
1978
+ function enrichWithNavigation(graph, errorNodeId, legacy, incidents, now) {
1979
+ const seedNode = legacy.rootCauseNode;
1980
+ const seedCtx = graph.hasNode(seedNode) ? nodeContext(graph, seedNode, incidents, now) : null;
1981
+ const lastProv = legacy.edgeProvenances[legacy.edgeProvenances.length - 1];
1982
+ const candidates = [];
1983
+ if (seedCtx && isVictimSeed(seedCtx)) {
1984
+ const origin = findLoadOrigin(graph, errorNodeId, incidents, now);
1985
+ if (origin) {
1986
+ const path68 = findPath(graph, errorNodeId, origin.node, "up", ROOT_CAUSE_MAX_DEPTH);
1987
+ const originConfidence = confidenceFromMix(path68?.edges ?? [], now);
1988
+ candidates.push({
1989
+ node: origin.node,
1990
+ classification: "primary-failure",
1991
+ 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.`,
1992
+ context: origin.ctx,
1993
+ confidence: Math.max(0.3, Math.min(0.8, originConfidence || 0.5)),
1994
+ provenance: import_types.Provenance.OBSERVED
1995
+ });
1996
+ }
1997
+ const staleNote = seedCtx.stale ? "; the node has gone STALE" : "";
1998
+ const satNote = isSaturated(seedCtx) ? `; inbound p95 ${Math.round(seedCtx.latencyP95Ms)}ms is saturated` : "";
1999
+ candidates.push({
2000
+ node: seedNode,
2001
+ classification: "symptom-only",
2002
+ reason: `Errors arrive from callers (${seedCtx.errorsFromCallers}) but none originate here${staleNote}${satNote} \u2014 a downstream victim of load, not the fault.`,
2003
+ context: seedCtx,
2004
+ confidence: Math.min(legacy.confidence, 0.4),
2005
+ ...lastProv ? { provenance: lastProv } : {}
2006
+ });
2007
+ } else {
2008
+ candidates.push({
2009
+ node: seedNode,
2010
+ classification: "primary-failure",
2011
+ reason: legacy.rootCauseReason,
2012
+ context: seedCtx ?? EMPTY_CONTEXT,
2013
+ confidence: legacy.confidence,
2014
+ ...lastProv ? { provenance: lastProv } : {}
2015
+ });
2016
+ }
2017
+ const top = candidates[0];
2018
+ let traversalPath = legacy.traversalPath;
2019
+ let edgeProvenances = legacy.edgeProvenances;
2020
+ if (top.node !== seedNode) {
2021
+ const path68 = findPath(graph, errorNodeId, top.node, "up", ROOT_CAUSE_MAX_DEPTH);
2022
+ if (path68) {
2023
+ traversalPath = path68.nodes;
2024
+ edgeProvenances = path68.edges.map((e) => e.provenance);
2025
+ } else {
2026
+ traversalPath = [errorNodeId, top.node];
2027
+ edgeProvenances = [top.provenance ?? import_types.Provenance.OBSERVED];
2028
+ }
2029
+ }
2030
+ return import_types.RootCauseResultSchema.parse({
2031
+ rootCauseNode: top.node,
2032
+ rootCauseReason: top.reason,
2033
+ traversalPath,
2034
+ edgeProvenances,
2035
+ confidence: top.confidence,
2036
+ ...legacy.fixRecommendation ? { fixRecommendation: legacy.fixRecommendation } : {},
2037
+ candidates
1744
2038
  });
1745
2039
  }
1746
2040
 
@@ -2266,6 +2560,7 @@ var import_yaml = require("yaml");
2266
2560
  var import_types3 = require("@neat.is/types");
2267
2561
  var SERVICE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".py", ".go", ".rb", ".php"]);
2268
2562
  var CONFIG_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".yaml", ".yml"]);
2563
+ var JSON_CONFIG_FILENAMES = /* @__PURE__ */ new Set(["app.json", "app.config.json", "eas.json"]);
2269
2564
  var IGNORED_DIRS = /* @__PURE__ */ new Set([
2270
2565
  "node_modules",
2271
2566
  ".git",
@@ -2305,6 +2600,7 @@ async function isPythonVenvDir(dir) {
2305
2600
  function isConfigFile(name) {
2306
2601
  const ext = import_node_path3.default.extname(name);
2307
2602
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
2603
+ if (JSON_CONFIG_FILENAMES.has(name)) return { match: true, fileType: "json" };
2308
2604
  if (name === ".env" || name.startsWith(".env.")) {
2309
2605
  if (isEnvTemplateFile(name)) return { match: false, fileType: "" };
2310
2606
  if (isNeatAuthoredEnvFile(name)) return { match: false, fileType: "" };
@@ -4621,6 +4917,63 @@ function columnIsObserved(col) {
4621
4917
  return col.provenances.includes(import_types7.Provenance.OBSERVED);
4622
4918
  }
4623
4919
 
4920
+ // src/latency-digest.ts
4921
+ init_cjs_shims();
4922
+ var SUB = 16;
4923
+ var MIN_EXP = -10;
4924
+ var MAX_EXP = 22;
4925
+ var OCTAVES = MAX_EXP - MIN_EXP + 1;
4926
+ var OVERFLOW_INDEX = OCTAVES * SUB + 1;
4927
+ function latencyBucketIndex(ms) {
4928
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
4929
+ const e = Math.floor(Math.log2(ms));
4930
+ if (e < MIN_EXP) return 0;
4931
+ if (e > MAX_EXP) return OVERFLOW_INDEX;
4932
+ const base = 2 ** e;
4933
+ const raw = Math.floor((ms / base - 1) * SUB);
4934
+ const s = raw < 0 ? 0 : raw >= SUB ? SUB - 1 : raw;
4935
+ return (e - MIN_EXP) * SUB + s + 1;
4936
+ }
4937
+ function bucketRepresentativeMs(index) {
4938
+ if (index <= 0) return 0;
4939
+ if (index >= OVERFLOW_INDEX) return 2 ** (MAX_EXP + 1);
4940
+ const zeroBased = index - 1;
4941
+ const e = MIN_EXP + Math.floor(zeroBased / SUB);
4942
+ const s = zeroBased % SUB;
4943
+ const base = 2 ** e;
4944
+ const lower = base * (1 + s / SUB);
4945
+ const upper = base * (1 + (s + 1) / SUB);
4946
+ return (lower + upper) / 2;
4947
+ }
4948
+ function recordLatency(hist, ms) {
4949
+ const key = String(latencyBucketIndex(ms));
4950
+ hist[key] = (hist[key] ?? 0) + 1;
4951
+ return hist;
4952
+ }
4953
+ function quantile(hist, q) {
4954
+ 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]);
4955
+ let total = 0;
4956
+ for (const [, c] of entries) total += c;
4957
+ if (total === 0) return 0;
4958
+ const rank = Math.max(1, Math.ceil(q * total));
4959
+ let cumulative = 0;
4960
+ for (const [idx, c] of entries) {
4961
+ cumulative += c;
4962
+ if (cumulative >= rank) return bucketRepresentativeMs(idx);
4963
+ }
4964
+ return bucketRepresentativeMs(entries[entries.length - 1][0]);
4965
+ }
4966
+ function round(ms) {
4967
+ return Math.round(ms * 100) / 100;
4968
+ }
4969
+ function latencyPercentiles(hist) {
4970
+ if (!hist) return void 0;
4971
+ let total = 0;
4972
+ for (const c of Object.values(hist)) total += c;
4973
+ if (total === 0) return void 0;
4974
+ return { p50: round(quantile(hist, 0.5)), p95: round(quantile(hist, 0.95)) };
4975
+ }
4976
+
4624
4977
  // src/ingest.ts
4625
4978
  var HOUR_MS = 60 * 60 * 1e3;
4626
4979
  var DAY_MS = 24 * HOUR_MS;
@@ -5316,7 +5669,7 @@ function ensureFrontierNode(graph, host, ts) {
5316
5669
  graph.addNode(id, node);
5317
5670
  return id;
5318
5671
  }
5319
- function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence) {
5672
+ function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence, durationMs) {
5320
5673
  if (!graph.hasNode(source) || !graph.hasNode(target)) return null;
5321
5674
  const grain = source.startsWith("file:") || source.startsWith("symbol:") ? "file" : "service";
5322
5675
  const id = makeObservedEdgeId(type, source, target);
@@ -5324,10 +5677,15 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5324
5677
  const existing = graph.getEdgeAttributes(id);
5325
5678
  const newSpanCount = (existing.signal?.spanCount ?? existing.callCount ?? 0) + 1;
5326
5679
  const newErrorCount = (existing.signal?.errorCount ?? 0) + (isError ? 1 : 0);
5680
+ const latencyHist2 = durationMs !== void 0 ? recordLatency({ ...existing.signal?.latencyHist ?? {} }, durationMs) : existing.signal?.latencyHist;
5681
+ const latencyMs2 = latencyPercentiles(latencyHist2) ?? existing.signal?.latencyMs;
5327
5682
  const newSignal = {
5328
5683
  spanCount: newSpanCount,
5329
5684
  errorCount: newErrorCount,
5330
- lastObservedAgeMs: 0
5685
+ lastObservedAgeMs: 0,
5686
+ ...latencyHist2 ? { latencyHist: latencyHist2 } : {},
5687
+ ...latencyMs2 ? { latencyMs: latencyMs2 } : {},
5688
+ ...existing.signal?.anomalous !== void 0 ? { anomalous: existing.signal.anomalous } : {}
5331
5689
  };
5332
5690
  const updated = {
5333
5691
  ...existing,
@@ -5342,10 +5700,14 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5342
5700
  graph.replaceEdgeAttributes(id, updated);
5343
5701
  return { edge: updated, created: false };
5344
5702
  }
5703
+ const latencyHist = durationMs !== void 0 ? recordLatency({}, durationMs) : void 0;
5704
+ const latencyMs = latencyHist ? latencyPercentiles(latencyHist) : void 0;
5345
5705
  const signal = {
5346
5706
  spanCount: 1,
5347
5707
  errorCount: isError ? 1 : 0,
5348
- lastObservedAgeMs: 0
5708
+ lastObservedAgeMs: 0,
5709
+ ...latencyHist ? { latencyHist } : {},
5710
+ ...latencyMs ? { latencyMs } : {}
5349
5711
  };
5350
5712
  const edge = {
5351
5713
  id,
@@ -5409,6 +5771,21 @@ async function appendErrorEvent(ctx, ev) {
5409
5771
  await import_node_fs7.promises.mkdir(import_node_path8.default.dirname(ctx.errorsPath), { recursive: true });
5410
5772
  await import_node_fs7.promises.appendFile(ctx.errorsPath, JSON.stringify(ev) + "\n", "utf8");
5411
5773
  }
5774
+ async function appendConnectorIncident(errorsPath, input) {
5775
+ const ev = {
5776
+ id: input.id,
5777
+ timestamp: input.timestamp,
5778
+ service: input.service,
5779
+ traceId: input.id,
5780
+ spanId: input.id,
5781
+ errorType: input.errorType,
5782
+ errorMessage: input.errorMessage,
5783
+ ...input.attributes && Object.keys(input.attributes).length > 0 ? { attributes: input.attributes } : {},
5784
+ affectedNode: input.affectedNode
5785
+ };
5786
+ await import_node_fs7.promises.mkdir(import_node_path8.default.dirname(errorsPath), { recursive: true });
5787
+ await import_node_fs7.promises.appendFile(errorsPath, JSON.stringify(ev) + "\n", "utf8");
5788
+ }
5412
5789
  function incidentAffectedNode(span, graph, scanPath) {
5413
5790
  const sid = graph ? resolveFusedServiceId(graph, span.service, span.env) : (0, import_types8.serviceId)(span.service, span.env);
5414
5791
  const serviceNode = graph && graph.hasNode(sid) ? graph.getNodeAttributes(sid) : void 0;
@@ -5555,6 +5932,7 @@ async function handleSpan(ctx, span) {
5555
5932
  }
5556
5933
  const sourceId = ensureServiceNode(ctx.graph, span.service, env);
5557
5934
  const isError = span.statusCode === 2;
5935
+ const durationMs = span.durationNanos > 0n ? Number(span.durationNanos) / 1e6 : void 0;
5558
5936
  const sourceServiceNode = ctx.graph.getNodeAttributes(sourceId);
5559
5937
  const callSite = callSiteFromSpan(span, sourceServiceNode, ctx.scanPath);
5560
5938
  cacheSpanService(span, nowMs, callSite);
@@ -5593,7 +5971,8 @@ async function handleSpan(ctx, span) {
5593
5971
  targetId,
5594
5972
  ts,
5595
5973
  isError,
5596
- callSiteEvidence
5974
+ callSiteEvidence,
5975
+ durationMs
5597
5976
  );
5598
5977
  if (result) affectedNode = targetId;
5599
5978
  if (span.dbSystem === "mongodb" && span.dbCollection) {
@@ -5605,7 +5984,8 @@ async function handleSpan(ctx, span) {
5605
5984
  collectionId,
5606
5985
  ts,
5607
5986
  isError,
5608
- callSiteEvidence
5987
+ callSiteEvidence,
5988
+ durationMs
5609
5989
  );
5610
5990
  }
5611
5991
  if (span.dbTable) {
@@ -5617,7 +5997,8 @@ async function handleSpan(ctx, span) {
5617
5997
  tableId,
5618
5998
  ts,
5619
5999
  isError,
5620
- callSiteEvidence
6000
+ callSiteEvidence,
6001
+ durationMs
5621
6002
  );
5622
6003
  mergeObservedColumns(ctx.graph, tableId, span.dbColumns);
5623
6004
  }
@@ -5636,7 +6017,8 @@ async function handleSpan(ctx, span) {
5636
6017
  targetId,
5637
6018
  ts,
5638
6019
  isError,
5639
- callSiteEvidence
6020
+ callSiteEvidence,
6021
+ durationMs
5640
6022
  );
5641
6023
  if (result) affectedNode = targetId;
5642
6024
  } else if (span.graphqlOperationName && span.graphqlOperationType && spanServesGraphqlOperation(span.kind)) {
@@ -5653,7 +6035,8 @@ async function handleSpan(ctx, span) {
5653
6035
  targetId,
5654
6036
  ts,
5655
6037
  isError,
5656
- callSiteEvidence
6038
+ callSiteEvidence,
6039
+ durationMs
5657
6040
  );
5658
6041
  if (result) affectedNode = targetId;
5659
6042
  } else if (span.rpcSystem === "grpc" && span.rpcService && span.rpcMethod && spanServesGrpcMethod(span.kind)) {
@@ -5665,7 +6048,8 @@ async function handleSpan(ctx, span) {
5665
6048
  targetId,
5666
6049
  ts,
5667
6050
  isError,
5668
- callSiteEvidence
6051
+ callSiteEvidence,
6052
+ durationMs
5669
6053
  );
5670
6054
  if (result) affectedNode = targetId;
5671
6055
  } else if (span.websocketChannel && spanServesWebsocketChannel(span.kind)) {
@@ -5681,7 +6065,8 @@ async function handleSpan(ctx, span) {
5681
6065
  targetId,
5682
6066
  ts,
5683
6067
  isError,
5684
- callSiteEvidence
6068
+ callSiteEvidence,
6069
+ durationMs
5685
6070
  );
5686
6071
  if (result) affectedNode = targetId;
5687
6072
  } else {
@@ -5697,7 +6082,8 @@ async function handleSpan(ctx, span) {
5697
6082
  targetId,
5698
6083
  ts,
5699
6084
  isError,
5700
- callSiteEvidence
6085
+ callSiteEvidence,
6086
+ durationMs
5701
6087
  );
5702
6088
  affectedNode = targetId;
5703
6089
  resolvedViaAddress = true;
@@ -5710,7 +6096,8 @@ async function handleSpan(ctx, span) {
5710
6096
  frontierNodeId,
5711
6097
  ts,
5712
6098
  isError,
5713
- callSiteEvidence
6099
+ callSiteEvidence,
6100
+ durationMs
5714
6101
  );
5715
6102
  affectedNode = frontierNodeId;
5716
6103
  resolvedViaAddress = true;
@@ -5736,7 +6123,8 @@ async function handleSpan(ctx, span) {
5736
6123
  sourceId,
5737
6124
  ts,
5738
6125
  isError,
5739
- fallbackEvidence
6126
+ fallbackEvidence,
6127
+ durationMs
5740
6128
  );
5741
6129
  }
5742
6130
  }
@@ -5750,7 +6138,7 @@ async function handleSpan(ctx, span) {
5750
6138
  );
5751
6139
  if (routeNodeId) {
5752
6140
  const routeSvc = ctx.graph.getNodeAttributes(routeNodeId).service;
5753
- upsertObservedEdge(ctx.graph, import_types8.EdgeType.CONTAINS, (0, import_types8.serviceId)(routeSvc), routeNodeId, ts, isError);
6141
+ upsertObservedEdge(ctx.graph, import_types8.EdgeType.CONTAINS, (0, import_types8.serviceId)(routeSvc), routeNodeId, ts, isError, void 0, durationMs);
5754
6142
  }
5755
6143
  }
5756
6144
  if (span.statusCode === 2) {
@@ -11222,8 +11610,41 @@ var import_tree_sitter14 = __toESM(require("tree-sitter"), 1);
11222
11610
  var import_tree_sitter_go3 = __toESM(require("tree-sitter-go"), 1);
11223
11611
  var import_types35 = require("@neat.is/types");
11224
11612
  init_otel();
11225
- var SQL_METHODS = /* @__PURE__ */ new Set(["Exec", "ExecContext", "Query", "QueryContext", "QueryRow", "QueryRowContext"]);
11226
11613
  var PARSE_CHUNK10 = 16384;
11614
+ var DATABASE_SQL_METHODS = /* @__PURE__ */ new Set([
11615
+ "Query",
11616
+ "QueryContext",
11617
+ "QueryRow",
11618
+ "QueryRowContext",
11619
+ "Exec",
11620
+ "ExecContext",
11621
+ "Prepare",
11622
+ "PrepareContext"
11623
+ ]);
11624
+ var SQLX_METHODS = /* @__PURE__ */ new Set([
11625
+ "Get",
11626
+ "Select",
11627
+ "Queryx",
11628
+ "QueryRowx",
11629
+ "NamedExec",
11630
+ "NamedQuery",
11631
+ "MustExec",
11632
+ "Preparex",
11633
+ "GetContext",
11634
+ "SelectContext"
11635
+ ]);
11636
+ var DATABASE_SQL_IMPORT = "database/sql";
11637
+ var SQLX_IMPORT = "github.com/jmoiron/sqlx";
11638
+ function makeGoParser3() {
11639
+ const p = new import_tree_sitter14.default();
11640
+ p.setLanguage(import_tree_sitter_go3.default);
11641
+ return p;
11642
+ }
11643
+ function parseSource10(parser, source) {
11644
+ return parser.parse(
11645
+ (index) => index >= source.length ? "" : source.slice(index, index + PARSE_CHUNK10)
11646
+ );
11647
+ }
11227
11648
  function walk7(node, visit) {
11228
11649
  visit(node);
11229
11650
  for (let i = 0; i < node.namedChildCount; i++) {
@@ -11231,25 +11652,54 @@ function walk7(node, visit) {
11231
11652
  if (child) walk7(child, visit);
11232
11653
  }
11233
11654
  }
11655
+ function goStringLiteralValue(node) {
11656
+ if (!node) return null;
11657
+ if (node.type === "interpreted_string_literal" || node.type === "raw_string_literal") {
11658
+ const t = node.text;
11659
+ return t.length >= 2 ? t.slice(1, -1) : "";
11660
+ }
11661
+ return null;
11662
+ }
11663
+ function goImportsAny(root, names) {
11664
+ let found = false;
11665
+ walk7(root, (node) => {
11666
+ if (found || node.type !== "import_spec") return;
11667
+ for (let i = 0; i < node.namedChildCount; i++) {
11668
+ const value = goStringLiteralValue(node.namedChild(i));
11669
+ if (value !== null && names.has(value)) found = true;
11670
+ }
11671
+ });
11672
+ return found;
11673
+ }
11674
+ function firstStringLiteralArg(argsNode) {
11675
+ if (!argsNode) return null;
11676
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
11677
+ const value = goStringLiteralValue(argsNode.namedChild(i));
11678
+ if (value !== null) return value;
11679
+ }
11680
+ return null;
11681
+ }
11234
11682
  function goSqlEndpointsFromFile(file, serviceDir) {
11235
11683
  if (import_node_path47.default.extname(file.path) !== ".go") return [];
11236
- const parser = new import_tree_sitter14.default();
11237
- parser.setLanguage(import_tree_sitter_go3.default);
11238
- const tree = parser.parse(
11239
- (index) => index >= file.content.length ? "" : file.content.slice(index, index + PARSE_CHUNK10)
11240
- );
11684
+ if (!file.content.includes(DATABASE_SQL_IMPORT) && !file.content.includes(SQLX_IMPORT)) return [];
11685
+ const tree = parseSource10(makeGoParser3(), file.content);
11686
+ const importsDatabaseSql = goImportsAny(tree.rootNode, /* @__PURE__ */ new Set([DATABASE_SQL_IMPORT]));
11687
+ const importsSqlx = goImportsAny(tree.rootNode, /* @__PURE__ */ new Set([SQLX_IMPORT]));
11688
+ if (!importsDatabaseSql && !importsSqlx) return [];
11241
11689
  const out = [];
11242
11690
  walk7(tree.rootNode, (node) => {
11243
11691
  if (node.type !== "call_expression") return;
11244
11692
  const fn = node.childForFieldName("function");
11245
11693
  if (fn?.type !== "selector_expression") return;
11246
11694
  const method = fn.childForFieldName("field")?.text;
11247
- if (!method || !SQL_METHODS.has(method)) return;
11248
- const arg = node.childForFieldName("arguments")?.namedChild(0);
11249
- if (!arg || arg.type !== "interpreted_string_literal" && arg.type !== "raw_string_literal") return;
11250
- const sql = arg.text.slice(1, -1);
11695
+ if (!method) return;
11696
+ const recognized = DATABASE_SQL_METHODS.has(method) || importsSqlx && SQLX_METHODS.has(method);
11697
+ if (!recognized) return;
11698
+ const sql = firstStringLiteralArg(node.childForFieldName("arguments"));
11699
+ if (sql === null) return;
11251
11700
  const table = tableFromSqlStatement(sql);
11252
11701
  if (!table) return;
11702
+ const columns = columnsFromSqlStatement(sql);
11253
11703
  const line = node.startPosition.row + 1;
11254
11704
  out.push({
11255
11705
  infraId: (0, import_types35.infraId)("sql-table", table),
@@ -11257,7 +11707,12 @@ function goSqlEndpointsFromFile(file, serviceDir) {
11257
11707
  kind: "sql-table",
11258
11708
  edgeType: "CALLS",
11259
11709
  confidenceKind: "verified-call-site",
11260
- evidence: { file: toPosix(import_node_path47.default.relative(serviceDir, file.path)), line, snippet: snippet(file.content, line) }
11710
+ ...columns.length > 0 ? { columns } : {},
11711
+ evidence: {
11712
+ file: toPosix(import_node_path47.default.relative(serviceDir, file.path)),
11713
+ line,
11714
+ snippet: snippet(file.content, line)
11715
+ }
11261
11716
  });
11262
11717
  });
11263
11718
  return out;
@@ -11271,12 +11726,12 @@ var import_tree_sitter_go4 = __toESM(require("tree-sitter-go"), 1);
11271
11726
  var import_types36 = require("@neat.is/types");
11272
11727
  var GORM_IMPORT_RE = /gorm\.io\/gorm/;
11273
11728
  var PARSE_CHUNK11 = 16384;
11274
- function makeGoParser3() {
11729
+ function makeGoParser4() {
11275
11730
  const p = new import_tree_sitter15.default();
11276
11731
  p.setLanguage(import_tree_sitter_go4.default);
11277
11732
  return p;
11278
11733
  }
11279
- function parseSource10(parser, source) {
11734
+ function parseSource11(parser, source) {
11280
11735
  return parser.parse(
11281
11736
  (index) => index >= source.length ? "" : source.slice(index, index + PARSE_CHUNK11)
11282
11737
  );
@@ -11700,7 +12155,7 @@ function collectColumns(struct, structs, seen, prefix, out, emitted) {
11700
12155
  function gormEndpointsFromFile(file, serviceDir) {
11701
12156
  if (import_node_path48.default.extname(file.path) !== ".go") return [];
11702
12157
  if (!GORM_IMPORT_RE.test(file.content)) return [];
11703
- const tree = parseSource10(makeGoParser3(), file.content);
12158
+ const tree = parseSource11(makeGoParser4(), file.content);
11704
12159
  const { structs, models, tableFor } = analyze(tree);
11705
12160
  const out = [];
11706
12161
  const seenTables = /* @__PURE__ */ new Set();
@@ -11731,7 +12186,7 @@ function gormEndpointsFromFile(file, serviceDir) {
11731
12186
  function gormForeignKeys(file, serviceDir) {
11732
12187
  if (import_node_path48.default.extname(file.path) !== ".go") return [];
11733
12188
  if (!GORM_IMPORT_RE.test(file.content)) return [];
11734
- const tree = parseSource10(makeGoParser3(), file.content);
12189
+ const tree = parseSource11(makeGoParser4(), file.content);
11735
12190
  const { structs, models, tableFor } = analyze(tree);
11736
12191
  const out = [];
11737
12192
  const seen = /* @__PURE__ */ new Set();
@@ -13474,7 +13929,7 @@ function startPersistLoop(graph, outPath, opts = {}) {
13474
13929
  init_cjs_shims();
13475
13930
  var import_fastify2 = __toESM(require("fastify"), 1);
13476
13931
  var import_cors = __toESM(require("@fastify/cors"), 1);
13477
- var import_types80 = require("@neat.is/types");
13932
+ var import_types85 = require("@neat.is/types");
13478
13933
 
13479
13934
  // src/extend/index.ts
13480
13935
  init_cjs_shims();
@@ -14980,6 +15435,23 @@ async function runConnectorPoll(connector, ctx, graph, resolveTarget) {
14980
15435
  unresolved++;
14981
15436
  continue;
14982
15437
  }
15438
+ if (signal.incident) {
15439
+ ensureServiceNode(graph, resolved.serviceName, NO_ENV);
15440
+ if (!ctx.errorsPath || !graph.hasNode(resolved.targetNodeId)) {
15441
+ unresolved++;
15442
+ continue;
15443
+ }
15444
+ await appendConnectorIncident(ctx.errorsPath, {
15445
+ id: signal.incident.id,
15446
+ timestamp: signal.incident.timestamp,
15447
+ service: signal.incident.service,
15448
+ errorType: signal.incident.errorType,
15449
+ errorMessage: signal.incident.errorMessage,
15450
+ ...signal.incident.attributes ? { attributes: signal.incident.attributes } : {},
15451
+ affectedNode: resolved.targetNodeId
15452
+ });
15453
+ continue;
15454
+ }
14983
15455
  if (resolved.ensureInfraNode) {
14984
15456
  const { kind, name, provider } = resolved.ensureInfraNode;
14985
15457
  ensureInfraNode(graph, kind, name, provider);
@@ -17214,6 +17686,338 @@ function createPlanetscaleConnector(graph, config, deps = {}) {
17214
17686
  };
17215
17687
  }
17216
17688
 
17689
+ // src/connectors/eas/index.ts
17690
+ init_cjs_shims();
17691
+
17692
+ // src/connectors/eas/client.ts
17693
+ init_cjs_shims();
17694
+
17695
+ // src/connectors/eas/types.ts
17696
+ init_cjs_shims();
17697
+ function readEasCredentials(raw) {
17698
+ const token = raw["token"];
17699
+ if (typeof token !== "string" || token.length === 0) {
17700
+ throw new Error("eas connector: credentials.token (EXPO_TOKEN) must be a non-empty string");
17701
+ }
17702
+ return { token };
17703
+ }
17704
+ var EAS_STATUS_ERRORED = "ERRORED";
17705
+ var TRANSIENT_BUILD_PHASES = /* @__PURE__ */ new Set([
17706
+ "SPIN_UP_BUILDER",
17707
+ "PREPARE_CREDENTIALS",
17708
+ "RESTORE_CACHE",
17709
+ "UPLOAD_APPLICATION_ARCHIVE"
17710
+ ]);
17711
+ var INTERNAL_ERROR_CODE = /INTERNAL_SERVER_ERROR|EAS_BUILD_.*INTERNAL|_INTERNAL_ERROR|UNKNOWN_ERROR/i;
17712
+ function isTransientFailure(err) {
17713
+ if (!err) return false;
17714
+ const phase = typeof err.buildPhase === "string" ? err.buildPhase : void 0;
17715
+ if (phase) {
17716
+ if (TRANSIENT_BUILD_PHASES.has(phase)) return true;
17717
+ if (/^SPIN_UP|_CREDENTIALS$|^RESTORE_CACHE/i.test(phase)) return true;
17718
+ }
17719
+ const code = typeof err.errorCode === "string" ? err.errorCode : void 0;
17720
+ if (code && INTERNAL_ERROR_CODE.test(code)) return true;
17721
+ return false;
17722
+ }
17723
+ var FIELD_SEP3 = "\0";
17724
+ var EAS_TARGET_KIND = "eas-build";
17725
+ function packEasTargetName(identity) {
17726
+ return [identity.serviceName, identity.phase].join(FIELD_SEP3);
17727
+ }
17728
+ function parseEasTargetName(targetName) {
17729
+ const sep = targetName.indexOf(FIELD_SEP3);
17730
+ if (sep === -1) return null;
17731
+ const serviceName = targetName.slice(0, sep);
17732
+ const phase = targetName.slice(sep + 1);
17733
+ if (!serviceName) return null;
17734
+ return { serviceName, phase };
17735
+ }
17736
+
17737
+ // src/connectors/eas/client.ts
17738
+ var DEFAULT_EAS_API_URL = "https://api.expo.dev/graphql";
17739
+ var DEFAULT_PAGE_SIZE = 50;
17740
+ var DEFAULT_MAX_PAGES = 10;
17741
+ var DEFAULT_MAX_LOG_BYTES = 16 * 1024;
17742
+ var DEFAULT_MAX_LOOKBACK_MS6 = 7 * 24 * 60 * 60 * 1e3;
17743
+ var BUILDS_QUERY = `
17744
+ query NeatEasErroredBuilds($appId: String!, $offset: Int!, $limit: Int!) {
17745
+ app {
17746
+ byId(appId: $appId) {
17747
+ builds(offset: $offset, limit: $limit, filter: { status: ERRORED }) {
17748
+ id
17749
+ status
17750
+ platform
17751
+ buildProfile
17752
+ gitCommitHash
17753
+ gitCommitMessage
17754
+ gitRef
17755
+ isGitWorkingTreeDirty
17756
+ createdAt
17757
+ completedAt
17758
+ error {
17759
+ buildPhase
17760
+ errorCode
17761
+ message
17762
+ docsUrl
17763
+ }
17764
+ logFileUrls
17765
+ }
17766
+ }
17767
+ }
17768
+ }
17769
+ `;
17770
+ async function easGraphQL(apiUrl, token, query, variables, accountKey, fetchImpl) {
17771
+ const res = await junctionFetch(
17772
+ apiUrl,
17773
+ {
17774
+ method: "POST",
17775
+ headers: {
17776
+ "Content-Type": "application/json",
17777
+ ...bearerAuthHeader(token)
17778
+ },
17779
+ body: JSON.stringify({ query, variables })
17780
+ },
17781
+ // accountKey: the Expo app id — the per-(provider, accountKey) rate-limit
17782
+ // bucket (ADR-131), the closest thing this connector carries to "one account".
17783
+ { provider: "eas", accountKey, ...fetchImpl ? { fetchImpl } : {} }
17784
+ );
17785
+ if (!res.ok) {
17786
+ throw new Error(`Expo GraphQL request failed: ${res.status} ${res.statusText}`);
17787
+ }
17788
+ const body = await res.json();
17789
+ if (body.errors && body.errors.length > 0) {
17790
+ throw new Error(`Expo GraphQL errors: ${body.errors.map((e) => e.message).join("; ")}`);
17791
+ }
17792
+ if (!body.data) throw new Error("Expo GraphQL response carried no data");
17793
+ return body.data;
17794
+ }
17795
+ async function fetchErroredBuilds(token, config, fetchImpl) {
17796
+ const apiUrl = config.apiUrl ?? DEFAULT_EAS_API_URL;
17797
+ const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE;
17798
+ const maxPages = config.maxPages ?? DEFAULT_MAX_PAGES;
17799
+ const out = [];
17800
+ const seen = /* @__PURE__ */ new Set();
17801
+ for (let page = 0; page < maxPages; page++) {
17802
+ const data = await easGraphQL(
17803
+ apiUrl,
17804
+ token,
17805
+ BUILDS_QUERY,
17806
+ { appId: config.appId, offset: page * pageSize, limit: pageSize },
17807
+ config.appId,
17808
+ fetchImpl
17809
+ );
17810
+ const builds = data.app?.byId?.builds;
17811
+ if (!Array.isArray(builds)) break;
17812
+ let added = 0;
17813
+ for (const b of builds) {
17814
+ if (!b || typeof b.id !== "string" || b.id.length === 0) continue;
17815
+ if (b.status !== EAS_STATUS_ERRORED) continue;
17816
+ if (seen.has(b.id)) continue;
17817
+ seen.add(b.id);
17818
+ out.push(b);
17819
+ added++;
17820
+ }
17821
+ if (builds.length < pageSize) break;
17822
+ if (added === 0) break;
17823
+ }
17824
+ return out;
17825
+ }
17826
+ async function fetchBuildLogs(logFileUrls, maxBytes = DEFAULT_MAX_LOG_BYTES, fetchImpl) {
17827
+ if (!Array.isArray(logFileUrls) || logFileUrls.length === 0) return "";
17828
+ const doFetch = fetchImpl ?? fetch;
17829
+ const chunks = [];
17830
+ for (const url of logFileUrls) {
17831
+ if (typeof url !== "string" || url.length === 0) continue;
17832
+ try {
17833
+ const res = await doFetch(url);
17834
+ if (!res.ok) continue;
17835
+ chunks.push(await res.text());
17836
+ } catch {
17837
+ }
17838
+ }
17839
+ const joined = chunks.join("\n");
17840
+ return joined.length > maxBytes ? joined.slice(joined.length - maxBytes) : joined;
17841
+ }
17842
+
17843
+ // src/connectors/eas/map.ts
17844
+ init_cjs_shims();
17845
+ function buildEventTime(build) {
17846
+ if (typeof build.completedAt === "string" && build.completedAt.length > 0) return build.completedAt;
17847
+ if (typeof build.createdAt === "string" && build.createdAt.length > 0) return build.createdAt;
17848
+ return (/* @__PURE__ */ new Date()).toISOString();
17849
+ }
17850
+ function incidentMessage2(build) {
17851
+ const err = build.error ?? {};
17852
+ const phase = typeof err.buildPhase === "string" && err.buildPhase.length > 0 ? ` at ${err.buildPhase}` : "";
17853
+ 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";
17854
+ let msg = `EAS build failed${phase}: ${detail}`;
17855
+ if (build.isGitWorkingTreeDirty === true) {
17856
+ msg += " (built from a dirty working tree \u2014 the commit may not represent what built)";
17857
+ }
17858
+ return msg;
17859
+ }
17860
+ function incidentAttributes(build) {
17861
+ const attrs = {};
17862
+ const err = build.error ?? {};
17863
+ const put = (k, v) => {
17864
+ if (typeof v === "string" && v.length === 0) return;
17865
+ if (v !== void 0 && v !== null) attrs[k] = v;
17866
+ };
17867
+ put("eas.buildId", build.id);
17868
+ put("eas.platform", build.platform ?? void 0);
17869
+ put("eas.buildProfile", build.buildProfile ?? void 0);
17870
+ put("eas.buildPhase", err.buildPhase ?? void 0);
17871
+ put("eas.errorCode", err.errorCode ?? void 0);
17872
+ put("eas.docsUrl", err.docsUrl ?? void 0);
17873
+ put("eas.gitCommitHash", build.gitCommitHash ?? void 0);
17874
+ put("eas.gitRef", build.gitRef ?? void 0);
17875
+ put("eas.gitCommitMessage", build.gitCommitMessage ?? void 0);
17876
+ if (typeof build.isGitWorkingTreeDirty === "boolean") {
17877
+ attrs["eas.gitWorkingTreeDirty"] = build.isGitWorkingTreeDirty;
17878
+ if (build.isGitWorkingTreeDirty) attrs["eas.confidence"] = "low";
17879
+ }
17880
+ put("eas.createdAt", build.createdAt ?? void 0);
17881
+ put("eas.completedAt", build.completedAt ?? void 0);
17882
+ if (typeof build.logsText === "string" && build.logsText.length > 0) {
17883
+ attrs["eas.logs"] = build.logsText;
17884
+ }
17885
+ return attrs;
17886
+ }
17887
+ function mapBuildToSignal(build, serviceName) {
17888
+ if (!build || typeof build !== "object") return null;
17889
+ if (build.status !== EAS_STATUS_ERRORED) return null;
17890
+ if (!build.error) return null;
17891
+ if (isTransientFailure(build.error)) return null;
17892
+ const timestamp = buildEventTime(build);
17893
+ const phase = typeof build.error.buildPhase === "string" ? build.error.buildPhase : "";
17894
+ return {
17895
+ targetKind: EAS_TARGET_KIND,
17896
+ targetName: packEasTargetName({ serviceName, phase }),
17897
+ // Incident-only — no edge, so no call/error count to replay.
17898
+ callCount: 0,
17899
+ errorCount: 0,
17900
+ lastObservedIso: timestamp,
17901
+ incident: {
17902
+ id: `eas:build:${build.id}`,
17903
+ timestamp,
17904
+ service: serviceName,
17905
+ errorType: "eas-build-failure",
17906
+ errorMessage: incidentMessage2(build),
17907
+ attributes: incidentAttributes(build)
17908
+ }
17909
+ };
17910
+ }
17911
+ function mapBuildsToSignals(builds, serviceName) {
17912
+ const out = [];
17913
+ for (const build of builds) {
17914
+ const signal = mapBuildToSignal(build, serviceName);
17915
+ if (signal) out.push(signal);
17916
+ }
17917
+ return out;
17918
+ }
17919
+
17920
+ // src/connectors/eas/resolve.ts
17921
+ init_cjs_shims();
17922
+ var import_types82 = require("@neat.is/types");
17923
+ var NO_ENV2 = "unknown";
17924
+ var EAS_JSON_PHASES = /* @__PURE__ */ new Set(["READ_EAS_JSON"]);
17925
+ var APP_CONFIG_PHASES = /* @__PURE__ */ new Set([
17926
+ "READ_APP_CONFIG",
17927
+ "CONFIGURE_EXPO_UPDATES",
17928
+ "CALCULATE_EXPO_UPDATES_RUNTIME_VERSION"
17929
+ ]);
17930
+ function configBasenamesForPhase(phase) {
17931
+ if (EAS_JSON_PHASES.has(phase)) return ["eas.json"];
17932
+ if (APP_CONFIG_PHASES.has(phase)) return ["app.json", "app.config.json"];
17933
+ return [];
17934
+ }
17935
+ function configNodeService(graph, configNodeId) {
17936
+ for (const edgeId of graph.inboundEdges(configNodeId)) {
17937
+ const edge = graph.getEdgeAttributes(edgeId);
17938
+ if (edge.type !== import_types82.EdgeType.CONFIGURED_BY) continue;
17939
+ const parsed = (0, import_types82.parseFileId)(edge.source);
17940
+ if (parsed) return parsed.service;
17941
+ }
17942
+ return null;
17943
+ }
17944
+ function findConfigNode(graph, basenames, serviceName) {
17945
+ let scoped = null;
17946
+ let anyMatch = null;
17947
+ graph.forEachNode((id, attrs) => {
17948
+ if (scoped) return;
17949
+ const node = attrs;
17950
+ if (node.type !== import_types82.NodeType.ConfigNode) return;
17951
+ if (typeof node.name !== "string" || !basenames.includes(node.name)) return;
17952
+ if (anyMatch === null) anyMatch = id;
17953
+ if (configNodeService(graph, id) === serviceName) scoped = id;
17954
+ });
17955
+ return scoped ?? anyMatch;
17956
+ }
17957
+ function createEasResolveTarget(graph) {
17958
+ return (signal) => {
17959
+ if (signal.targetKind !== EAS_TARGET_KIND) return null;
17960
+ const identity = parseEasTargetName(signal.targetName);
17961
+ if (!identity) return null;
17962
+ const { serviceName, phase } = identity;
17963
+ const basenames = configBasenamesForPhase(phase);
17964
+ if (basenames.length > 0) {
17965
+ const configNodeId = findConfigNode(graph, basenames, serviceName);
17966
+ if (configNodeId) {
17967
+ return { targetNodeId: configNodeId, serviceName, edgeType: import_types82.EdgeType.CALLS };
17968
+ }
17969
+ }
17970
+ return {
17971
+ targetNodeId: resolveFusedServiceId(graph, serviceName, NO_ENV2),
17972
+ serviceName,
17973
+ edgeType: import_types82.EdgeType.CALLS
17974
+ };
17975
+ };
17976
+ }
17977
+
17978
+ // src/connectors/eas/index.ts
17979
+ function isBuildSince(build, sinceIso) {
17980
+ const t = Date.parse(buildEventTime(build));
17981
+ const s = Date.parse(sinceIso);
17982
+ if (Number.isNaN(t) || Number.isNaN(s)) return true;
17983
+ return t > s;
17984
+ }
17985
+ function boundedSinceIso2(since, now, maxLookbackMs) {
17986
+ const floor = new Date(now.getTime() - maxLookbackMs);
17987
+ if (!since) return floor.toISOString();
17988
+ const sinceMs = new Date(since).getTime();
17989
+ if (Number.isNaN(sinceMs)) return floor.toISOString();
17990
+ return sinceMs < floor.getTime() ? floor.toISOString() : new Date(sinceMs).toISOString();
17991
+ }
17992
+ var EasConnector = class {
17993
+ constructor(config, fetchImpl) {
17994
+ this.config = config;
17995
+ this.fetchImpl = fetchImpl;
17996
+ }
17997
+ config;
17998
+ fetchImpl;
17999
+ provider = "eas";
18000
+ async poll(ctx) {
18001
+ const creds = readEasCredentials(ctx.credentials);
18002
+ const serviceName = this.config.serviceName ?? this.config.appId;
18003
+ const maxLookbackMs = this.config.maxLookbackMs ?? DEFAULT_MAX_LOOKBACK_MS6;
18004
+ const sinceIso = boundedSinceIso2(ctx.since, /* @__PURE__ */ new Date(), maxLookbackMs);
18005
+ const builds = await fetchErroredBuilds(creds.token, this.config, this.fetchImpl);
18006
+ const fresh = builds.filter((b) => isBuildSince(b, sinceIso));
18007
+ const maxLogBytes = this.config.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES;
18008
+ for (const build of fresh) {
18009
+ build.logsText = await fetchBuildLogs(build.logFileUrls, maxLogBytes, this.fetchImpl);
18010
+ }
18011
+ return mapBuildsToSignals(fresh, serviceName);
18012
+ }
18013
+ };
18014
+ function createEasConnector(graph, config, fetchImpl) {
18015
+ return {
18016
+ connector: new EasConnector(config, fetchImpl),
18017
+ resolveTarget: createEasResolveTarget(graph)
18018
+ };
18019
+ }
18020
+
17217
18021
  // src/connectors/registry.ts
17218
18022
  var CLOUDFLARE_API_BASE_URL = "https://api.cloudflare.com/client/v4";
17219
18023
  async function authProbe(input) {
@@ -17501,6 +18305,41 @@ var PROVIDER_DISPATCH = {
17501
18305
  ...fetchImpl ? { fetchImpl } : {}
17502
18306
  });
17503
18307
  }
18308
+ },
18309
+ eas: {
18310
+ provider: "eas",
18311
+ // The secret is a single robot-user EXPO_TOKEN; a single-string credential
18312
+ // maps to `token`. `appId` is non-secret config (connector-config.md §7.1).
18313
+ primaryCredentialKey: "token",
18314
+ requiredCredentialFields: ["token"],
18315
+ requiredOptionFields: ["appId"],
18316
+ build(graph, options) {
18317
+ return createEasConnector(graph, options);
18318
+ },
18319
+ // Runs the connector's real `builds` query at limit 1 — the exact read poll()
18320
+ // performs, minus the pages — so the probe checks both that the EXPO_TOKEN
18321
+ // authenticates and that this app id is reachable, the same probe-the-real-
18322
+ // query discipline Railway and Cloud Run use over a trivial `{ __typename }`.
18323
+ // A bad or wrong-scoped token comes back as an Expo GraphQL error, which
18324
+ // `fetchErroredBuilds` throws on, so it fails honestly here rather than
18325
+ // silently at the first poll.
18326
+ async validate({ credentials, options, fetchImpl }) {
18327
+ const cfg = options;
18328
+ const appId = String(cfg.appId ?? "");
18329
+ if (!appId) return { ok: false, reason: "eas: appId is required to validate" };
18330
+ const probeConfig = {
18331
+ appId,
18332
+ pageSize: 1,
18333
+ maxPages: 1,
18334
+ ...cfg.apiUrl ? { apiUrl: cfg.apiUrl } : {}
18335
+ };
18336
+ try {
18337
+ await fetchErroredBuilds(String(credentials.token ?? ""), probeConfig, fetchImpl);
18338
+ return { ok: true };
18339
+ } catch (err) {
18340
+ return { ok: false, reason: `eas auth check failed: ${err.message}` };
18341
+ }
18342
+ }
17504
18343
  }
17505
18344
  };
17506
18345
  function vercelCredsFrom(credentials) {
@@ -17682,7 +18521,11 @@ async function startConnectorPolling(input) {
17682
18521
  const stopFns = all.map(
17683
18522
  (registration) => startConnectorPollLoop(
17684
18523
  registration.connector,
17685
- { projectDir: input.projectDir, credentials: registration.credentials },
18524
+ {
18525
+ projectDir: input.projectDir,
18526
+ credentials: registration.credentials,
18527
+ ...input.errorsPath ? { errorsPath: input.errorsPath } : {}
18528
+ },
17686
18529
  input.graph,
17687
18530
  registration.resolveTarget,
17688
18531
  { intervalMs: registration.intervalMs, connectorId: registration.id }
@@ -17852,11 +18695,11 @@ function registerRoutes(scope, ctx) {
17852
18695
  const candidates = req.query.type.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
17853
18696
  const parsed = [];
17854
18697
  for (const c of candidates) {
17855
- const r = import_types80.DivergenceTypeSchema.safeParse(c);
18698
+ const r = import_types85.DivergenceTypeSchema.safeParse(c);
17856
18699
  if (!r.success) {
17857
18700
  return reply.code(400).send({
17858
18701
  error: `unknown divergence type "${c}"`,
17859
- allowed: import_types80.DivergenceTypeSchema.options
18702
+ allowed: import_types85.DivergenceTypeSchema.options
17860
18703
  });
17861
18704
  }
17862
18705
  parsed.push(r.data);
@@ -17963,10 +18806,15 @@ function registerRoutes(scope, ctx) {
17963
18806
  }
17964
18807
  const reg = built.registration;
17965
18808
  const at = (/* @__PURE__ */ new Date()).toISOString();
18809
+ const incidentsPath = errorsPathFor(proj);
17966
18810
  try {
17967
18811
  const result = await ctx.runPoll(
17968
18812
  reg.connector,
17969
- { projectDir: proj.scanPath ?? "", credentials: reg.credentials },
18813
+ {
18814
+ projectDir: proj.scanPath ?? "",
18815
+ credentials: reg.credentials,
18816
+ ...incidentsPath ? { errorsPath: incidentsPath } : {}
18817
+ },
17970
18818
  proj.graph,
17971
18819
  reg.resolveTarget
17972
18820
  );
@@ -18036,6 +18884,34 @@ function registerRoutes(scope, ctx) {
18036
18884
  }
18037
18885
  return getBlastRadius(proj.graph, nodeId, depth);
18038
18886
  });
18887
+ scope.get("/graph/expand/:nodeId", async (req, reply) => {
18888
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18889
+ if (!proj) return;
18890
+ const { nodeId } = req.params;
18891
+ if (!proj.graph.hasNode(nodeId)) {
18892
+ return reply.code(404).send({ error: "node not found", id: nodeId });
18893
+ }
18894
+ const direction = req.query.direction;
18895
+ if (direction !== "up" && direction !== "down") {
18896
+ return reply.code(400).send({ error: 'direction must be "up" or "down"' });
18897
+ }
18898
+ const epath = errorsPathFor(proj);
18899
+ const incidents = epath ? await readErrorEvents(epath) : [];
18900
+ return expandNode(proj.graph, nodeId, direction, incidents);
18901
+ });
18902
+ scope.get("/graph/relate", async (req, reply) => {
18903
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18904
+ if (!proj) return;
18905
+ const { a, b } = req.query;
18906
+ if (!a || !b) {
18907
+ return reply.code(400).send({ error: "both a and b query params are required" });
18908
+ }
18909
+ const maxDepth = req.query.maxDepth ? Number(req.query.maxDepth) : void 0;
18910
+ if (maxDepth !== void 0 && (!Number.isFinite(maxDepth) || maxDepth < 1)) {
18911
+ return reply.code(400).send({ error: "maxDepth must be a positive integer" });
18912
+ }
18913
+ return relate(proj.graph, a, b, maxDepth);
18914
+ });
18039
18915
  scope.get("/search", async (req, reply) => {
18040
18916
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18041
18917
  if (!proj) return;
@@ -18165,7 +19041,7 @@ function registerRoutes(scope, ctx) {
18165
19041
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
18166
19042
  let violations = await log.readAll();
18167
19043
  if (req.query.severity) {
18168
- const sev = import_types80.PolicySeveritySchema.safeParse(req.query.severity);
19044
+ const sev = import_types85.PolicySeveritySchema.safeParse(req.query.severity);
18169
19045
  if (!sev.success) {
18170
19046
  return reply.code(400).send({
18171
19047
  error: "invalid severity",
@@ -18204,7 +19080,7 @@ function registerRoutes(scope, ctx) {
18204
19080
  scope.post("/policies/check", async (req, reply) => {
18205
19081
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18206
19082
  if (!proj) return;
18207
- const parsed = import_types80.PoliciesCheckBodySchema.safeParse(req.body ?? {});
19083
+ const parsed = import_types85.PoliciesCheckBodySchema.safeParse(req.body ?? {});
18208
19084
  if (!parsed.success) {
18209
19085
  return reply.code(400).send({
18210
19086
  error: "invalid /policies/check body",
@@ -18553,7 +19429,7 @@ function unroutedErrorsPath(neatHome3) {
18553
19429
  }
18554
19430
 
18555
19431
  // src/daemon.ts
18556
- var import_types81 = require("@neat.is/types");
19432
+ var import_types86 = require("@neat.is/types");
18557
19433
  function daemonJsonPath(scanPath) {
18558
19434
  return import_node_path67.default.join(scanPath, "neat-out", "daemon.json");
18559
19435
  }
@@ -18678,7 +19554,7 @@ function spanBelongsToSingleProject(graph, project, serviceName) {
18678
19554
  if (!serviceName) return true;
18679
19555
  if (serviceNameMatchesProject(serviceName, project)) return true;
18680
19556
  return graph.someNode(
18681
- (_id, attrs) => attrs.type === import_types81.NodeType.ServiceNode && attrs.name === serviceName
19557
+ (_id, attrs) => attrs.type === import_types86.NodeType.ServiceNode && attrs.name === serviceName
18682
19558
  );
18683
19559
  }
18684
19560
  async function bootstrapProject(entry, connectors = [], neatHome3) {
@@ -18726,6 +19602,10 @@ async function bootstrapProject(entry, connectors = [], neatHome3) {
18726
19602
  project: entry.name,
18727
19603
  graph,
18728
19604
  projectDir: entry.path,
19605
+ // The slot's incident ledger, so an incident-emitting connector (ADR-185)
19606
+ // writes a build-failure incident onto the same errors.ndjson OTLP-derived
19607
+ // incidents land in.
19608
+ errorsPath: paths.errorsPath,
18729
19609
  ...neatHome3 ? { home: neatHome3 } : {},
18730
19610
  extra: connectors,
18731
19611
  onSkip: (skipped, reason) => console.warn(