@neat.is/core 0.9.3-dev.20260823 → 0.9.3

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
@@ -1925,6 +1925,47 @@ function classifyNode(ctx) {
1925
1925
  function isVictimSeed(ctx) {
1926
1926
  return ctx.errorsFromCallers > 0 && (ctx.stale || isSaturated(ctx)) && ctx.errorsEmittedHere <= ctx.errorsFromCallers;
1927
1927
  }
1928
+ var OUTBOUND_CONNECTION_FAILURE_PATTERNS = [
1929
+ "name resolution",
1930
+ "resolve host",
1931
+ "getaddrinfo",
1932
+ "enotfound",
1933
+ "connection refused",
1934
+ "econnrefused",
1935
+ "connection reset",
1936
+ "econnreset",
1937
+ "able to connect",
1938
+ "failed to connect",
1939
+ "cannot connect",
1940
+ "could not connect",
1941
+ "unable to connect",
1942
+ "connection timed out",
1943
+ "etimedout",
1944
+ "no route to host",
1945
+ "host unreachable",
1946
+ "network is unreachable",
1947
+ "connection closed"
1948
+ ];
1949
+ function incidentTextIndicatesOutboundFailure(ev) {
1950
+ const haystack = [ev.errorMessage, ev.errorType, ev.exceptionType, ev.exceptionStacktrace].filter((s) => typeof s === "string").join(" ").toLowerCase();
1951
+ return OUTBOUND_CONNECTION_FAILURE_PATTERNS.some((p) => haystack.includes(p));
1952
+ }
1953
+ function hasFailingOutbound(graph, nodeId, seedSource, incidents) {
1954
+ for (const n of nodeScope(graph, nodeId)) {
1955
+ if (!graph.hasNode(n)) continue;
1956
+ for (const edgeId of graph.outboundEdges(n)) {
1957
+ const e = graph.getEdgeAttributes(edgeId);
1958
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1959
+ if ((e.signal?.errorCount ?? 0) > 0) return true;
1960
+ }
1961
+ }
1962
+ if (seedSource === "incident" && incidents) {
1963
+ for (const ev of incidents) {
1964
+ if (incidentMatchesNode(ev, nodeId) && incidentTextIndicatesOutboundFailure(ev)) return true;
1965
+ }
1966
+ }
1967
+ return false;
1968
+ }
1928
1969
  function grainOf(graph, nodeId) {
1929
1970
  if (!graph.hasNode(nodeId)) return "unknown";
1930
1971
  const t = graph.getNodeAttributes(nodeId).type;
@@ -2088,7 +2129,8 @@ function enrichWithNavigation(graph, errorNodeId, tagged, incidents, now) {
2088
2129
  const candidates = [];
2089
2130
  const deadEndOnSymptom = tagged.source === "incident" && seedNode === errorNodeId && legacy.traversalPath.length === 1;
2090
2131
  const staleChain = deadEndOnSymptom && !(seedCtx && isVictimSeed(seedCtx)) ? followStaleCallChain(graph, errorNodeId, ROOT_CAUSE_MAX_DEPTH) : null;
2091
- if (seedCtx && isVictimSeed(seedCtx)) {
2132
+ const seedFailsOutbound = seedCtx !== null && hasFailingOutbound(graph, seedNode, tagged.source, incidents);
2133
+ if (seedCtx && isVictimSeed(seedCtx) && !seedFailsOutbound) {
2092
2134
  const origin = findLoadOrigin(graph, errorNodeId, incidents, now);
2093
2135
  const staleNote = seedCtx.stale ? "; it has gone STALE under load" : "";
2094
2136
  const satNote = isSaturated(seedCtx) ? `; its inbound p95 ${Math.round(seedCtx.latencyP95Ms)}ms is saturated` : "";
@@ -6848,15 +6890,36 @@ function startStalenessLoop(graph, options = {}) {
6848
6890
  clearInterval(interval);
6849
6891
  };
6850
6892
  }
6851
- async function readErrorEvents(errorsPath) {
6893
+ var INCIDENT_READ_MAX_BYTES = 32 * 1024 * 1024;
6894
+ var INCIDENT_READ_MAX_EVENTS = 5e3;
6895
+ async function readErrorFileTail(errorsPath, maxBytes) {
6896
+ const handle = await import_node_fs7.promises.open(errorsPath, "r");
6897
+ try {
6898
+ const { size } = await handle.stat();
6899
+ if (size <= maxBytes) {
6900
+ return (await handle.readFile()).toString("utf8");
6901
+ }
6902
+ const buf = Buffer.alloc(maxBytes);
6903
+ await handle.read(buf, 0, maxBytes, size - maxBytes);
6904
+ const raw = buf.toString("utf8");
6905
+ const firstNewline = raw.indexOf("\n");
6906
+ return firstNewline === -1 ? "" : raw.slice(firstNewline + 1);
6907
+ } finally {
6908
+ await handle.close();
6909
+ }
6910
+ }
6911
+ async function readErrorEvents(errorsPath, opts) {
6912
+ let raw;
6852
6913
  try {
6853
- const raw = await import_node_fs7.promises.readFile(errorsPath, "utf8");
6854
- const events = raw.split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
6855
- return dedupeIncidents(events);
6914
+ raw = await readErrorFileTail(errorsPath, INCIDENT_READ_MAX_BYTES);
6856
6915
  } catch (err) {
6857
6916
  if (err.code === "ENOENT") return [];
6858
6917
  throw err;
6859
6918
  }
6919
+ const events = raw.split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
6920
+ const deduped = dedupeIncidents(events);
6921
+ const cap = opts?.limit !== void 0 && opts.limit > 0 ? Math.min(opts.limit, INCIDENT_READ_MAX_EVENTS) : INCIDENT_READ_MAX_EVENTS;
6922
+ return deduped.length > cap ? deduped.slice(deduped.length - cap) : deduped;
6860
6923
  }
6861
6924
  function isSynthesizedHttpIncident(ev) {
6862
6925
  if (ev.exceptionType || ev.exceptionStacktrace) return false;
@@ -16443,6 +16506,120 @@ function detectColumnDrift(node) {
16443
16506
  }
16444
16507
  return out;
16445
16508
  }
16509
+ var SYMBOL_MISMATCH_PATTERNS = [
16510
+ {
16511
+ // "'ListProductsResponse' object has no attribute 'products_list'" and kin.
16512
+ kind: "missing-attribute",
16513
+ patterns: [/\bhas no attribute\b[\s:=]*['"`]?([A-Za-z_$][\w$]*)['"`]?/i]
16514
+ },
16515
+ {
16516
+ // "object has no field 'X'", "no such field X", "unknown field X".
16517
+ kind: "missing-field",
16518
+ patterns: [
16519
+ /\b(?:has no field|no such field|unknown field)\b(?:\s+named)?[\s:=]*['"`]?([A-Za-z_$][\w$.]*)['"`]?/i
16520
+ ]
16521
+ },
16522
+ {
16523
+ // "has no property X", "no property named X".
16524
+ kind: "missing-property",
16525
+ patterns: [
16526
+ /\b(?:has no property|no property named)\b[\s:=]*['"`]?([A-Za-z_$][\w$]*)['"`]?/i
16527
+ ]
16528
+ },
16529
+ {
16530
+ // "no such column: X", "unknown column 'X'", "column X does not exist".
16531
+ kind: "missing-column",
16532
+ patterns: [
16533
+ /\bno such column\b[\s:=]*['"`]?([\w$.]+)['"`]?/i,
16534
+ /\bunknown column\b[\s:=]*['"`]?([\w$.]+)['"`]?/i,
16535
+ /\bcolumn\b[\s:=]*['"`]?([\w$.]+)['"`]?\s+does not exist/i
16536
+ ]
16537
+ },
16538
+ {
16539
+ // "undefined method `foo' for X" — kept to the unambiguous form so a generic
16540
+ // "method not found" (an unimplemented RPC — an edge/route gap, not a symbol
16541
+ // mismatch) does not get miscategorised here.
16542
+ kind: "undefined-method",
16543
+ patterns: [/\bundefined method\b\s*[`'"]?([\w$?!]+)/i]
16544
+ }
16545
+ ];
16546
+ function classifySymbolMismatch(message) {
16547
+ for (const entry of SYMBOL_MISMATCH_PATTERNS) {
16548
+ for (const re of entry.patterns) {
16549
+ const m = re.exec(message);
16550
+ if (m) {
16551
+ const captured = m[1];
16552
+ return captured ? { kind: entry.kind, symbol: captured } : { kind: entry.kind };
16553
+ }
16554
+ }
16555
+ }
16556
+ return null;
16557
+ }
16558
+ function symbolLocus(graph, ev) {
16559
+ const attrs = ev.attributes ?? {};
16560
+ const filepath = codeFilepathOf(attrs);
16561
+ const lineno = codeLinenoOf(attrs);
16562
+ const location = filepath ? `${filepath}${lineno !== void 0 ? `:${lineno}` : ""}` : void 0;
16563
+ const affected = ev.affectedNode;
16564
+ const affectedInGraph = affected.length > 0 && graph.hasNode(affected);
16565
+ const affectedIsCode = affectedInGraph && ((0, import_types58.parseSymbolId)(affected) !== null || (0, import_types58.parseFileId)(affected) !== null);
16566
+ if (affectedIsCode) return { node: affected, ...location ? { location } : {} };
16567
+ if (!location) return null;
16568
+ if (affectedInGraph) return { node: affected, location };
16569
+ const svc = (0, import_types58.serviceId)(ev.service);
16570
+ if (graph.hasNode(svc)) return { node: svc, location };
16571
+ return null;
16572
+ }
16573
+ var SYMBOL_MISMATCH_CONFIDENCE = 0.6;
16574
+ function detectSymbolMismatches(graph, incidents) {
16575
+ const groups = /* @__PURE__ */ new Map();
16576
+ for (const ev of incidents) {
16577
+ const classified = classifySymbolMismatch(ev.errorMessage);
16578
+ if (!classified) continue;
16579
+ const locus = symbolLocus(graph, ev);
16580
+ if (!locus) continue;
16581
+ const key = `${locus.node}|${classified.kind}|${classified.symbol ?? ""}`;
16582
+ const existing = groups.get(key);
16583
+ if (!existing) {
16584
+ groups.set(key, {
16585
+ node: locus.node,
16586
+ kind: classified.kind,
16587
+ ...classified.symbol ? { symbol: classified.symbol } : {},
16588
+ ...locus.location ? { location: locus.location } : {},
16589
+ latest: ev,
16590
+ count: 1
16591
+ });
16592
+ } else {
16593
+ existing.count += 1;
16594
+ if (ev.timestamp.localeCompare(existing.latest.timestamp) > 0) {
16595
+ existing.latest = ev;
16596
+ if (locus.location) existing.location = locus.location;
16597
+ }
16598
+ }
16599
+ }
16600
+ const out = [];
16601
+ for (const g of groups.values()) {
16602
+ const member = g.symbol ? `\`${g.symbol}\`` : "a member";
16603
+ const where = g.location ? ` at ${g.location}` : "";
16604
+ const times = g.count > 1 ? ` (${g.count} recorded incidents)` : " (1 recorded incident)";
16605
+ out.push({
16606
+ type: "observed-symbol-mismatch",
16607
+ source: g.node,
16608
+ target: g.node,
16609
+ mismatchKind: g.kind,
16610
+ ...g.symbol ? { symbol: g.symbol } : {},
16611
+ ...g.location ? { location: g.location } : {},
16612
+ provenance: import_types58.Provenance.INFERRED,
16613
+ incidentId: g.latest.id,
16614
+ errorMessage: g.latest.errorMessage,
16615
+ incidentCount: g.count,
16616
+ confidence: SYMBOL_MISMATCH_CONFIDENCE,
16617
+ reason: `Code${where} declares access to ${member} the runtime object does not have \u2014 ${g.latest.service} raised "${g.latest.errorMessage}"${times}. The declared access (EXTRACTED) and the runtime shape (OBSERVED) disagree at symbol grain.`,
16618
+ recommendation: "Reconcile the declared access with the runtime shape: a field, attribute, method, or column was renamed, removed, or never existed on the object this code reaches. Update the code to the current shape, or restore the member."
16619
+ });
16620
+ }
16621
+ return out;
16622
+ }
16446
16623
  function involvesNode(d, nodeId) {
16447
16624
  return d.source === nodeId || d.target === nodeId;
16448
16625
  }
@@ -16528,6 +16705,9 @@ function computeDivergences(graph, opts = {}) {
16528
16705
  for (const d of detectColumnDrift(n)) all.push(d);
16529
16706
  }
16530
16707
  });
16708
+ if (opts.incidents && opts.incidents.length > 0) {
16709
+ for (const d of detectSymbolMismatches(graph, opts.incidents)) all.push(d);
16710
+ }
16531
16711
  const reconciled = suppressHostMismatchHalves(all);
16532
16712
  const dampened = dampenDeadCodeProbes(graph, buckets2, reconciled);
16533
16713
  let filtered = dampened;
@@ -16548,7 +16728,12 @@ function computeDivergences(graph, opts = {}) {
16548
16728
  "missing-observed": 1,
16549
16729
  "version-mismatch": 2,
16550
16730
  "host-mismatch": 3,
16551
- "compat-violation": 4
16731
+ "compat-violation": 4,
16732
+ // Symbol/field-grain (ADR-215) rides the confidence sort like every other
16733
+ // type; this only breaks a confidence tie, and it orders last so a same-
16734
+ // confidence edge finding leads. In practice it carries the INFERRED grade
16735
+ // (0.6), so it sits below the high-confidence edge divergences already.
16736
+ "observed-symbol-mismatch": 5
16552
16737
  };
16553
16738
  filtered.sort((a, b) => {
16554
16739
  if (b.confidence !== a.confidence) return b.confidence - a.confidence;
@@ -16559,7 +16744,10 @@ function computeDivergences(graph, opts = {}) {
16559
16744
  if (a.target !== b.target) return a.target.localeCompare(b.target);
16560
16745
  const ac = "column" in a && a.column ? a.column : "";
16561
16746
  const bc = "column" in b && b.column ? b.column : "";
16562
- return ac.localeCompare(bc);
16747
+ if (ac !== bc) return ac.localeCompare(bc);
16748
+ const asym = "symbol" in a && a.symbol ? a.symbol : "";
16749
+ const bsym = "symbol" in b && b.symbol ? b.symbol : "";
16750
+ return asym.localeCompare(bsym);
16563
16751
  });
16564
16752
  return import_types58.DivergenceResultSchema.parse({
16565
16753
  divergences: filtered,
@@ -16986,10 +17174,15 @@ function divergenceLine(d) {
16986
17174
  if ((d.type === "missing-observed" || d.type === "missing-extracted") && d.column) {
16987
17175
  return `[${d.type}] ${d.table ?? d.source} column ${d.column} \u2014 ${d.reason}`;
16988
17176
  }
17177
+ if (d.type === "observed-symbol-mismatch") {
17178
+ const at = d.location ? ` at ${d.location}` : "";
17179
+ const member = d.symbol ? ` ${d.symbol}` : "";
17180
+ return `[${d.type}] ${d.source}${member}${at} \u2014 ${d.reason}`;
17181
+ }
16989
17182
  return `[${d.type}] ${d.source} \u2192 ${d.target} \u2014 ${d.reason}`;
16990
17183
  }
16991
- function buildDivergenceSection(graph, node) {
16992
- const result = computeDivergences(graph, { node });
17184
+ function buildDivergenceSection(graph, node, incidents) {
17185
+ const result = computeDivergences(graph, { node, ...incidents ? { incidents } : {} });
16993
17186
  if (result.totalAffected === 0) return null;
16994
17187
  const facts = result.divergences.slice(0, MAX_FACTS_PER_SECTION).map((d) => ({
16995
17188
  text: divergenceLine(d),
@@ -17002,8 +17195,8 @@ function buildDivergenceSection(graph, node) {
17002
17195
  facts
17003
17196
  };
17004
17197
  }
17005
- function buildGlobalDivergenceSection(graph) {
17006
- const result = computeDivergences(graph);
17198
+ function buildGlobalDivergenceSection(graph, incidents) {
17199
+ const result = computeDivergences(graph, incidents ? { incidents } : {});
17007
17200
  if (result.totalAffected === 0) {
17008
17201
  return {
17009
17202
  heading: "Divergences (EXTRACTED vs OBSERVED)",
@@ -17101,7 +17294,7 @@ function buildOverviewSections(graph, incidents) {
17101
17294
  }))
17102
17295
  });
17103
17296
  }
17104
- const div = computeDivergences(graph);
17297
+ const div = computeDivergences(graph, incidents ? { incidents } : {});
17105
17298
  sections.push({
17106
17299
  heading: "Divergences",
17107
17300
  facts: [
@@ -17115,7 +17308,7 @@ function buildOverviewSections(graph, incidents) {
17115
17308
  function buildGlobalSections(intent, graph, incidents) {
17116
17309
  switch (intent) {
17117
17310
  case "divergence":
17118
- return [buildGlobalDivergenceSection(graph)];
17311
+ return [buildGlobalDivergenceSection(graph, incidents)];
17119
17312
  case "incidents":
17120
17313
  return [buildGlobalIncidentsSection(incidents)];
17121
17314
  case "overview":
@@ -17146,7 +17339,7 @@ function buildSection(kind, graph, node, incidents, now) {
17146
17339
  case "incidents":
17147
17340
  return buildIncidentsSection(node, incidents);
17148
17341
  case "divergence":
17149
- return buildDivergenceSection(graph, node);
17342
+ return buildDivergenceSection(graph, node, incidents);
17150
17343
  }
17151
17344
  }
17152
17345
  function summarizeGlobal(intent, sections) {
@@ -21131,6 +21324,8 @@ async function startConnectorPolling(input) {
21131
21324
  }
21132
21325
 
21133
21326
  // src/api.ts
21327
+ var INCIDENT_LIST_DEFAULT_LIMIT = 50;
21328
+ var INCIDENT_LIST_MAX_LIMIT = 200;
21134
21329
  function serializeGraph(graph) {
21135
21330
  const nodes = [];
21136
21331
  graph.forEachNode((_id, attrs) => {
@@ -21310,10 +21505,13 @@ function registerRoutes(scope, ctx) {
21310
21505
  }
21311
21506
  minConfidence = n;
21312
21507
  }
21508
+ const epath = errorsPathFor(proj);
21509
+ const incidents = epath ? await readErrorEvents(epath) : [];
21313
21510
  return computeDivergences(proj.graph, {
21314
21511
  ...typeFilter ? { type: typeFilter } : {},
21315
21512
  ...minConfidence !== void 0 ? { minConfidence } : {},
21316
- ...req.query.node ? { node: req.query.node } : {}
21513
+ ...req.query.node ? { node: req.query.node } : {},
21514
+ incidents
21317
21515
  });
21318
21516
  });
21319
21517
  scope.get("/incidents", async (req, reply) => {
@@ -21323,10 +21521,11 @@ function registerRoutes(scope, ctx) {
21323
21521
  if (!epath) return { count: 0, total: 0, events: [] };
21324
21522
  const events = await readErrorEvents(epath);
21325
21523
  const total = events.length;
21326
- const limit = req.query.limit ? Number(req.query.limit) : 50;
21327
- const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 200) : 50;
21524
+ const limit = req.query.limit ? Number(req.query.limit) : INCIDENT_LIST_DEFAULT_LIMIT;
21525
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, INCIDENT_LIST_MAX_LIMIT) : INCIDENT_LIST_DEFAULT_LIMIT;
21328
21526
  const sliced = events.slice(0, safeLimit);
21329
- return { count: sliced.length, total, events: sliced };
21527
+ const omitted = total - sliced.length;
21528
+ return { count: sliced.length, total, events: sliced, ...omitted > 0 ? { omitted } : {} };
21330
21529
  });
21331
21530
  scope.get("/stale-events", async (req, reply) => {
21332
21531
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
@@ -21435,16 +21634,20 @@ function registerRoutes(scope, ctx) {
21435
21634
  const filtered = events.filter(
21436
21635
  (e) => e.affectedNode === nodeId || e.service === nodeId.replace(/^service:/, "")
21437
21636
  );
21438
- return { count: filtered.length, total: filtered.length, events: filtered };
21637
+ const total = filtered.length;
21638
+ const limit = req.query.limit ? Number(req.query.limit) : INCIDENT_LIST_DEFAULT_LIMIT;
21639
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, INCIDENT_LIST_MAX_LIMIT) : INCIDENT_LIST_DEFAULT_LIMIT;
21640
+ const recent = [...filtered].sort((a, b) => (b.timestamp ?? "").localeCompare(a.timestamp ?? "")).slice(0, safeLimit);
21641
+ const omitted = total - recent.length;
21642
+ return {
21643
+ count: recent.length,
21644
+ total,
21645
+ events: recent,
21646
+ ...omitted > 0 ? { omitted } : {}
21647
+ };
21439
21648
  };
21440
- scope.get(
21441
- "/incidents/:nodeId",
21442
- incidentHistoryHandler
21443
- );
21444
- scope.get(
21445
- "/graph/incident-history/:nodeId",
21446
- incidentHistoryHandler
21447
- );
21649
+ scope.get("/incidents/:nodeId", incidentHistoryHandler);
21650
+ scope.get("/graph/incident-history/:nodeId", incidentHistoryHandler);
21448
21651
  scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
21449
21652
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
21450
21653
  if (!proj) return;