@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.d.cts CHANGED
@@ -441,7 +441,9 @@ interface StalenessLoopOptions {
441
441
  onPolicyTrigger?: (graph: NeatGraph) => Promise<void> | void;
442
442
  }
443
443
  declare function startStalenessLoop(graph: NeatGraph, options?: StalenessLoopOptions): () => void;
444
- declare function readErrorEvents(errorsPath: string): Promise<ErrorEvent[]>;
444
+ declare function readErrorEvents(errorsPath: string, opts?: {
445
+ limit?: number;
446
+ }): Promise<ErrorEvent[]>;
445
447
 
446
448
  declare function confidenceForEdge(edge: GraphEdge, now?: number): number;
447
449
  declare function getBlastRadius(graph: NeatGraph, nodeId: string, maxDepth?: number): BlastRadiusResult;
package/dist/index.d.ts CHANGED
@@ -441,7 +441,9 @@ interface StalenessLoopOptions {
441
441
  onPolicyTrigger?: (graph: NeatGraph) => Promise<void> | void;
442
442
  }
443
443
  declare function startStalenessLoop(graph: NeatGraph, options?: StalenessLoopOptions): () => void;
444
- declare function readErrorEvents(errorsPath: string): Promise<ErrorEvent[]>;
444
+ declare function readErrorEvents(errorsPath: string, opts?: {
445
+ limit?: number;
446
+ }): Promise<ErrorEvent[]>;
445
447
 
446
448
  declare function confidenceForEdge(edge: GraphEdge, now?: number): number;
447
449
  declare function getBlastRadius(graph: NeatGraph, nodeId: string, maxDepth?: number): BlastRadiusResult;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  routeSpanToProject,
3
3
  startDaemon
4
- } from "./chunk-WJGZYEUG.js";
4
+ } from "./chunk-2D4Y5QHE.js";
5
5
  import {
6
6
  ProjectNameCollisionError,
7
7
  addProject,
@@ -37,7 +37,7 @@ import {
37
37
  thresholdForEdgeType,
38
38
  touchLastSeen,
39
39
  writeAtomically
40
- } from "./chunk-LRYPKC3B.js";
40
+ } from "./chunk-EDE4XP2M.js";
41
41
  import {
42
42
  startOtelGrpcReceiver
43
43
  } from "./chunk-ERE47MCR.js";
package/dist/neatd.cjs CHANGED
@@ -1888,6 +1888,47 @@ function classifyNode(ctx) {
1888
1888
  function isVictimSeed(ctx) {
1889
1889
  return ctx.errorsFromCallers > 0 && (ctx.stale || isSaturated(ctx)) && ctx.errorsEmittedHere <= ctx.errorsFromCallers;
1890
1890
  }
1891
+ var OUTBOUND_CONNECTION_FAILURE_PATTERNS = [
1892
+ "name resolution",
1893
+ "resolve host",
1894
+ "getaddrinfo",
1895
+ "enotfound",
1896
+ "connection refused",
1897
+ "econnrefused",
1898
+ "connection reset",
1899
+ "econnreset",
1900
+ "able to connect",
1901
+ "failed to connect",
1902
+ "cannot connect",
1903
+ "could not connect",
1904
+ "unable to connect",
1905
+ "connection timed out",
1906
+ "etimedout",
1907
+ "no route to host",
1908
+ "host unreachable",
1909
+ "network is unreachable",
1910
+ "connection closed"
1911
+ ];
1912
+ function incidentTextIndicatesOutboundFailure(ev) {
1913
+ const haystack = [ev.errorMessage, ev.errorType, ev.exceptionType, ev.exceptionStacktrace].filter((s) => typeof s === "string").join(" ").toLowerCase();
1914
+ return OUTBOUND_CONNECTION_FAILURE_PATTERNS.some((p) => haystack.includes(p));
1915
+ }
1916
+ function hasFailingOutbound(graph, nodeId, seedSource, incidents) {
1917
+ for (const n of nodeScope(graph, nodeId)) {
1918
+ if (!graph.hasNode(n)) continue;
1919
+ for (const edgeId of graph.outboundEdges(n)) {
1920
+ const e = graph.getEdgeAttributes(edgeId);
1921
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1922
+ if ((e.signal?.errorCount ?? 0) > 0) return true;
1923
+ }
1924
+ }
1925
+ if (seedSource === "incident" && incidents) {
1926
+ for (const ev of incidents) {
1927
+ if (incidentMatchesNode(ev, nodeId) && incidentTextIndicatesOutboundFailure(ev)) return true;
1928
+ }
1929
+ }
1930
+ return false;
1931
+ }
1891
1932
  function grainOf(graph, nodeId) {
1892
1933
  if (!graph.hasNode(nodeId)) return "unknown";
1893
1934
  const t = graph.getNodeAttributes(nodeId).type;
@@ -2051,7 +2092,8 @@ function enrichWithNavigation(graph, errorNodeId, tagged, incidents, now) {
2051
2092
  const candidates = [];
2052
2093
  const deadEndOnSymptom = tagged.source === "incident" && seedNode === errorNodeId && legacy.traversalPath.length === 1;
2053
2094
  const staleChain = deadEndOnSymptom && !(seedCtx && isVictimSeed(seedCtx)) ? followStaleCallChain(graph, errorNodeId, ROOT_CAUSE_MAX_DEPTH) : null;
2054
- if (seedCtx && isVictimSeed(seedCtx)) {
2095
+ const seedFailsOutbound = seedCtx !== null && hasFailingOutbound(graph, seedNode, tagged.source, incidents);
2096
+ if (seedCtx && isVictimSeed(seedCtx) && !seedFailsOutbound) {
2055
2097
  const origin = findLoadOrigin(graph, errorNodeId, incidents, now);
2056
2098
  const staleNote = seedCtx.stale ? "; it has gone STALE under load" : "";
2057
2099
  const satNote = isSaturated(seedCtx) ? `; its inbound p95 ${Math.round(seedCtx.latencyP95Ms)}ms is saturated` : "";
@@ -6808,15 +6850,36 @@ function startStalenessLoop(graph, options = {}) {
6808
6850
  clearInterval(interval);
6809
6851
  };
6810
6852
  }
6811
- async function readErrorEvents(errorsPath) {
6853
+ var INCIDENT_READ_MAX_BYTES = 32 * 1024 * 1024;
6854
+ var INCIDENT_READ_MAX_EVENTS = 5e3;
6855
+ async function readErrorFileTail(errorsPath, maxBytes) {
6856
+ const handle = await import_node_fs7.promises.open(errorsPath, "r");
6857
+ try {
6858
+ const { size } = await handle.stat();
6859
+ if (size <= maxBytes) {
6860
+ return (await handle.readFile()).toString("utf8");
6861
+ }
6862
+ const buf = Buffer.alloc(maxBytes);
6863
+ await handle.read(buf, 0, maxBytes, size - maxBytes);
6864
+ const raw = buf.toString("utf8");
6865
+ const firstNewline = raw.indexOf("\n");
6866
+ return firstNewline === -1 ? "" : raw.slice(firstNewline + 1);
6867
+ } finally {
6868
+ await handle.close();
6869
+ }
6870
+ }
6871
+ async function readErrorEvents(errorsPath, opts) {
6872
+ let raw;
6812
6873
  try {
6813
- const raw = await import_node_fs7.promises.readFile(errorsPath, "utf8");
6814
- const events = raw.split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
6815
- return dedupeIncidents(events);
6874
+ raw = await readErrorFileTail(errorsPath, INCIDENT_READ_MAX_BYTES);
6816
6875
  } catch (err) {
6817
6876
  if (err.code === "ENOENT") return [];
6818
6877
  throw err;
6819
6878
  }
6879
+ const events = raw.split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
6880
+ const deduped = dedupeIncidents(events);
6881
+ const cap = opts?.limit !== void 0 && opts.limit > 0 ? Math.min(opts.limit, INCIDENT_READ_MAX_EVENTS) : INCIDENT_READ_MAX_EVENTS;
6882
+ return deduped.length > cap ? deduped.slice(deduped.length - cap) : deduped;
6820
6883
  }
6821
6884
  function isSynthesizedHttpIncident(ev) {
6822
6885
  if (ev.exceptionType || ev.exceptionStacktrace) return false;
@@ -16455,6 +16518,120 @@ function detectColumnDrift(node) {
16455
16518
  }
16456
16519
  return out;
16457
16520
  }
16521
+ var SYMBOL_MISMATCH_PATTERNS = [
16522
+ {
16523
+ // "'ListProductsResponse' object has no attribute 'products_list'" and kin.
16524
+ kind: "missing-attribute",
16525
+ patterns: [/\bhas no attribute\b[\s:=]*['"`]?([A-Za-z_$][\w$]*)['"`]?/i]
16526
+ },
16527
+ {
16528
+ // "object has no field 'X'", "no such field X", "unknown field X".
16529
+ kind: "missing-field",
16530
+ patterns: [
16531
+ /\b(?:has no field|no such field|unknown field)\b(?:\s+named)?[\s:=]*['"`]?([A-Za-z_$][\w$.]*)['"`]?/i
16532
+ ]
16533
+ },
16534
+ {
16535
+ // "has no property X", "no property named X".
16536
+ kind: "missing-property",
16537
+ patterns: [
16538
+ /\b(?:has no property|no property named)\b[\s:=]*['"`]?([A-Za-z_$][\w$]*)['"`]?/i
16539
+ ]
16540
+ },
16541
+ {
16542
+ // "no such column: X", "unknown column 'X'", "column X does not exist".
16543
+ kind: "missing-column",
16544
+ patterns: [
16545
+ /\bno such column\b[\s:=]*['"`]?([\w$.]+)['"`]?/i,
16546
+ /\bunknown column\b[\s:=]*['"`]?([\w$.]+)['"`]?/i,
16547
+ /\bcolumn\b[\s:=]*['"`]?([\w$.]+)['"`]?\s+does not exist/i
16548
+ ]
16549
+ },
16550
+ {
16551
+ // "undefined method `foo' for X" — kept to the unambiguous form so a generic
16552
+ // "method not found" (an unimplemented RPC — an edge/route gap, not a symbol
16553
+ // mismatch) does not get miscategorised here.
16554
+ kind: "undefined-method",
16555
+ patterns: [/\bundefined method\b\s*[`'"]?([\w$?!]+)/i]
16556
+ }
16557
+ ];
16558
+ function classifySymbolMismatch(message) {
16559
+ for (const entry2 of SYMBOL_MISMATCH_PATTERNS) {
16560
+ for (const re of entry2.patterns) {
16561
+ const m = re.exec(message);
16562
+ if (m) {
16563
+ const captured = m[1];
16564
+ return captured ? { kind: entry2.kind, symbol: captured } : { kind: entry2.kind };
16565
+ }
16566
+ }
16567
+ }
16568
+ return null;
16569
+ }
16570
+ function symbolLocus(graph, ev) {
16571
+ const attrs = ev.attributes ?? {};
16572
+ const filepath = codeFilepathOf(attrs);
16573
+ const lineno = codeLinenoOf(attrs);
16574
+ const location = filepath ? `${filepath}${lineno !== void 0 ? `:${lineno}` : ""}` : void 0;
16575
+ const affected = ev.affectedNode;
16576
+ const affectedInGraph = affected.length > 0 && graph.hasNode(affected);
16577
+ const affectedIsCode = affectedInGraph && ((0, import_types58.parseSymbolId)(affected) !== null || (0, import_types58.parseFileId)(affected) !== null);
16578
+ if (affectedIsCode) return { node: affected, ...location ? { location } : {} };
16579
+ if (!location) return null;
16580
+ if (affectedInGraph) return { node: affected, location };
16581
+ const svc = (0, import_types58.serviceId)(ev.service);
16582
+ if (graph.hasNode(svc)) return { node: svc, location };
16583
+ return null;
16584
+ }
16585
+ var SYMBOL_MISMATCH_CONFIDENCE = 0.6;
16586
+ function detectSymbolMismatches(graph, incidents) {
16587
+ const groups = /* @__PURE__ */ new Map();
16588
+ for (const ev of incidents) {
16589
+ const classified = classifySymbolMismatch(ev.errorMessage);
16590
+ if (!classified) continue;
16591
+ const locus = symbolLocus(graph, ev);
16592
+ if (!locus) continue;
16593
+ const key = `${locus.node}|${classified.kind}|${classified.symbol ?? ""}`;
16594
+ const existing = groups.get(key);
16595
+ if (!existing) {
16596
+ groups.set(key, {
16597
+ node: locus.node,
16598
+ kind: classified.kind,
16599
+ ...classified.symbol ? { symbol: classified.symbol } : {},
16600
+ ...locus.location ? { location: locus.location } : {},
16601
+ latest: ev,
16602
+ count: 1
16603
+ });
16604
+ } else {
16605
+ existing.count += 1;
16606
+ if (ev.timestamp.localeCompare(existing.latest.timestamp) > 0) {
16607
+ existing.latest = ev;
16608
+ if (locus.location) existing.location = locus.location;
16609
+ }
16610
+ }
16611
+ }
16612
+ const out = [];
16613
+ for (const g of groups.values()) {
16614
+ const member = g.symbol ? `\`${g.symbol}\`` : "a member";
16615
+ const where = g.location ? ` at ${g.location}` : "";
16616
+ const times = g.count > 1 ? ` (${g.count} recorded incidents)` : " (1 recorded incident)";
16617
+ out.push({
16618
+ type: "observed-symbol-mismatch",
16619
+ source: g.node,
16620
+ target: g.node,
16621
+ mismatchKind: g.kind,
16622
+ ...g.symbol ? { symbol: g.symbol } : {},
16623
+ ...g.location ? { location: g.location } : {},
16624
+ provenance: import_types58.Provenance.INFERRED,
16625
+ incidentId: g.latest.id,
16626
+ errorMessage: g.latest.errorMessage,
16627
+ incidentCount: g.count,
16628
+ confidence: SYMBOL_MISMATCH_CONFIDENCE,
16629
+ 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.`,
16630
+ 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."
16631
+ });
16632
+ }
16633
+ return out;
16634
+ }
16458
16635
  function involvesNode(d, nodeId) {
16459
16636
  return d.source === nodeId || d.target === nodeId;
16460
16637
  }
@@ -16540,6 +16717,9 @@ function computeDivergences(graph, opts = {}) {
16540
16717
  for (const d of detectColumnDrift(n)) all.push(d);
16541
16718
  }
16542
16719
  });
16720
+ if (opts.incidents && opts.incidents.length > 0) {
16721
+ for (const d of detectSymbolMismatches(graph, opts.incidents)) all.push(d);
16722
+ }
16543
16723
  const reconciled = suppressHostMismatchHalves(all);
16544
16724
  const dampened = dampenDeadCodeProbes(graph, buckets2, reconciled);
16545
16725
  let filtered = dampened;
@@ -16560,7 +16740,12 @@ function computeDivergences(graph, opts = {}) {
16560
16740
  "missing-observed": 1,
16561
16741
  "version-mismatch": 2,
16562
16742
  "host-mismatch": 3,
16563
- "compat-violation": 4
16743
+ "compat-violation": 4,
16744
+ // Symbol/field-grain (ADR-215) rides the confidence sort like every other
16745
+ // type; this only breaks a confidence tie, and it orders last so a same-
16746
+ // confidence edge finding leads. In practice it carries the INFERRED grade
16747
+ // (0.6), so it sits below the high-confidence edge divergences already.
16748
+ "observed-symbol-mismatch": 5
16564
16749
  };
16565
16750
  filtered.sort((a, b) => {
16566
16751
  if (b.confidence !== a.confidence) return b.confidence - a.confidence;
@@ -16571,7 +16756,10 @@ function computeDivergences(graph, opts = {}) {
16571
16756
  if (a.target !== b.target) return a.target.localeCompare(b.target);
16572
16757
  const ac = "column" in a && a.column ? a.column : "";
16573
16758
  const bc = "column" in b && b.column ? b.column : "";
16574
- return ac.localeCompare(bc);
16759
+ if (ac !== bc) return ac.localeCompare(bc);
16760
+ const asym = "symbol" in a && a.symbol ? a.symbol : "";
16761
+ const bsym = "symbol" in b && b.symbol ? b.symbol : "";
16762
+ return asym.localeCompare(bsym);
16575
16763
  });
16576
16764
  return import_types58.DivergenceResultSchema.parse({
16577
16765
  divergences: filtered,
@@ -16998,10 +17186,15 @@ function divergenceLine(d) {
16998
17186
  if ((d.type === "missing-observed" || d.type === "missing-extracted") && d.column) {
16999
17187
  return `[${d.type}] ${d.table ?? d.source} column ${d.column} \u2014 ${d.reason}`;
17000
17188
  }
17189
+ if (d.type === "observed-symbol-mismatch") {
17190
+ const at = d.location ? ` at ${d.location}` : "";
17191
+ const member = d.symbol ? ` ${d.symbol}` : "";
17192
+ return `[${d.type}] ${d.source}${member}${at} \u2014 ${d.reason}`;
17193
+ }
17001
17194
  return `[${d.type}] ${d.source} \u2192 ${d.target} \u2014 ${d.reason}`;
17002
17195
  }
17003
- function buildDivergenceSection(graph, node) {
17004
- const result = computeDivergences(graph, { node });
17196
+ function buildDivergenceSection(graph, node, incidents) {
17197
+ const result = computeDivergences(graph, { node, ...incidents ? { incidents } : {} });
17005
17198
  if (result.totalAffected === 0) return null;
17006
17199
  const facts = result.divergences.slice(0, MAX_FACTS_PER_SECTION).map((d) => ({
17007
17200
  text: divergenceLine(d),
@@ -17014,8 +17207,8 @@ function buildDivergenceSection(graph, node) {
17014
17207
  facts
17015
17208
  };
17016
17209
  }
17017
- function buildGlobalDivergenceSection(graph) {
17018
- const result = computeDivergences(graph);
17210
+ function buildGlobalDivergenceSection(graph, incidents) {
17211
+ const result = computeDivergences(graph, incidents ? { incidents } : {});
17019
17212
  if (result.totalAffected === 0) {
17020
17213
  return {
17021
17214
  heading: "Divergences (EXTRACTED vs OBSERVED)",
@@ -17113,7 +17306,7 @@ function buildOverviewSections(graph, incidents) {
17113
17306
  }))
17114
17307
  });
17115
17308
  }
17116
- const div = computeDivergences(graph);
17309
+ const div = computeDivergences(graph, incidents ? { incidents } : {});
17117
17310
  sections.push({
17118
17311
  heading: "Divergences",
17119
17312
  facts: [
@@ -17127,7 +17320,7 @@ function buildOverviewSections(graph, incidents) {
17127
17320
  function buildGlobalSections(intent, graph, incidents) {
17128
17321
  switch (intent) {
17129
17322
  case "divergence":
17130
- return [buildGlobalDivergenceSection(graph)];
17323
+ return [buildGlobalDivergenceSection(graph, incidents)];
17131
17324
  case "incidents":
17132
17325
  return [buildGlobalIncidentsSection(incidents)];
17133
17326
  case "overview":
@@ -17158,7 +17351,7 @@ function buildSection(kind, graph, node, incidents, now) {
17158
17351
  case "incidents":
17159
17352
  return buildIncidentsSection(node, incidents);
17160
17353
  case "divergence":
17161
- return buildDivergenceSection(graph, node);
17354
+ return buildDivergenceSection(graph, node, incidents);
17162
17355
  }
17163
17356
  }
17164
17357
  function summarizeGlobal(intent, sections) {
@@ -21033,6 +21226,8 @@ async function startConnectorPolling(input) {
21033
21226
  }
21034
21227
 
21035
21228
  // src/api.ts
21229
+ var INCIDENT_LIST_DEFAULT_LIMIT = 50;
21230
+ var INCIDENT_LIST_MAX_LIMIT = 200;
21036
21231
  function serializeGraph(graph) {
21037
21232
  const nodes = [];
21038
21233
  graph.forEachNode((_id, attrs) => {
@@ -21212,10 +21407,13 @@ function registerRoutes(scope, ctx) {
21212
21407
  }
21213
21408
  minConfidence = n;
21214
21409
  }
21410
+ const epath = errorsPathFor(proj);
21411
+ const incidents = epath ? await readErrorEvents(epath) : [];
21215
21412
  return computeDivergences(proj.graph, {
21216
21413
  ...typeFilter ? { type: typeFilter } : {},
21217
21414
  ...minConfidence !== void 0 ? { minConfidence } : {},
21218
- ...req2.query.node ? { node: req2.query.node } : {}
21415
+ ...req2.query.node ? { node: req2.query.node } : {},
21416
+ incidents
21219
21417
  });
21220
21418
  });
21221
21419
  scope.get("/incidents", async (req2, reply) => {
@@ -21225,10 +21423,11 @@ function registerRoutes(scope, ctx) {
21225
21423
  if (!epath) return { count: 0, total: 0, events: [] };
21226
21424
  const events = await readErrorEvents(epath);
21227
21425
  const total = events.length;
21228
- const limit = req2.query.limit ? Number(req2.query.limit) : 50;
21229
- const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 200) : 50;
21426
+ const limit = req2.query.limit ? Number(req2.query.limit) : INCIDENT_LIST_DEFAULT_LIMIT;
21427
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, INCIDENT_LIST_MAX_LIMIT) : INCIDENT_LIST_DEFAULT_LIMIT;
21230
21428
  const sliced = events.slice(0, safeLimit);
21231
- return { count: sliced.length, total, events: sliced };
21429
+ const omitted = total - sliced.length;
21430
+ return { count: sliced.length, total, events: sliced, ...omitted > 0 ? { omitted } : {} };
21232
21431
  });
21233
21432
  scope.get("/stale-events", async (req2, reply) => {
21234
21433
  const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
@@ -21337,16 +21536,20 @@ function registerRoutes(scope, ctx) {
21337
21536
  const filtered = events.filter(
21338
21537
  (e) => e.affectedNode === nodeId || e.service === nodeId.replace(/^service:/, "")
21339
21538
  );
21340
- return { count: filtered.length, total: filtered.length, events: filtered };
21539
+ const total = filtered.length;
21540
+ const limit = req2.query.limit ? Number(req2.query.limit) : INCIDENT_LIST_DEFAULT_LIMIT;
21541
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, INCIDENT_LIST_MAX_LIMIT) : INCIDENT_LIST_DEFAULT_LIMIT;
21542
+ const recent = [...filtered].sort((a, b) => (b.timestamp ?? "").localeCompare(a.timestamp ?? "")).slice(0, safeLimit);
21543
+ const omitted = total - recent.length;
21544
+ return {
21545
+ count: recent.length,
21546
+ total,
21547
+ events: recent,
21548
+ ...omitted > 0 ? { omitted } : {}
21549
+ };
21341
21550
  };
21342
- scope.get(
21343
- "/incidents/:nodeId",
21344
- incidentHistoryHandler
21345
- );
21346
- scope.get(
21347
- "/graph/incident-history/:nodeId",
21348
- incidentHistoryHandler
21349
- );
21551
+ scope.get("/incidents/:nodeId", incidentHistoryHandler);
21552
+ scope.get("/graph/incident-history/:nodeId", incidentHistoryHandler);
21350
21553
  scope.get("/graph/root-cause/:nodeId", async (req2, reply) => {
21351
21554
  const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
21352
21555
  if (!proj) return;