@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.
@@ -963,6 +963,7 @@ import { parse as parseYaml } from "yaml";
963
963
  import { extractedEdgeId } from "@neat.is/types";
964
964
  var SERVICE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".py", ".go", ".rb", ".php"]);
965
965
  var CONFIG_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".yaml", ".yml"]);
966
+ var JSON_CONFIG_FILENAMES = /* @__PURE__ */ new Set(["app.json", "app.config.json", "eas.json"]);
966
967
  var IGNORED_DIRS = /* @__PURE__ */ new Set([
967
968
  "node_modules",
968
969
  ".git",
@@ -1002,6 +1003,7 @@ async function isPythonVenvDir(dir) {
1002
1003
  function isConfigFile(name) {
1003
1004
  const ext = path3.extname(name);
1004
1005
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
1006
+ if (JSON_CONFIG_FILENAMES.has(name)) return { match: true, fileType: "json" };
1005
1007
  if (name === ".env" || name.startsWith(".env.")) {
1006
1008
  if (isEnvTemplateFile(name)) return { match: false, fileType: "" };
1007
1009
  if (isNeatAuthoredEnvFile(name)) return { match: false, fileType: "" };
@@ -3339,6 +3341,62 @@ function columnIsObserved(col) {
3339
3341
  return col.provenances.includes(Provenance4.OBSERVED);
3340
3342
  }
3341
3343
 
3344
+ // src/latency-digest.ts
3345
+ var SUB = 16;
3346
+ var MIN_EXP = -10;
3347
+ var MAX_EXP = 22;
3348
+ var OCTAVES = MAX_EXP - MIN_EXP + 1;
3349
+ var OVERFLOW_INDEX = OCTAVES * SUB + 1;
3350
+ function latencyBucketIndex(ms) {
3351
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
3352
+ const e = Math.floor(Math.log2(ms));
3353
+ if (e < MIN_EXP) return 0;
3354
+ if (e > MAX_EXP) return OVERFLOW_INDEX;
3355
+ const base = 2 ** e;
3356
+ const raw = Math.floor((ms / base - 1) * SUB);
3357
+ const s = raw < 0 ? 0 : raw >= SUB ? SUB - 1 : raw;
3358
+ return (e - MIN_EXP) * SUB + s + 1;
3359
+ }
3360
+ function bucketRepresentativeMs(index) {
3361
+ if (index <= 0) return 0;
3362
+ if (index >= OVERFLOW_INDEX) return 2 ** (MAX_EXP + 1);
3363
+ const zeroBased = index - 1;
3364
+ const e = MIN_EXP + Math.floor(zeroBased / SUB);
3365
+ const s = zeroBased % SUB;
3366
+ const base = 2 ** e;
3367
+ const lower = base * (1 + s / SUB);
3368
+ const upper = base * (1 + (s + 1) / SUB);
3369
+ return (lower + upper) / 2;
3370
+ }
3371
+ function recordLatency(hist, ms) {
3372
+ const key = String(latencyBucketIndex(ms));
3373
+ hist[key] = (hist[key] ?? 0) + 1;
3374
+ return hist;
3375
+ }
3376
+ function quantile(hist, q) {
3377
+ 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]);
3378
+ let total = 0;
3379
+ for (const [, c] of entries) total += c;
3380
+ if (total === 0) return 0;
3381
+ const rank = Math.max(1, Math.ceil(q * total));
3382
+ let cumulative = 0;
3383
+ for (const [idx, c] of entries) {
3384
+ cumulative += c;
3385
+ if (cumulative >= rank) return bucketRepresentativeMs(idx);
3386
+ }
3387
+ return bucketRepresentativeMs(entries[entries.length - 1][0]);
3388
+ }
3389
+ function round(ms) {
3390
+ return Math.round(ms * 100) / 100;
3391
+ }
3392
+ function latencyPercentiles(hist) {
3393
+ if (!hist) return void 0;
3394
+ let total = 0;
3395
+ for (const c of Object.values(hist)) total += c;
3396
+ if (total === 0) return void 0;
3397
+ return { p50: round(quantile(hist, 0.5)), p95: round(quantile(hist, 0.95)) };
3398
+ }
3399
+
3342
3400
  // src/ingest.ts
3343
3401
  var HOUR_MS = 60 * 60 * 1e3;
3344
3402
  var DAY_MS = 24 * HOUR_MS;
@@ -4034,7 +4092,7 @@ function ensureFrontierNode(graph, host, ts) {
4034
4092
  graph.addNode(id, node);
4035
4093
  return id;
4036
4094
  }
4037
- function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence) {
4095
+ function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence, durationMs) {
4038
4096
  if (!graph.hasNode(source) || !graph.hasNode(target)) return null;
4039
4097
  const grain = source.startsWith("file:") || source.startsWith("symbol:") ? "file" : "service";
4040
4098
  const id = makeObservedEdgeId(type, source, target);
@@ -4042,10 +4100,15 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
4042
4100
  const existing = graph.getEdgeAttributes(id);
4043
4101
  const newSpanCount = (existing.signal?.spanCount ?? existing.callCount ?? 0) + 1;
4044
4102
  const newErrorCount = (existing.signal?.errorCount ?? 0) + (isError ? 1 : 0);
4103
+ const latencyHist2 = durationMs !== void 0 ? recordLatency({ ...existing.signal?.latencyHist ?? {} }, durationMs) : existing.signal?.latencyHist;
4104
+ const latencyMs2 = latencyPercentiles(latencyHist2) ?? existing.signal?.latencyMs;
4045
4105
  const newSignal = {
4046
4106
  spanCount: newSpanCount,
4047
4107
  errorCount: newErrorCount,
4048
- lastObservedAgeMs: 0
4108
+ lastObservedAgeMs: 0,
4109
+ ...latencyHist2 ? { latencyHist: latencyHist2 } : {},
4110
+ ...latencyMs2 ? { latencyMs: latencyMs2 } : {},
4111
+ ...existing.signal?.anomalous !== void 0 ? { anomalous: existing.signal.anomalous } : {}
4049
4112
  };
4050
4113
  const updated = {
4051
4114
  ...existing,
@@ -4060,10 +4123,14 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
4060
4123
  graph.replaceEdgeAttributes(id, updated);
4061
4124
  return { edge: updated, created: false };
4062
4125
  }
4126
+ const latencyHist = durationMs !== void 0 ? recordLatency({}, durationMs) : void 0;
4127
+ const latencyMs = latencyHist ? latencyPercentiles(latencyHist) : void 0;
4063
4128
  const signal = {
4064
4129
  spanCount: 1,
4065
4130
  errorCount: isError ? 1 : 0,
4066
- lastObservedAgeMs: 0
4131
+ lastObservedAgeMs: 0,
4132
+ ...latencyHist ? { latencyHist } : {},
4133
+ ...latencyMs ? { latencyMs } : {}
4067
4134
  };
4068
4135
  const edge = {
4069
4136
  id,
@@ -4127,6 +4194,21 @@ async function appendErrorEvent(ctx, ev) {
4127
4194
  await fs7.mkdir(path8.dirname(ctx.errorsPath), { recursive: true });
4128
4195
  await fs7.appendFile(ctx.errorsPath, JSON.stringify(ev) + "\n", "utf8");
4129
4196
  }
4197
+ async function appendConnectorIncident(errorsPath, input) {
4198
+ const ev = {
4199
+ id: input.id,
4200
+ timestamp: input.timestamp,
4201
+ service: input.service,
4202
+ traceId: input.id,
4203
+ spanId: input.id,
4204
+ errorType: input.errorType,
4205
+ errorMessage: input.errorMessage,
4206
+ ...input.attributes && Object.keys(input.attributes).length > 0 ? { attributes: input.attributes } : {},
4207
+ affectedNode: input.affectedNode
4208
+ };
4209
+ await fs7.mkdir(path8.dirname(errorsPath), { recursive: true });
4210
+ await fs7.appendFile(errorsPath, JSON.stringify(ev) + "\n", "utf8");
4211
+ }
4130
4212
  function incidentAffectedNode(span, graph, scanPath) {
4131
4213
  const sid = graph ? resolveFusedServiceId(graph, span.service, span.env) : serviceId(span.service, span.env);
4132
4214
  const serviceNode = graph && graph.hasNode(sid) ? graph.getNodeAttributes(sid) : void 0;
@@ -4273,6 +4355,7 @@ async function handleSpan(ctx, span) {
4273
4355
  }
4274
4356
  const sourceId = ensureServiceNode(ctx.graph, span.service, env);
4275
4357
  const isError = span.statusCode === 2;
4358
+ const durationMs = span.durationNanos > 0n ? Number(span.durationNanos) / 1e6 : void 0;
4276
4359
  const sourceServiceNode = ctx.graph.getNodeAttributes(sourceId);
4277
4360
  const callSite = callSiteFromSpan(span, sourceServiceNode, ctx.scanPath);
4278
4361
  cacheSpanService(span, nowMs, callSite);
@@ -4311,7 +4394,8 @@ async function handleSpan(ctx, span) {
4311
4394
  targetId,
4312
4395
  ts,
4313
4396
  isError,
4314
- callSiteEvidence
4397
+ callSiteEvidence,
4398
+ durationMs
4315
4399
  );
4316
4400
  if (result) affectedNode = targetId;
4317
4401
  if (span.dbSystem === "mongodb" && span.dbCollection) {
@@ -4323,7 +4407,8 @@ async function handleSpan(ctx, span) {
4323
4407
  collectionId,
4324
4408
  ts,
4325
4409
  isError,
4326
- callSiteEvidence
4410
+ callSiteEvidence,
4411
+ durationMs
4327
4412
  );
4328
4413
  }
4329
4414
  if (span.dbTable) {
@@ -4335,7 +4420,8 @@ async function handleSpan(ctx, span) {
4335
4420
  tableId,
4336
4421
  ts,
4337
4422
  isError,
4338
- callSiteEvidence
4423
+ callSiteEvidence,
4424
+ durationMs
4339
4425
  );
4340
4426
  mergeObservedColumns(ctx.graph, tableId, span.dbColumns);
4341
4427
  }
@@ -4354,7 +4440,8 @@ async function handleSpan(ctx, span) {
4354
4440
  targetId,
4355
4441
  ts,
4356
4442
  isError,
4357
- callSiteEvidence
4443
+ callSiteEvidence,
4444
+ durationMs
4358
4445
  );
4359
4446
  if (result) affectedNode = targetId;
4360
4447
  } else if (span.graphqlOperationName && span.graphqlOperationType && spanServesGraphqlOperation(span.kind)) {
@@ -4371,7 +4458,8 @@ async function handleSpan(ctx, span) {
4371
4458
  targetId,
4372
4459
  ts,
4373
4460
  isError,
4374
- callSiteEvidence
4461
+ callSiteEvidence,
4462
+ durationMs
4375
4463
  );
4376
4464
  if (result) affectedNode = targetId;
4377
4465
  } else if (span.rpcSystem === "grpc" && span.rpcService && span.rpcMethod && spanServesGrpcMethod(span.kind)) {
@@ -4383,7 +4471,8 @@ async function handleSpan(ctx, span) {
4383
4471
  targetId,
4384
4472
  ts,
4385
4473
  isError,
4386
- callSiteEvidence
4474
+ callSiteEvidence,
4475
+ durationMs
4387
4476
  );
4388
4477
  if (result) affectedNode = targetId;
4389
4478
  } else if (span.websocketChannel && spanServesWebsocketChannel(span.kind)) {
@@ -4399,7 +4488,8 @@ async function handleSpan(ctx, span) {
4399
4488
  targetId,
4400
4489
  ts,
4401
4490
  isError,
4402
- callSiteEvidence
4491
+ callSiteEvidence,
4492
+ durationMs
4403
4493
  );
4404
4494
  if (result) affectedNode = targetId;
4405
4495
  } else {
@@ -4415,7 +4505,8 @@ async function handleSpan(ctx, span) {
4415
4505
  targetId,
4416
4506
  ts,
4417
4507
  isError,
4418
- callSiteEvidence
4508
+ callSiteEvidence,
4509
+ durationMs
4419
4510
  );
4420
4511
  affectedNode = targetId;
4421
4512
  resolvedViaAddress = true;
@@ -4428,7 +4519,8 @@ async function handleSpan(ctx, span) {
4428
4519
  frontierNodeId,
4429
4520
  ts,
4430
4521
  isError,
4431
- callSiteEvidence
4522
+ callSiteEvidence,
4523
+ durationMs
4432
4524
  );
4433
4525
  affectedNode = frontierNodeId;
4434
4526
  resolvedViaAddress = true;
@@ -4454,7 +4546,8 @@ async function handleSpan(ctx, span) {
4454
4546
  sourceId,
4455
4547
  ts,
4456
4548
  isError,
4457
- fallbackEvidence
4549
+ fallbackEvidence,
4550
+ durationMs
4458
4551
  );
4459
4552
  }
4460
4553
  }
@@ -4468,7 +4561,7 @@ async function handleSpan(ctx, span) {
4468
4561
  );
4469
4562
  if (routeNodeId) {
4470
4563
  const routeSvc = ctx.graph.getNodeAttributes(routeNodeId).service;
4471
- upsertObservedEdge(ctx.graph, EdgeType5.CONTAINS, serviceId(routeSvc), routeNodeId, ts, isError);
4564
+ upsertObservedEdge(ctx.graph, EdgeType5.CONTAINS, serviceId(routeSvc), routeNodeId, ts, isError, void 0, durationMs);
4472
4565
  }
4473
4566
  }
4474
4567
  if (span.statusCode === 2) {
@@ -4757,10 +4850,12 @@ function mergeSnapshot(graph, snapshot) {
4757
4850
  import {
4758
4851
  BlastRadiusResultSchema,
4759
4852
  EdgeType as EdgeType6,
4853
+ ExpandResultSchema,
4760
4854
  NodeType as NodeType5,
4761
4855
  ObservedDependenciesResultSchema,
4762
4856
  PROV_RANK,
4763
4857
  Provenance as Provenance6,
4858
+ RelateResultSchema,
4764
4859
  RootCauseResultSchema,
4765
4860
  TransitiveDependenciesResultSchema
4766
4861
  } from "@neat.is/types";
@@ -4977,7 +5072,7 @@ var rootCauseShapes = {
4977
5072
  [NodeType5.FileNode]: fileRootCauseShape,
4978
5073
  [NodeType5.SymbolNode]: symbolRootCauseShape
4979
5074
  };
4980
- function getRootCause(graph, errorNodeId, errorEvent, incidents) {
5075
+ function legacyRootCause(graph, errorNodeId, errorEvent, incidents) {
4981
5076
  if (!graph.hasNode(errorNodeId)) return null;
4982
5077
  const origin = graph.getNodeAttributes(errorNodeId);
4983
5078
  const shape = rootCauseShapes[origin.type];
@@ -5234,7 +5329,9 @@ function getObservedDependencies(graph, nodeId) {
5234
5329
  dependencies: [],
5235
5330
  observed: false,
5236
5331
  inboundObservedCount: 0,
5237
- hasExtractedOutbound: false
5332
+ hasExtractedOutbound: false,
5333
+ inboundVolume: 0,
5334
+ window: "lifetime"
5238
5335
  });
5239
5336
  }
5240
5337
  const attrs = graph.getNodeAttributes(nodeId);
@@ -5265,11 +5362,19 @@ function getObservedDependencies(graph, nodeId) {
5265
5362
  }
5266
5363
  }
5267
5364
  let inboundObservedCount = 0;
5365
+ let inboundVolume = 0;
5366
+ let inboundLastObserved;
5268
5367
  for (const tgt of scope) {
5269
5368
  for (const edgeId of graph.inboundEdges(tgt)) {
5270
5369
  const e = graph.getEdgeAttributes(edgeId);
5271
5370
  if (e.type === EdgeType6.CONTAINS) continue;
5272
- if (e.provenance === Provenance6.OBSERVED) inboundObservedCount += 1;
5371
+ if (e.provenance === Provenance6.OBSERVED) {
5372
+ inboundObservedCount += 1;
5373
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 1;
5374
+ if (e.lastObserved && (!inboundLastObserved || e.lastObserved > inboundLastObserved)) {
5375
+ inboundLastObserved = e.lastObserved;
5376
+ }
5377
+ }
5273
5378
  }
5274
5379
  }
5275
5380
  dependencies.sort(
@@ -5280,7 +5385,291 @@ function getObservedDependencies(graph, nodeId) {
5280
5385
  dependencies,
5281
5386
  observed: dependencies.length > 0 || inboundObservedCount > 0,
5282
5387
  inboundObservedCount,
5283
- hasExtractedOutbound
5388
+ hasExtractedOutbound,
5389
+ // The signal is cumulative, so the honest window label is "lifetime" (ADR-190).
5390
+ inboundVolume,
5391
+ window: "lifetime",
5392
+ ...inboundLastObserved ? { inboundLastObserved } : {}
5393
+ });
5394
+ }
5395
+ var SATURATION_P95_MS = 1e3;
5396
+ function nodeScope(graph, nodeId) {
5397
+ const scope = [nodeId];
5398
+ if (!graph.hasNode(nodeId)) return scope;
5399
+ const attrs = graph.getNodeAttributes(nodeId);
5400
+ if (attrs.type === NodeType5.ServiceNode) {
5401
+ for (const edgeId of graph.outboundEdges(nodeId)) {
5402
+ const e = graph.getEdgeAttributes(edgeId);
5403
+ if (e.type !== EdgeType6.CONTAINS) continue;
5404
+ const owned = graph.getNodeAttributes(e.target);
5405
+ if (owned.type === NodeType5.FileNode) scope.push(e.target);
5406
+ }
5407
+ }
5408
+ return scope;
5409
+ }
5410
+ function incidentCountForNode(nodeId, incidents) {
5411
+ if (!incidents || incidents.length === 0) return 0;
5412
+ return incidents.filter((ev) => incidentMatchesNode(ev, nodeId)).length;
5413
+ }
5414
+ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
5415
+ const scope = nodeScope(graph, nodeId);
5416
+ let errorsFromCallers = 0;
5417
+ let inboundVolume = 0;
5418
+ let outboundVolume = 0;
5419
+ let outboundErrors = 0;
5420
+ let latestInboundMs;
5421
+ let latencyP95Ms;
5422
+ let stale = false;
5423
+ for (const n of scope) {
5424
+ if (!graph.hasNode(n)) continue;
5425
+ for (const edgeId of graph.inboundEdges(n)) {
5426
+ const e = graph.getEdgeAttributes(edgeId);
5427
+ if (e.type === EdgeType6.CONTAINS) continue;
5428
+ errorsFromCallers += e.signal?.errorCount ?? 0;
5429
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
5430
+ if (e.provenance === Provenance6.STALE) stale = true;
5431
+ const p95 = e.signal?.latencyMs?.p95;
5432
+ if (p95 !== void 0) latencyP95Ms = Math.max(latencyP95Ms ?? 0, p95);
5433
+ if (e.lastObserved) {
5434
+ const t = Date.parse(e.lastObserved);
5435
+ if (Number.isFinite(t)) latestInboundMs = Math.max(latestInboundMs ?? 0, t);
5436
+ }
5437
+ }
5438
+ for (const edgeId of graph.outboundEdges(n)) {
5439
+ const e = graph.getEdgeAttributes(edgeId);
5440
+ if (e.type === EdgeType6.CONTAINS) continue;
5441
+ outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
5442
+ if (e.type === EdgeType6.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
5443
+ if (e.provenance === Provenance6.STALE) stale = true;
5444
+ }
5445
+ }
5446
+ const errorsEmittedHere = incidentCountForNode(nodeId, incidents) + outboundErrors;
5447
+ const lastObservedAgeMs = latestInboundMs !== void 0 ? Math.max(0, now - latestInboundMs) : void 0;
5448
+ return {
5449
+ errorsEmittedHere,
5450
+ errorsFromCallers,
5451
+ callCount: inboundVolume,
5452
+ outboundVolume,
5453
+ ...lastObservedAgeMs !== void 0 ? { lastObservedAgeMs } : {},
5454
+ ...latencyP95Ms !== void 0 ? { latencyP95Ms } : {},
5455
+ stale
5456
+ };
5457
+ }
5458
+ function isSaturated(ctx) {
5459
+ return ctx.latencyP95Ms !== void 0 && ctx.latencyP95Ms >= SATURATION_P95_MS;
5460
+ }
5461
+ function classifyNode(ctx) {
5462
+ if (ctx.errorsEmittedHere > 0) {
5463
+ if ((ctx.stale || isSaturated(ctx)) && ctx.errorsFromCallers >= ctx.errorsEmittedHere) {
5464
+ return "symptom-only";
5465
+ }
5466
+ return "primary-failure";
5467
+ }
5468
+ if (ctx.errorsFromCallers > 0) return "symptom-only";
5469
+ return "unrelated";
5470
+ }
5471
+ function isVictimSeed(ctx) {
5472
+ return ctx.errorsFromCallers > 0 && (ctx.stale || isSaturated(ctx)) && ctx.errorsEmittedHere <= ctx.errorsFromCallers;
5473
+ }
5474
+ function grainOf(graph, nodeId) {
5475
+ if (!graph.hasNode(nodeId)) return "unknown";
5476
+ const t = graph.getNodeAttributes(nodeId).type;
5477
+ if (t === NodeType5.ServiceNode) return "service";
5478
+ if (t === NodeType5.FileNode) return "file";
5479
+ if (t === NodeType5.SymbolNode) return "symbol";
5480
+ return t;
5481
+ }
5482
+ function findPath(graph, from, to, direction, maxDepth) {
5483
+ if (!graph.hasNode(from) || !graph.hasNode(to)) return null;
5484
+ if (from === to) return { nodes: [from], edges: [] };
5485
+ const queue = [{ nodeId: from, depth: 0, nodes: [from], edges: [] }];
5486
+ const enqueued = /* @__PURE__ */ new Set([from]);
5487
+ while (queue.length > 0) {
5488
+ const frame = queue.shift();
5489
+ if (frame.depth >= maxDepth) continue;
5490
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(frame.nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(frame.nodeId));
5491
+ const neighbours = [...best.entries()].sort((a, b) => a[0].localeCompare(b[0]));
5492
+ for (const [nid, edge] of neighbours) {
5493
+ if (nid === to) return { nodes: [...frame.nodes, nid], edges: [...frame.edges, edge] };
5494
+ if (enqueued.has(nid)) continue;
5495
+ enqueued.add(nid);
5496
+ queue.push({
5497
+ nodeId: nid,
5498
+ depth: frame.depth + 1,
5499
+ nodes: [...frame.nodes, nid],
5500
+ edges: [...frame.edges, edge]
5501
+ });
5502
+ }
5503
+ }
5504
+ return null;
5505
+ }
5506
+ var EMPTY_CONTEXT = {
5507
+ errorsEmittedHere: 0,
5508
+ errorsFromCallers: 0,
5509
+ callCount: 0,
5510
+ outboundVolume: 0,
5511
+ stale: false
5512
+ };
5513
+ function expandNode(graph, nodeId, direction, incidents, now = Date.now()) {
5514
+ if (!graph.hasNode(nodeId)) {
5515
+ return ExpandResultSchema.parse({
5516
+ origin: nodeId,
5517
+ direction,
5518
+ node: { id: nodeId, classification: "unrelated", context: EMPTY_CONTEXT },
5519
+ neighbours: []
5520
+ });
5521
+ }
5522
+ const ctx = nodeContext(graph, nodeId, incidents, now);
5523
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(nodeId));
5524
+ const neighbours = [];
5525
+ for (const [nid, edge] of best) {
5526
+ if (edge.type === EdgeType6.CONTAINS) continue;
5527
+ const nctx = nodeContext(graph, nid, incidents, now);
5528
+ neighbours.push({
5529
+ node: nid,
5530
+ edgeType: edge.type,
5531
+ provenance: edge.provenance,
5532
+ classification: classifyNode(nctx),
5533
+ context: nctx
5534
+ });
5535
+ }
5536
+ neighbours.sort((a, b) => a.node.localeCompare(b.node));
5537
+ return ExpandResultSchema.parse({
5538
+ origin: nodeId,
5539
+ direction,
5540
+ node: { id: nodeId, classification: classifyNode(ctx), context: ctx },
5541
+ neighbours
5542
+ });
5543
+ }
5544
+ function relate(graph, a, b, maxDepth = ROOT_CAUSE_MAX_DEPTH) {
5545
+ const buildPath = (fp) => ({
5546
+ nodes: fp.nodes,
5547
+ edgeTypes: fp.edges.map((e) => e.type),
5548
+ provenance: fp.edges.map((e) => e.provenance),
5549
+ grain: fp.nodes.map((n) => grainOf(graph, n)),
5550
+ // The failure runs end to end when every hop carries error / latency / alert
5551
+ // signal — that is what turns reachability into cause-confirmation.
5552
+ carriesSignal: fp.edges.length > 0 && fp.edges.every(
5553
+ (e) => (e.signal?.errorCount ?? 0) > 0 || e.signal?.latencyMs !== void 0 || e.signal?.anomalous !== void 0
5554
+ )
5555
+ });
5556
+ if (!graph.hasNode(a) || !graph.hasNode(b)) {
5557
+ return RelateResultSchema.parse({
5558
+ a,
5559
+ b,
5560
+ related: false,
5561
+ direction: null,
5562
+ paths: [],
5563
+ note: !graph.hasNode(a) ? `node not found: ${a}` : `node not found: ${b}`
5564
+ });
5565
+ }
5566
+ const down = findPath(graph, a, b, "down", maxDepth);
5567
+ const up = findPath(graph, a, b, "up", maxDepth);
5568
+ if (!down && !up) {
5569
+ return RelateResultSchema.parse({
5570
+ a,
5571
+ b,
5572
+ related: false,
5573
+ direction: null,
5574
+ paths: [],
5575
+ note: `no path within ${maxDepth} hops`
5576
+ });
5577
+ }
5578
+ const paths = [];
5579
+ const direction = down ? "a->b" : "b->a";
5580
+ if (down) paths.push(buildPath(down));
5581
+ if (up) paths.push(buildPath(up));
5582
+ const endpointsFine = grainOf(graph, a) !== "service" && grainOf(graph, b) !== "service";
5583
+ const grainGap = endpointsFine && paths[0].grain.some((g) => g === "service");
5584
+ return RelateResultSchema.parse({
5585
+ a,
5586
+ b,
5587
+ related: true,
5588
+ direction,
5589
+ paths,
5590
+ ...grainGap ? { grainGap: true } : {}
5591
+ });
5592
+ }
5593
+ function findLoadOrigin(graph, alertNodeId, incidents, now) {
5594
+ const upstream = getBlastRadius(graph, alertNodeId).affectedNodes.map((n) => n.nodeId);
5595
+ let best = null;
5596
+ for (const nid of upstream) {
5597
+ if (nid === alertNodeId) continue;
5598
+ const ctx = nodeContext(graph, nid, incidents, now);
5599
+ if (ctx.outboundVolume === 0) continue;
5600
+ const isSource = ctx.callCount === 0;
5601
+ const better = !best || (isSource !== best.isSource ? isSource : ctx.outboundVolume !== best.ctx.outboundVolume ? ctx.outboundVolume > best.ctx.outboundVolume : nid < best.node);
5602
+ if (better) best = { node: nid, ctx, isSource };
5603
+ }
5604
+ return best ? { node: best.node, ctx: best.ctx } : null;
5605
+ }
5606
+ function getRootCause(graph, errorNodeId, errorEvent, incidents, opts) {
5607
+ const legacy = legacyRootCause(graph, errorNodeId, errorEvent, incidents);
5608
+ if (!legacy) return null;
5609
+ const navigation = opts?.navigation ?? process.env.NEAT_RCA_NAVIGATION !== "0";
5610
+ if (!navigation) return legacy;
5611
+ return enrichWithNavigation(graph, errorNodeId, legacy, incidents, opts?.now ?? Date.now());
5612
+ }
5613
+ function enrichWithNavigation(graph, errorNodeId, legacy, incidents, now) {
5614
+ const seedNode = legacy.rootCauseNode;
5615
+ const seedCtx = graph.hasNode(seedNode) ? nodeContext(graph, seedNode, incidents, now) : null;
5616
+ const lastProv = legacy.edgeProvenances[legacy.edgeProvenances.length - 1];
5617
+ const candidates = [];
5618
+ if (seedCtx && isVictimSeed(seedCtx)) {
5619
+ const origin = findLoadOrigin(graph, errorNodeId, incidents, now);
5620
+ if (origin) {
5621
+ const path64 = findPath(graph, errorNodeId, origin.node, "up", ROOT_CAUSE_MAX_DEPTH);
5622
+ const originConfidence = confidenceFromMix(path64?.edges ?? [], now);
5623
+ candidates.push({
5624
+ node: origin.node,
5625
+ classification: "primary-failure",
5626
+ 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.`,
5627
+ context: origin.ctx,
5628
+ confidence: Math.max(0.3, Math.min(0.8, originConfidence || 0.5)),
5629
+ provenance: Provenance6.OBSERVED
5630
+ });
5631
+ }
5632
+ const staleNote = seedCtx.stale ? "; the node has gone STALE" : "";
5633
+ const satNote = isSaturated(seedCtx) ? `; inbound p95 ${Math.round(seedCtx.latencyP95Ms)}ms is saturated` : "";
5634
+ candidates.push({
5635
+ node: seedNode,
5636
+ classification: "symptom-only",
5637
+ reason: `Errors arrive from callers (${seedCtx.errorsFromCallers}) but none originate here${staleNote}${satNote} \u2014 a downstream victim of load, not the fault.`,
5638
+ context: seedCtx,
5639
+ confidence: Math.min(legacy.confidence, 0.4),
5640
+ ...lastProv ? { provenance: lastProv } : {}
5641
+ });
5642
+ } else {
5643
+ candidates.push({
5644
+ node: seedNode,
5645
+ classification: "primary-failure",
5646
+ reason: legacy.rootCauseReason,
5647
+ context: seedCtx ?? EMPTY_CONTEXT,
5648
+ confidence: legacy.confidence,
5649
+ ...lastProv ? { provenance: lastProv } : {}
5650
+ });
5651
+ }
5652
+ const top = candidates[0];
5653
+ let traversalPath = legacy.traversalPath;
5654
+ let edgeProvenances = legacy.edgeProvenances;
5655
+ if (top.node !== seedNode) {
5656
+ const path64 = findPath(graph, errorNodeId, top.node, "up", ROOT_CAUSE_MAX_DEPTH);
5657
+ if (path64) {
5658
+ traversalPath = path64.nodes;
5659
+ edgeProvenances = path64.edges.map((e) => e.provenance);
5660
+ } else {
5661
+ traversalPath = [errorNodeId, top.node];
5662
+ edgeProvenances = [top.provenance ?? Provenance6.OBSERVED];
5663
+ }
5664
+ }
5665
+ return RootCauseResultSchema.parse({
5666
+ rootCauseNode: top.node,
5667
+ rootCauseReason: top.reason,
5668
+ traversalPath,
5669
+ edgeProvenances,
5670
+ confidence: top.confidence,
5671
+ ...legacy.fixRecommendation ? { fixRecommendation: legacy.fixRecommendation } : {},
5672
+ candidates
5284
5673
  });
5285
5674
  }
5286
5675
 
@@ -10488,8 +10877,41 @@ import path45 from "path";
10488
10877
  import Parser14 from "tree-sitter";
10489
10878
  import Go3 from "tree-sitter-go";
10490
10879
  import { infraId as infraId15 } from "@neat.is/types";
10491
- var SQL_METHODS = /* @__PURE__ */ new Set(["Exec", "ExecContext", "Query", "QueryContext", "QueryRow", "QueryRowContext"]);
10492
10880
  var PARSE_CHUNK10 = 16384;
10881
+ var DATABASE_SQL_METHODS = /* @__PURE__ */ new Set([
10882
+ "Query",
10883
+ "QueryContext",
10884
+ "QueryRow",
10885
+ "QueryRowContext",
10886
+ "Exec",
10887
+ "ExecContext",
10888
+ "Prepare",
10889
+ "PrepareContext"
10890
+ ]);
10891
+ var SQLX_METHODS = /* @__PURE__ */ new Set([
10892
+ "Get",
10893
+ "Select",
10894
+ "Queryx",
10895
+ "QueryRowx",
10896
+ "NamedExec",
10897
+ "NamedQuery",
10898
+ "MustExec",
10899
+ "Preparex",
10900
+ "GetContext",
10901
+ "SelectContext"
10902
+ ]);
10903
+ var DATABASE_SQL_IMPORT = "database/sql";
10904
+ var SQLX_IMPORT = "github.com/jmoiron/sqlx";
10905
+ function makeGoParser3() {
10906
+ const p = new Parser14();
10907
+ p.setLanguage(Go3);
10908
+ return p;
10909
+ }
10910
+ function parseSource10(parser, source) {
10911
+ return parser.parse(
10912
+ (index) => index >= source.length ? "" : source.slice(index, index + PARSE_CHUNK10)
10913
+ );
10914
+ }
10493
10915
  function walk7(node, visit) {
10494
10916
  visit(node);
10495
10917
  for (let i = 0; i < node.namedChildCount; i++) {
@@ -10497,25 +10919,54 @@ function walk7(node, visit) {
10497
10919
  if (child) walk7(child, visit);
10498
10920
  }
10499
10921
  }
10922
+ function goStringLiteralValue(node) {
10923
+ if (!node) return null;
10924
+ if (node.type === "interpreted_string_literal" || node.type === "raw_string_literal") {
10925
+ const t = node.text;
10926
+ return t.length >= 2 ? t.slice(1, -1) : "";
10927
+ }
10928
+ return null;
10929
+ }
10930
+ function goImportsAny(root, names) {
10931
+ let found = false;
10932
+ walk7(root, (node) => {
10933
+ if (found || node.type !== "import_spec") return;
10934
+ for (let i = 0; i < node.namedChildCount; i++) {
10935
+ const value = goStringLiteralValue(node.namedChild(i));
10936
+ if (value !== null && names.has(value)) found = true;
10937
+ }
10938
+ });
10939
+ return found;
10940
+ }
10941
+ function firstStringLiteralArg(argsNode) {
10942
+ if (!argsNode) return null;
10943
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
10944
+ const value = goStringLiteralValue(argsNode.namedChild(i));
10945
+ if (value !== null) return value;
10946
+ }
10947
+ return null;
10948
+ }
10500
10949
  function goSqlEndpointsFromFile(file, serviceDir) {
10501
10950
  if (path45.extname(file.path) !== ".go") return [];
10502
- const parser = new Parser14();
10503
- parser.setLanguage(Go3);
10504
- const tree = parser.parse(
10505
- (index) => index >= file.content.length ? "" : file.content.slice(index, index + PARSE_CHUNK10)
10506
- );
10951
+ if (!file.content.includes(DATABASE_SQL_IMPORT) && !file.content.includes(SQLX_IMPORT)) return [];
10952
+ const tree = parseSource10(makeGoParser3(), file.content);
10953
+ const importsDatabaseSql = goImportsAny(tree.rootNode, /* @__PURE__ */ new Set([DATABASE_SQL_IMPORT]));
10954
+ const importsSqlx = goImportsAny(tree.rootNode, /* @__PURE__ */ new Set([SQLX_IMPORT]));
10955
+ if (!importsDatabaseSql && !importsSqlx) return [];
10507
10956
  const out = [];
10508
10957
  walk7(tree.rootNode, (node) => {
10509
10958
  if (node.type !== "call_expression") return;
10510
10959
  const fn = node.childForFieldName("function");
10511
10960
  if (fn?.type !== "selector_expression") return;
10512
10961
  const method = fn.childForFieldName("field")?.text;
10513
- if (!method || !SQL_METHODS.has(method)) return;
10514
- const arg = node.childForFieldName("arguments")?.namedChild(0);
10515
- if (!arg || arg.type !== "interpreted_string_literal" && arg.type !== "raw_string_literal") return;
10516
- const sql = arg.text.slice(1, -1);
10962
+ if (!method) return;
10963
+ const recognized = DATABASE_SQL_METHODS.has(method) || importsSqlx && SQLX_METHODS.has(method);
10964
+ if (!recognized) return;
10965
+ const sql = firstStringLiteralArg(node.childForFieldName("arguments"));
10966
+ if (sql === null) return;
10517
10967
  const table = tableFromSqlStatement(sql);
10518
10968
  if (!table) return;
10969
+ const columns = columnsFromSqlStatement(sql);
10519
10970
  const line = node.startPosition.row + 1;
10520
10971
  out.push({
10521
10972
  infraId: infraId15("sql-table", table),
@@ -10523,7 +10974,12 @@ function goSqlEndpointsFromFile(file, serviceDir) {
10523
10974
  kind: "sql-table",
10524
10975
  edgeType: "CALLS",
10525
10976
  confidenceKind: "verified-call-site",
10526
- evidence: { file: toPosix(path45.relative(serviceDir, file.path)), line, snippet: snippet(file.content, line) }
10977
+ ...columns.length > 0 ? { columns } : {},
10978
+ evidence: {
10979
+ file: toPosix(path45.relative(serviceDir, file.path)),
10980
+ line,
10981
+ snippet: snippet(file.content, line)
10982
+ }
10527
10983
  });
10528
10984
  });
10529
10985
  return out;
@@ -10536,12 +10992,12 @@ import Go4 from "tree-sitter-go";
10536
10992
  import { infraId as infraId16 } from "@neat.is/types";
10537
10993
  var GORM_IMPORT_RE = /gorm\.io\/gorm/;
10538
10994
  var PARSE_CHUNK11 = 16384;
10539
- function makeGoParser3() {
10995
+ function makeGoParser4() {
10540
10996
  const p = new Parser15();
10541
10997
  p.setLanguage(Go4);
10542
10998
  return p;
10543
10999
  }
10544
- function parseSource10(parser, source) {
11000
+ function parseSource11(parser, source) {
10545
11001
  return parser.parse(
10546
11002
  (index) => index >= source.length ? "" : source.slice(index, index + PARSE_CHUNK11)
10547
11003
  );
@@ -10965,7 +11421,7 @@ function collectColumns(struct, structs, seen, prefix, out, emitted) {
10965
11421
  function gormEndpointsFromFile(file, serviceDir) {
10966
11422
  if (path46.extname(file.path) !== ".go") return [];
10967
11423
  if (!GORM_IMPORT_RE.test(file.content)) return [];
10968
- const tree = parseSource10(makeGoParser3(), file.content);
11424
+ const tree = parseSource11(makeGoParser4(), file.content);
10969
11425
  const { structs, models, tableFor } = analyze(tree);
10970
11426
  const out = [];
10971
11427
  const seenTables = /* @__PURE__ */ new Set();
@@ -10996,7 +11452,7 @@ function gormEndpointsFromFile(file, serviceDir) {
10996
11452
  function gormForeignKeys(file, serviceDir) {
10997
11453
  if (path46.extname(file.path) !== ".go") return [];
10998
11454
  if (!GORM_IMPORT_RE.test(file.content)) return [];
10999
- const tree = parseSource10(makeGoParser3(), file.content);
11455
+ const tree = parseSource11(makeGoParser4(), file.content);
11000
11456
  const { structs, models, tableFor } = analyze(tree);
11001
11457
  const out = [];
11002
11458
  const seen = /* @__PURE__ */ new Set();
@@ -14420,6 +14876,23 @@ async function runConnectorPoll(connector, ctx, graph, resolveTarget) {
14420
14876
  unresolved++;
14421
14877
  continue;
14422
14878
  }
14879
+ if (signal.incident) {
14880
+ ensureServiceNode(graph, resolved.serviceName, NO_ENV);
14881
+ if (!ctx.errorsPath || !graph.hasNode(resolved.targetNodeId)) {
14882
+ unresolved++;
14883
+ continue;
14884
+ }
14885
+ await appendConnectorIncident(ctx.errorsPath, {
14886
+ id: signal.incident.id,
14887
+ timestamp: signal.incident.timestamp,
14888
+ service: signal.incident.service,
14889
+ errorType: signal.incident.errorType,
14890
+ errorMessage: signal.incident.errorMessage,
14891
+ ...signal.incident.attributes ? { attributes: signal.incident.attributes } : {},
14892
+ affectedNode: resolved.targetNodeId
14893
+ });
14894
+ continue;
14895
+ }
14423
14896
  if (resolved.ensureInfraNode) {
14424
14897
  const { kind, name, provider } = resolved.ensureInfraNode;
14425
14898
  ensureInfraNode(graph, kind, name, provider);
@@ -16587,6 +17060,329 @@ function createPlanetscaleConnector(graph, config, deps = {}) {
16587
17060
  };
16588
17061
  }
16589
17062
 
17063
+ // src/connectors/eas/types.ts
17064
+ function readEasCredentials(raw) {
17065
+ const token = raw["token"];
17066
+ if (typeof token !== "string" || token.length === 0) {
17067
+ throw new Error("eas connector: credentials.token (EXPO_TOKEN) must be a non-empty string");
17068
+ }
17069
+ return { token };
17070
+ }
17071
+ var EAS_STATUS_ERRORED = "ERRORED";
17072
+ var TRANSIENT_BUILD_PHASES = /* @__PURE__ */ new Set([
17073
+ "SPIN_UP_BUILDER",
17074
+ "PREPARE_CREDENTIALS",
17075
+ "RESTORE_CACHE",
17076
+ "UPLOAD_APPLICATION_ARCHIVE"
17077
+ ]);
17078
+ var INTERNAL_ERROR_CODE = /INTERNAL_SERVER_ERROR|EAS_BUILD_.*INTERNAL|_INTERNAL_ERROR|UNKNOWN_ERROR/i;
17079
+ function isTransientFailure(err) {
17080
+ if (!err) return false;
17081
+ const phase = typeof err.buildPhase === "string" ? err.buildPhase : void 0;
17082
+ if (phase) {
17083
+ if (TRANSIENT_BUILD_PHASES.has(phase)) return true;
17084
+ if (/^SPIN_UP|_CREDENTIALS$|^RESTORE_CACHE/i.test(phase)) return true;
17085
+ }
17086
+ const code = typeof err.errorCode === "string" ? err.errorCode : void 0;
17087
+ if (code && INTERNAL_ERROR_CODE.test(code)) return true;
17088
+ return false;
17089
+ }
17090
+ var FIELD_SEP3 = "\0";
17091
+ var EAS_TARGET_KIND = "eas-build";
17092
+ function packEasTargetName(identity) {
17093
+ return [identity.serviceName, identity.phase].join(FIELD_SEP3);
17094
+ }
17095
+ function parseEasTargetName(targetName) {
17096
+ const sep = targetName.indexOf(FIELD_SEP3);
17097
+ if (sep === -1) return null;
17098
+ const serviceName = targetName.slice(0, sep);
17099
+ const phase = targetName.slice(sep + 1);
17100
+ if (!serviceName) return null;
17101
+ return { serviceName, phase };
17102
+ }
17103
+
17104
+ // src/connectors/eas/client.ts
17105
+ var DEFAULT_EAS_API_URL = "https://api.expo.dev/graphql";
17106
+ var DEFAULT_PAGE_SIZE = 50;
17107
+ var DEFAULT_MAX_PAGES = 10;
17108
+ var DEFAULT_MAX_LOG_BYTES = 16 * 1024;
17109
+ var DEFAULT_MAX_LOOKBACK_MS6 = 7 * 24 * 60 * 60 * 1e3;
17110
+ var BUILDS_QUERY = `
17111
+ query NeatEasErroredBuilds($appId: String!, $offset: Int!, $limit: Int!) {
17112
+ app {
17113
+ byId(appId: $appId) {
17114
+ builds(offset: $offset, limit: $limit, filter: { status: ERRORED }) {
17115
+ id
17116
+ status
17117
+ platform
17118
+ buildProfile
17119
+ gitCommitHash
17120
+ gitCommitMessage
17121
+ gitRef
17122
+ isGitWorkingTreeDirty
17123
+ createdAt
17124
+ completedAt
17125
+ error {
17126
+ buildPhase
17127
+ errorCode
17128
+ message
17129
+ docsUrl
17130
+ }
17131
+ logFileUrls
17132
+ }
17133
+ }
17134
+ }
17135
+ }
17136
+ `;
17137
+ async function easGraphQL(apiUrl, token, query, variables, accountKey, fetchImpl) {
17138
+ const res = await junctionFetch(
17139
+ apiUrl,
17140
+ {
17141
+ method: "POST",
17142
+ headers: {
17143
+ "Content-Type": "application/json",
17144
+ ...bearerAuthHeader(token)
17145
+ },
17146
+ body: JSON.stringify({ query, variables })
17147
+ },
17148
+ // accountKey: the Expo app id — the per-(provider, accountKey) rate-limit
17149
+ // bucket (ADR-131), the closest thing this connector carries to "one account".
17150
+ { provider: "eas", accountKey, ...fetchImpl ? { fetchImpl } : {} }
17151
+ );
17152
+ if (!res.ok) {
17153
+ throw new Error(`Expo GraphQL request failed: ${res.status} ${res.statusText}`);
17154
+ }
17155
+ const body = await res.json();
17156
+ if (body.errors && body.errors.length > 0) {
17157
+ throw new Error(`Expo GraphQL errors: ${body.errors.map((e) => e.message).join("; ")}`);
17158
+ }
17159
+ if (!body.data) throw new Error("Expo GraphQL response carried no data");
17160
+ return body.data;
17161
+ }
17162
+ async function fetchErroredBuilds(token, config, fetchImpl) {
17163
+ const apiUrl = config.apiUrl ?? DEFAULT_EAS_API_URL;
17164
+ const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE;
17165
+ const maxPages = config.maxPages ?? DEFAULT_MAX_PAGES;
17166
+ const out = [];
17167
+ const seen = /* @__PURE__ */ new Set();
17168
+ for (let page = 0; page < maxPages; page++) {
17169
+ const data = await easGraphQL(
17170
+ apiUrl,
17171
+ token,
17172
+ BUILDS_QUERY,
17173
+ { appId: config.appId, offset: page * pageSize, limit: pageSize },
17174
+ config.appId,
17175
+ fetchImpl
17176
+ );
17177
+ const builds = data.app?.byId?.builds;
17178
+ if (!Array.isArray(builds)) break;
17179
+ let added = 0;
17180
+ for (const b of builds) {
17181
+ if (!b || typeof b.id !== "string" || b.id.length === 0) continue;
17182
+ if (b.status !== EAS_STATUS_ERRORED) continue;
17183
+ if (seen.has(b.id)) continue;
17184
+ seen.add(b.id);
17185
+ out.push(b);
17186
+ added++;
17187
+ }
17188
+ if (builds.length < pageSize) break;
17189
+ if (added === 0) break;
17190
+ }
17191
+ return out;
17192
+ }
17193
+ async function fetchBuildLogs(logFileUrls, maxBytes = DEFAULT_MAX_LOG_BYTES, fetchImpl) {
17194
+ if (!Array.isArray(logFileUrls) || logFileUrls.length === 0) return "";
17195
+ const doFetch = fetchImpl ?? fetch;
17196
+ const chunks = [];
17197
+ for (const url of logFileUrls) {
17198
+ if (typeof url !== "string" || url.length === 0) continue;
17199
+ try {
17200
+ const res = await doFetch(url);
17201
+ if (!res.ok) continue;
17202
+ chunks.push(await res.text());
17203
+ } catch {
17204
+ }
17205
+ }
17206
+ const joined = chunks.join("\n");
17207
+ return joined.length > maxBytes ? joined.slice(joined.length - maxBytes) : joined;
17208
+ }
17209
+
17210
+ // src/connectors/eas/map.ts
17211
+ function buildEventTime(build) {
17212
+ if (typeof build.completedAt === "string" && build.completedAt.length > 0) return build.completedAt;
17213
+ if (typeof build.createdAt === "string" && build.createdAt.length > 0) return build.createdAt;
17214
+ return (/* @__PURE__ */ new Date()).toISOString();
17215
+ }
17216
+ function incidentMessage2(build) {
17217
+ const err = build.error ?? {};
17218
+ const phase = typeof err.buildPhase === "string" && err.buildPhase.length > 0 ? ` at ${err.buildPhase}` : "";
17219
+ 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";
17220
+ let msg = `EAS build failed${phase}: ${detail}`;
17221
+ if (build.isGitWorkingTreeDirty === true) {
17222
+ msg += " (built from a dirty working tree \u2014 the commit may not represent what built)";
17223
+ }
17224
+ return msg;
17225
+ }
17226
+ function incidentAttributes(build) {
17227
+ const attrs = {};
17228
+ const err = build.error ?? {};
17229
+ const put = (k, v) => {
17230
+ if (typeof v === "string" && v.length === 0) return;
17231
+ if (v !== void 0 && v !== null) attrs[k] = v;
17232
+ };
17233
+ put("eas.buildId", build.id);
17234
+ put("eas.platform", build.platform ?? void 0);
17235
+ put("eas.buildProfile", build.buildProfile ?? void 0);
17236
+ put("eas.buildPhase", err.buildPhase ?? void 0);
17237
+ put("eas.errorCode", err.errorCode ?? void 0);
17238
+ put("eas.docsUrl", err.docsUrl ?? void 0);
17239
+ put("eas.gitCommitHash", build.gitCommitHash ?? void 0);
17240
+ put("eas.gitRef", build.gitRef ?? void 0);
17241
+ put("eas.gitCommitMessage", build.gitCommitMessage ?? void 0);
17242
+ if (typeof build.isGitWorkingTreeDirty === "boolean") {
17243
+ attrs["eas.gitWorkingTreeDirty"] = build.isGitWorkingTreeDirty;
17244
+ if (build.isGitWorkingTreeDirty) attrs["eas.confidence"] = "low";
17245
+ }
17246
+ put("eas.createdAt", build.createdAt ?? void 0);
17247
+ put("eas.completedAt", build.completedAt ?? void 0);
17248
+ if (typeof build.logsText === "string" && build.logsText.length > 0) {
17249
+ attrs["eas.logs"] = build.logsText;
17250
+ }
17251
+ return attrs;
17252
+ }
17253
+ function mapBuildToSignal(build, serviceName) {
17254
+ if (!build || typeof build !== "object") return null;
17255
+ if (build.status !== EAS_STATUS_ERRORED) return null;
17256
+ if (!build.error) return null;
17257
+ if (isTransientFailure(build.error)) return null;
17258
+ const timestamp = buildEventTime(build);
17259
+ const phase = typeof build.error.buildPhase === "string" ? build.error.buildPhase : "";
17260
+ return {
17261
+ targetKind: EAS_TARGET_KIND,
17262
+ targetName: packEasTargetName({ serviceName, phase }),
17263
+ // Incident-only — no edge, so no call/error count to replay.
17264
+ callCount: 0,
17265
+ errorCount: 0,
17266
+ lastObservedIso: timestamp,
17267
+ incident: {
17268
+ id: `eas:build:${build.id}`,
17269
+ timestamp,
17270
+ service: serviceName,
17271
+ errorType: "eas-build-failure",
17272
+ errorMessage: incidentMessage2(build),
17273
+ attributes: incidentAttributes(build)
17274
+ }
17275
+ };
17276
+ }
17277
+ function mapBuildsToSignals(builds, serviceName) {
17278
+ const out = [];
17279
+ for (const build of builds) {
17280
+ const signal = mapBuildToSignal(build, serviceName);
17281
+ if (signal) out.push(signal);
17282
+ }
17283
+ return out;
17284
+ }
17285
+
17286
+ // src/connectors/eas/resolve.ts
17287
+ import { EdgeType as EdgeType34, NodeType as NodeType32, parseFileId as parseFileId3 } from "@neat.is/types";
17288
+ var NO_ENV2 = "unknown";
17289
+ var EAS_JSON_PHASES = /* @__PURE__ */ new Set(["READ_EAS_JSON"]);
17290
+ var APP_CONFIG_PHASES = /* @__PURE__ */ new Set([
17291
+ "READ_APP_CONFIG",
17292
+ "CONFIGURE_EXPO_UPDATES",
17293
+ "CALCULATE_EXPO_UPDATES_RUNTIME_VERSION"
17294
+ ]);
17295
+ function configBasenamesForPhase(phase) {
17296
+ if (EAS_JSON_PHASES.has(phase)) return ["eas.json"];
17297
+ if (APP_CONFIG_PHASES.has(phase)) return ["app.json", "app.config.json"];
17298
+ return [];
17299
+ }
17300
+ function configNodeService(graph, configNodeId) {
17301
+ for (const edgeId of graph.inboundEdges(configNodeId)) {
17302
+ const edge = graph.getEdgeAttributes(edgeId);
17303
+ if (edge.type !== EdgeType34.CONFIGURED_BY) continue;
17304
+ const parsed = parseFileId3(edge.source);
17305
+ if (parsed) return parsed.service;
17306
+ }
17307
+ return null;
17308
+ }
17309
+ function findConfigNode(graph, basenames, serviceName) {
17310
+ let scoped = null;
17311
+ let anyMatch = null;
17312
+ graph.forEachNode((id, attrs) => {
17313
+ if (scoped) return;
17314
+ const node = attrs;
17315
+ if (node.type !== NodeType32.ConfigNode) return;
17316
+ if (typeof node.name !== "string" || !basenames.includes(node.name)) return;
17317
+ if (anyMatch === null) anyMatch = id;
17318
+ if (configNodeService(graph, id) === serviceName) scoped = id;
17319
+ });
17320
+ return scoped ?? anyMatch;
17321
+ }
17322
+ function createEasResolveTarget(graph) {
17323
+ return (signal) => {
17324
+ if (signal.targetKind !== EAS_TARGET_KIND) return null;
17325
+ const identity = parseEasTargetName(signal.targetName);
17326
+ if (!identity) return null;
17327
+ const { serviceName, phase } = identity;
17328
+ const basenames = configBasenamesForPhase(phase);
17329
+ if (basenames.length > 0) {
17330
+ const configNodeId = findConfigNode(graph, basenames, serviceName);
17331
+ if (configNodeId) {
17332
+ return { targetNodeId: configNodeId, serviceName, edgeType: EdgeType34.CALLS };
17333
+ }
17334
+ }
17335
+ return {
17336
+ targetNodeId: resolveFusedServiceId(graph, serviceName, NO_ENV2),
17337
+ serviceName,
17338
+ edgeType: EdgeType34.CALLS
17339
+ };
17340
+ };
17341
+ }
17342
+
17343
+ // src/connectors/eas/index.ts
17344
+ function isBuildSince(build, sinceIso) {
17345
+ const t = Date.parse(buildEventTime(build));
17346
+ const s = Date.parse(sinceIso);
17347
+ if (Number.isNaN(t) || Number.isNaN(s)) return true;
17348
+ return t > s;
17349
+ }
17350
+ function boundedSinceIso2(since, now, maxLookbackMs) {
17351
+ const floor = new Date(now.getTime() - maxLookbackMs);
17352
+ if (!since) return floor.toISOString();
17353
+ const sinceMs = new Date(since).getTime();
17354
+ if (Number.isNaN(sinceMs)) return floor.toISOString();
17355
+ return sinceMs < floor.getTime() ? floor.toISOString() : new Date(sinceMs).toISOString();
17356
+ }
17357
+ var EasConnector = class {
17358
+ constructor(config, fetchImpl) {
17359
+ this.config = config;
17360
+ this.fetchImpl = fetchImpl;
17361
+ }
17362
+ config;
17363
+ fetchImpl;
17364
+ provider = "eas";
17365
+ async poll(ctx) {
17366
+ const creds = readEasCredentials(ctx.credentials);
17367
+ const serviceName = this.config.serviceName ?? this.config.appId;
17368
+ const maxLookbackMs = this.config.maxLookbackMs ?? DEFAULT_MAX_LOOKBACK_MS6;
17369
+ const sinceIso = boundedSinceIso2(ctx.since, /* @__PURE__ */ new Date(), maxLookbackMs);
17370
+ const builds = await fetchErroredBuilds(creds.token, this.config, this.fetchImpl);
17371
+ const fresh = builds.filter((b) => isBuildSince(b, sinceIso));
17372
+ const maxLogBytes = this.config.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES;
17373
+ for (const build of fresh) {
17374
+ build.logsText = await fetchBuildLogs(build.logFileUrls, maxLogBytes, this.fetchImpl);
17375
+ }
17376
+ return mapBuildsToSignals(fresh, serviceName);
17377
+ }
17378
+ };
17379
+ function createEasConnector(graph, config, fetchImpl) {
17380
+ return {
17381
+ connector: new EasConnector(config, fetchImpl),
17382
+ resolveTarget: createEasResolveTarget(graph)
17383
+ };
17384
+ }
17385
+
16590
17386
  // src/connectors/registry.ts
16591
17387
  var CLOUDFLARE_API_BASE_URL = "https://api.cloudflare.com/client/v4";
16592
17388
  async function authProbe(input) {
@@ -16874,6 +17670,41 @@ var PROVIDER_DISPATCH = {
16874
17670
  ...fetchImpl ? { fetchImpl } : {}
16875
17671
  });
16876
17672
  }
17673
+ },
17674
+ eas: {
17675
+ provider: "eas",
17676
+ // The secret is a single robot-user EXPO_TOKEN; a single-string credential
17677
+ // maps to `token`. `appId` is non-secret config (connector-config.md §7.1).
17678
+ primaryCredentialKey: "token",
17679
+ requiredCredentialFields: ["token"],
17680
+ requiredOptionFields: ["appId"],
17681
+ build(graph, options) {
17682
+ return createEasConnector(graph, options);
17683
+ },
17684
+ // Runs the connector's real `builds` query at limit 1 — the exact read poll()
17685
+ // performs, minus the pages — so the probe checks both that the EXPO_TOKEN
17686
+ // authenticates and that this app id is reachable, the same probe-the-real-
17687
+ // query discipline Railway and Cloud Run use over a trivial `{ __typename }`.
17688
+ // A bad or wrong-scoped token comes back as an Expo GraphQL error, which
17689
+ // `fetchErroredBuilds` throws on, so it fails honestly here rather than
17690
+ // silently at the first poll.
17691
+ async validate({ credentials, options, fetchImpl }) {
17692
+ const cfg = options;
17693
+ const appId = String(cfg.appId ?? "");
17694
+ if (!appId) return { ok: false, reason: "eas: appId is required to validate" };
17695
+ const probeConfig = {
17696
+ appId,
17697
+ pageSize: 1,
17698
+ maxPages: 1,
17699
+ ...cfg.apiUrl ? { apiUrl: cfg.apiUrl } : {}
17700
+ };
17701
+ try {
17702
+ await fetchErroredBuilds(String(credentials.token ?? ""), probeConfig, fetchImpl);
17703
+ return { ok: true };
17704
+ } catch (err) {
17705
+ return { ok: false, reason: `eas auth check failed: ${err.message}` };
17706
+ }
17707
+ }
16877
17708
  }
16878
17709
  };
16879
17710
  function vercelCredsFrom(credentials) {
@@ -17086,7 +17917,11 @@ async function startConnectorPolling(input) {
17086
17917
  const stopFns = all.map(
17087
17918
  (registration) => startConnectorPollLoop(
17088
17919
  registration.connector,
17089
- { projectDir: input.projectDir, credentials: registration.credentials },
17920
+ {
17921
+ projectDir: input.projectDir,
17922
+ credentials: registration.credentials,
17923
+ ...input.errorsPath ? { errorsPath: input.errorsPath } : {}
17924
+ },
17090
17925
  input.graph,
17091
17926
  registration.resolveTarget,
17092
17927
  { intervalMs: registration.intervalMs, connectorId: registration.id }
@@ -17415,10 +18250,15 @@ function registerRoutes(scope, ctx) {
17415
18250
  }
17416
18251
  const reg = built.registration;
17417
18252
  const at = (/* @__PURE__ */ new Date()).toISOString();
18253
+ const incidentsPath = errorsPathFor(proj);
17418
18254
  try {
17419
18255
  const result = await ctx.runPoll(
17420
18256
  reg.connector,
17421
- { projectDir: proj.scanPath ?? "", credentials: reg.credentials },
18257
+ {
18258
+ projectDir: proj.scanPath ?? "",
18259
+ credentials: reg.credentials,
18260
+ ...incidentsPath ? { errorsPath: incidentsPath } : {}
18261
+ },
17422
18262
  proj.graph,
17423
18263
  reg.resolveTarget
17424
18264
  );
@@ -17488,6 +18328,34 @@ function registerRoutes(scope, ctx) {
17488
18328
  }
17489
18329
  return getBlastRadius(proj.graph, nodeId, depth);
17490
18330
  });
18331
+ scope.get("/graph/expand/:nodeId", async (req, reply) => {
18332
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18333
+ if (!proj) return;
18334
+ const { nodeId } = req.params;
18335
+ if (!proj.graph.hasNode(nodeId)) {
18336
+ return reply.code(404).send({ error: "node not found", id: nodeId });
18337
+ }
18338
+ const direction = req.query.direction;
18339
+ if (direction !== "up" && direction !== "down") {
18340
+ return reply.code(400).send({ error: 'direction must be "up" or "down"' });
18341
+ }
18342
+ const epath = errorsPathFor(proj);
18343
+ const incidents = epath ? await readErrorEvents(epath) : [];
18344
+ return expandNode(proj.graph, nodeId, direction, incidents);
18345
+ });
18346
+ scope.get("/graph/relate", async (req, reply) => {
18347
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18348
+ if (!proj) return;
18349
+ const { a, b } = req.query;
18350
+ if (!a || !b) {
18351
+ return reply.code(400).send({ error: "both a and b query params are required" });
18352
+ }
18353
+ const maxDepth = req.query.maxDepth ? Number(req.query.maxDepth) : void 0;
18354
+ if (maxDepth !== void 0 && (!Number.isFinite(maxDepth) || maxDepth < 1)) {
18355
+ return reply.code(400).send({ error: "maxDepth must be a positive integer" });
18356
+ }
18357
+ return relate(proj.graph, a, b, maxDepth);
18358
+ });
17491
18359
  scope.get("/search", async (req, reply) => {
17492
18360
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
17493
18361
  if (!proj) return;
@@ -17981,8 +18849,8 @@ export {
17981
18849
  emitNeatEvent,
17982
18850
  attachGraphToEventBus,
17983
18851
  confidenceForEdge,
17984
- getRootCause,
17985
18852
  getBlastRadius,
18853
+ getRootCause,
17986
18854
  evaluateAllPolicies,
17987
18855
  loadPolicyFile,
17988
18856
  PolicyViolationsLog,
@@ -18056,4 +18924,4 @@ export {
18056
18924
  deprovisionConnector,
18057
18925
  buildApi
18058
18926
  };
18059
- //# sourceMappingURL=chunk-N5TPODCX.js.map
18927
+ //# sourceMappingURL=chunk-3PXRQH53.js.map