@neat.is/core 0.3.2 → 0.3.4

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.
@@ -427,19 +427,19 @@ function confidenceFromMix(edges, now = Date.now()) {
427
427
  function longestIncomingWalk(graph, start, maxDepth) {
428
428
  let best = { path: [start], edges: [] };
429
429
  const visited = /* @__PURE__ */ new Set([start]);
430
- function step(node, path31, edges) {
431
- if (path31.length > best.path.length) {
432
- best = { path: [...path31], edges: [...edges] };
430
+ function step(node, path34, edges) {
431
+ if (path34.length > best.path.length) {
432
+ best = { path: [...path34], edges: [...edges] };
433
433
  }
434
- if (path31.length - 1 >= maxDepth) return;
434
+ if (path34.length - 1 >= maxDepth) return;
435
435
  const incoming = bestEdgeBySource(graph, graph.inboundEdges(node));
436
436
  for (const [srcId, edge] of incoming) {
437
437
  if (visited.has(srcId)) continue;
438
438
  visited.add(srcId);
439
- path31.push(srcId);
439
+ path34.push(srcId);
440
440
  edges.push(edge);
441
- step(srcId, path31, edges);
442
- path31.pop();
441
+ step(srcId, path34, edges);
442
+ path34.pop();
443
443
  edges.pop();
444
444
  visited.delete(srcId);
445
445
  }
@@ -1018,6 +1018,7 @@ import {
1018
1018
  EdgeType as EdgeType2,
1019
1019
  NodeType as NodeType3,
1020
1020
  Provenance as Provenance3,
1021
+ confidenceForObservedSignal,
1021
1022
  databaseId,
1022
1023
  extractedEdgeId,
1023
1024
  frontierEdgeId,
@@ -1214,35 +1215,37 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false) {
1214
1215
  const existing = graph.getEdgeAttributes(id);
1215
1216
  const newSpanCount = (existing.signal?.spanCount ?? existing.callCount ?? 0) + 1;
1216
1217
  const newErrorCount = (existing.signal?.errorCount ?? 0) + (isError ? 1 : 0);
1218
+ const newSignal = {
1219
+ spanCount: newSpanCount,
1220
+ errorCount: newErrorCount,
1221
+ lastObservedAgeMs: 0
1222
+ };
1217
1223
  const updated = {
1218
1224
  ...existing,
1219
1225
  provenance: Provenance3.OBSERVED,
1220
1226
  lastObserved: ts,
1221
1227
  callCount: newSpanCount,
1222
- signal: {
1223
- spanCount: newSpanCount,
1224
- errorCount: newErrorCount,
1225
- lastObservedAgeMs: 0
1226
- },
1227
- confidence: 1
1228
+ signal: newSignal,
1229
+ confidence: confidenceForObservedSignal(newSignal)
1228
1230
  };
1229
1231
  graph.replaceEdgeAttributes(id, updated);
1230
1232
  return { edge: updated, created: false };
1231
1233
  }
1234
+ const signal = {
1235
+ spanCount: 1,
1236
+ errorCount: isError ? 1 : 0,
1237
+ lastObservedAgeMs: 0
1238
+ };
1232
1239
  const edge = {
1233
1240
  id,
1234
1241
  source,
1235
1242
  target,
1236
1243
  type,
1237
1244
  provenance: Provenance3.OBSERVED,
1238
- confidence: 1,
1245
+ confidence: confidenceForObservedSignal(signal),
1239
1246
  lastObserved: ts,
1240
1247
  callCount: 1,
1241
- signal: {
1242
- spanCount: 1,
1243
- errorCount: isError ? 1 : 0,
1244
- lastObservedAgeMs: 0
1245
- }
1248
+ signal
1246
1249
  };
1247
1250
  graph.addEdgeWithKey(id, source, target, edge);
1248
1251
  return { edge, created: true };
@@ -1560,16 +1563,71 @@ async function readErrorEvents(errorsPath) {
1560
1563
  }
1561
1564
  }
1562
1565
 
1566
+ // src/extract/errors.ts
1567
+ import { promises as fs4 } from "fs";
1568
+ import path4 from "path";
1569
+ var sink = [];
1570
+ function recordExtractionError(producer, file, err) {
1571
+ const e = err instanceof Error ? err : new Error(String(err));
1572
+ sink.push({
1573
+ producer,
1574
+ file,
1575
+ error: e.message,
1576
+ stack: e.stack,
1577
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1578
+ source: "extract"
1579
+ });
1580
+ console.warn(`[neat] ${producer} skipped ${file}: ${e.message}`);
1581
+ }
1582
+ function drainExtractionErrors() {
1583
+ return sink.splice(0, sink.length);
1584
+ }
1585
+ async function writeExtractionErrors(errors, errorsPath) {
1586
+ if (errors.length === 0) return;
1587
+ await fs4.mkdir(path4.dirname(errorsPath), { recursive: true });
1588
+ const lines = errors.map((e) => JSON.stringify(e)).join("\n") + "\n";
1589
+ await fs4.appendFile(errorsPath, lines, "utf8");
1590
+ }
1591
+ function isStrictExtractionEnabled() {
1592
+ const raw = process.env.NEAT_STRICT_EXTRACTION;
1593
+ return raw === "1" || raw === "true";
1594
+ }
1595
+ function formatExtractionBanner(count) {
1596
+ if (count === 1) return `[neat] 1 file skipped due to parse errors`;
1597
+ return `[neat] ${count} files skipped due to parse errors`;
1598
+ }
1599
+ var droppedSink = [];
1600
+ function noteExtractedDropped(edge) {
1601
+ droppedSink.push(edge);
1602
+ }
1603
+ function drainDroppedExtracted() {
1604
+ return droppedSink.splice(0, droppedSink.length);
1605
+ }
1606
+ function isRejectedLogEnabled() {
1607
+ const raw = process.env.NEAT_EXTRACTED_REJECTED_LOG;
1608
+ return raw === "1" || raw === "true";
1609
+ }
1610
+ async function writeRejectedExtracted(drops, rejectedPath) {
1611
+ if (drops.length === 0) return;
1612
+ await fs4.mkdir(path4.dirname(rejectedPath), { recursive: true });
1613
+ const lines = drops.map((d) => JSON.stringify({ ...d, ts: (/* @__PURE__ */ new Date()).toISOString() })).join("\n") + "\n";
1614
+ await fs4.appendFile(rejectedPath, lines, "utf8");
1615
+ }
1616
+ function formatPrecisionFloorBanner(count) {
1617
+ if (count === 1) return `[neat] 1 extracted edge dropped below precision floor`;
1618
+ return `[neat] ${count} extracted edges dropped below precision floor`;
1619
+ }
1620
+
1563
1621
  // src/extract/services.ts
1564
- import { promises as fs7 } from "fs";
1565
- import path7 from "path";
1622
+ import { promises as fs8 } from "fs";
1623
+ import path8 from "path";
1566
1624
  import ignore from "ignore";
1567
1625
  import { minimatch as minimatch2 } from "minimatch";
1568
1626
  import { NodeType as NodeType4, serviceId as serviceId2 } from "@neat.is/types";
1569
1627
 
1570
1628
  // src/extract/shared.ts
1571
- import { promises as fs4 } from "fs";
1572
- import path4 from "path";
1629
+ import { promises as fs5 } from "fs";
1630
+ import path5 from "path";
1573
1631
  import { parse as parseYaml } from "yaml";
1574
1632
  import { extractedEdgeId as extractedEdgeId2 } from "@neat.is/types";
1575
1633
  var SERVICE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".py"]);
@@ -1583,26 +1641,124 @@ var IGNORED_DIRS = /* @__PURE__ */ new Set([
1583
1641
  ".next"
1584
1642
  ]);
1585
1643
  function isConfigFile(name) {
1586
- const ext = path4.extname(name);
1644
+ const ext = path5.extname(name);
1587
1645
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
1588
- if (name === ".env" || name.startsWith(".env.")) return { match: true, fileType: "env" };
1646
+ if (name === ".env" || name.startsWith(".env.")) {
1647
+ if (isEnvTemplateFile(name)) return { match: false, fileType: "" };
1648
+ return { match: true, fileType: "env" };
1649
+ }
1589
1650
  return { match: false, fileType: "" };
1590
1651
  }
1652
+ function isTestPath(filePath) {
1653
+ const normalised = filePath.replace(/\\/g, "/");
1654
+ const segments = normalised.split("/");
1655
+ for (const seg of segments) {
1656
+ if (seg === "__tests__" || seg === "__fixtures__" || seg === "integration-tests") {
1657
+ return true;
1658
+ }
1659
+ }
1660
+ const base = segments[segments.length - 1] ?? "";
1661
+ return /\.(spec|test)\.(?:tsx?|jsx?|mjs|cjs|py)$/i.test(base);
1662
+ }
1663
+ function isEnvTemplateFile(name) {
1664
+ if (name === ".env.template" || name === ".env.example" || name === ".env.sample") {
1665
+ return true;
1666
+ }
1667
+ return /^\.env\.[^.]+\.(?:template|example|sample)$/i.test(name);
1668
+ }
1669
+ function maskCommentsInSource(src) {
1670
+ const len = src.length;
1671
+ const out = new Array(len);
1672
+ let i = 0;
1673
+ let inString = 0;
1674
+ let escaped = false;
1675
+ while (i < len) {
1676
+ const c = src[i];
1677
+ if (inString !== 0) {
1678
+ out[i] = c;
1679
+ if (escaped) {
1680
+ escaped = false;
1681
+ } else if (c === "\\") {
1682
+ escaped = true;
1683
+ } else if (c === inString) {
1684
+ inString = 0;
1685
+ }
1686
+ i++;
1687
+ continue;
1688
+ }
1689
+ if (c === "/" && i + 1 < len) {
1690
+ const next = src[i + 1];
1691
+ if (next === "/") {
1692
+ out[i] = " ";
1693
+ out[i + 1] = " ";
1694
+ let j = i + 2;
1695
+ while (j < len && src[j] !== "\n") {
1696
+ out[j] = " ";
1697
+ j++;
1698
+ }
1699
+ i = j;
1700
+ continue;
1701
+ }
1702
+ if (next === "*") {
1703
+ out[i] = " ";
1704
+ out[i + 1] = " ";
1705
+ let j = i + 2;
1706
+ while (j < len) {
1707
+ if (src[j] === "\n") {
1708
+ out[j] = "\n";
1709
+ j++;
1710
+ continue;
1711
+ }
1712
+ if (src[j] === "*" && j + 1 < len && src[j + 1] === "/") {
1713
+ out[j] = " ";
1714
+ out[j + 1] = " ";
1715
+ j += 2;
1716
+ break;
1717
+ }
1718
+ out[j] = " ";
1719
+ j++;
1720
+ }
1721
+ i = j;
1722
+ continue;
1723
+ }
1724
+ }
1725
+ out[i] = c;
1726
+ if (c === "'" || c === '"' || c === "`") inString = c;
1727
+ i++;
1728
+ }
1729
+ return out.join("");
1730
+ }
1731
+ var URL_LIKE = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i;
1732
+ function urlMatchesHost(urlString, host) {
1733
+ if (typeof urlString !== "string" || urlString.length === 0) return false;
1734
+ if (!URL_LIKE.test(urlString)) return false;
1735
+ const [wantedHost, wantedPort] = host.split(":");
1736
+ let parsed;
1737
+ try {
1738
+ const candidate = urlString.startsWith("//") ? `http:${urlString}` : urlString;
1739
+ parsed = new URL(candidate);
1740
+ } catch {
1741
+ return false;
1742
+ }
1743
+ if (parsed.hostname.toLowerCase() !== (wantedHost ?? "").toLowerCase()) return false;
1744
+ if (wantedPort && parsed.port !== wantedPort) return false;
1745
+ return true;
1746
+ }
1591
1747
  function cleanVersion(raw) {
1592
1748
  if (!raw) return void 0;
1593
1749
  return raw.replace(/^[\^~><=v\s]+/, "").trim() || void 0;
1594
1750
  }
1595
1751
  async function readJson(filePath) {
1596
- const raw = await fs4.readFile(filePath, "utf8");
1752
+ const raw = await fs5.readFile(filePath, "utf8");
1597
1753
  return JSON.parse(raw);
1598
1754
  }
1599
1755
  async function readYaml(filePath) {
1600
- const raw = await fs4.readFile(filePath, "utf8");
1756
+ const raw = await fs5.readFile(filePath, "utf8");
1601
1757
  return parseYaml(raw);
1602
1758
  }
1603
1759
  async function exists(p) {
1604
1760
  try {
1605
- await fs4.access(p);
1761
+ await fs5.access(p);
1606
1762
  return true;
1607
1763
  } catch {
1608
1764
  return false;
@@ -1610,8 +1766,8 @@ async function exists(p) {
1610
1766
  }
1611
1767
 
1612
1768
  // src/extract/python.ts
1613
- import { promises as fs5 } from "fs";
1614
- import path5 from "path";
1769
+ import { promises as fs6 } from "fs";
1770
+ import path6 from "path";
1615
1771
  import { parse as parseToml } from "smol-toml";
1616
1772
  var REQUIREMENT_LINE = /^\s*([A-Za-z0-9_.-]+)(?:\[[^\]]*\])?\s*(?:(==)\s*([A-Za-z0-9_.+-]+))?/;
1617
1773
  function parseRequirementsTxt(content) {
@@ -1644,25 +1800,25 @@ function depsFromPyProject(pyproject) {
1644
1800
  return out;
1645
1801
  }
1646
1802
  async function discoverPythonService(serviceDir) {
1647
- const pyprojectPath = path5.join(serviceDir, "pyproject.toml");
1648
- const requirementsPath = path5.join(serviceDir, "requirements.txt");
1649
- const setupPath = path5.join(serviceDir, "setup.py");
1803
+ const pyprojectPath = path6.join(serviceDir, "pyproject.toml");
1804
+ const requirementsPath = path6.join(serviceDir, "requirements.txt");
1805
+ const setupPath = path6.join(serviceDir, "setup.py");
1650
1806
  const hasPyproject = await exists(pyprojectPath);
1651
1807
  const hasRequirements = await exists(requirementsPath);
1652
1808
  const hasSetup = await exists(setupPath);
1653
1809
  if (!hasPyproject && !hasRequirements && !hasSetup) return null;
1654
- let name = path5.basename(serviceDir);
1810
+ let name = path6.basename(serviceDir);
1655
1811
  let version;
1656
1812
  const dependencies = {};
1657
1813
  if (hasPyproject) {
1658
- const raw = await fs5.readFile(pyprojectPath, "utf8");
1814
+ const raw = await fs6.readFile(pyprojectPath, "utf8");
1659
1815
  const pyproject = parseToml(raw);
1660
1816
  name = pyproject.project?.name ?? pyproject.tool?.poetry?.name ?? name;
1661
1817
  version = pyproject.project?.version ?? pyproject.tool?.poetry?.version ?? void 0;
1662
1818
  Object.assign(dependencies, depsFromPyProject(pyproject));
1663
1819
  }
1664
1820
  if (hasRequirements) {
1665
- const raw = await fs5.readFile(requirementsPath, "utf8");
1821
+ const raw = await fs6.readFile(requirementsPath, "utf8");
1666
1822
  Object.assign(dependencies, parseRequirementsTxt(raw));
1667
1823
  }
1668
1824
  return { name, version, dependencies };
@@ -1676,17 +1832,17 @@ function pythonToPackage(service) {
1676
1832
  }
1677
1833
 
1678
1834
  // src/extract/owners.ts
1679
- import { promises as fs6 } from "fs";
1680
- import path6 from "path";
1835
+ import { promises as fs7 } from "fs";
1836
+ import path7 from "path";
1681
1837
  import { minimatch } from "minimatch";
1682
1838
  async function loadCodeowners(scanPath) {
1683
1839
  const candidates = [
1684
- path6.join(scanPath, "CODEOWNERS"),
1685
- path6.join(scanPath, ".github", "CODEOWNERS")
1840
+ path7.join(scanPath, "CODEOWNERS"),
1841
+ path7.join(scanPath, ".github", "CODEOWNERS")
1686
1842
  ];
1687
1843
  for (const file of candidates) {
1688
1844
  if (await exists(file)) {
1689
- const raw = await fs6.readFile(file, "utf8");
1845
+ const raw = await fs7.readFile(file, "utf8");
1690
1846
  return parseCodeowners(raw);
1691
1847
  }
1692
1848
  }
@@ -1704,7 +1860,7 @@ function parseCodeowners(raw) {
1704
1860
  return { rules };
1705
1861
  }
1706
1862
  function matchOwner(file, repoPath) {
1707
- const normalized = repoPath.split(path6.sep).join("/");
1863
+ const normalized = repoPath.split(path7.sep).join("/");
1708
1864
  for (const rule of file.rules) {
1709
1865
  if (matchesPattern(rule.pattern, normalized)) return rule.owners;
1710
1866
  }
@@ -1720,7 +1876,7 @@ function matchesPattern(rawPattern, repoPath) {
1720
1876
  return false;
1721
1877
  }
1722
1878
  async function readPackageJsonAuthor(serviceDir) {
1723
- const pkgPath = path6.join(serviceDir, "package.json");
1879
+ const pkgPath = path7.join(serviceDir, "package.json");
1724
1880
  if (!await exists(pkgPath)) return null;
1725
1881
  try {
1726
1882
  const pkg = await readJson(pkgPath);
@@ -1757,21 +1913,21 @@ function workspaceGlobs(pkg) {
1757
1913
  return null;
1758
1914
  }
1759
1915
  async function loadGitignore(scanPath) {
1760
- const gitignorePath = path7.join(scanPath, ".gitignore");
1916
+ const gitignorePath = path8.join(scanPath, ".gitignore");
1761
1917
  if (!await exists(gitignorePath)) return null;
1762
- const raw = await fs7.readFile(gitignorePath, "utf8");
1918
+ const raw = await fs8.readFile(gitignorePath, "utf8");
1763
1919
  return ignore().add(raw);
1764
1920
  }
1765
1921
  async function walkDirs(start, scanPath, options, visit) {
1766
1922
  async function recurse(current, depth) {
1767
1923
  if (depth > options.maxDepth) return;
1768
- const entries = await fs7.readdir(current, { withFileTypes: true }).catch(() => []);
1924
+ const entries = await fs8.readdir(current, { withFileTypes: true }).catch(() => []);
1769
1925
  for (const entry of entries) {
1770
1926
  if (!entry.isDirectory()) continue;
1771
1927
  if (IGNORED_DIRS.has(entry.name)) continue;
1772
- const child = path7.join(current, entry.name);
1928
+ const child = path8.join(current, entry.name);
1773
1929
  if (options.ig) {
1774
- const rel = path7.relative(scanPath, child).split(path7.sep).join("/");
1930
+ const rel = path8.relative(scanPath, child).split(path8.sep).join("/");
1775
1931
  if (rel && options.ig.ignores(rel + "/")) continue;
1776
1932
  }
1777
1933
  await visit(child);
@@ -1786,8 +1942,8 @@ async function expandWorkspaceGlobs(scanPath, globs) {
1786
1942
  for (const raw of globs) {
1787
1943
  const pattern = raw.replace(/^\.\//, "");
1788
1944
  if (!pattern.includes("*")) {
1789
- const candidate = path7.join(scanPath, pattern);
1790
- if (await exists(path7.join(candidate, "package.json"))) found.add(candidate);
1945
+ const candidate = path8.join(scanPath, pattern);
1946
+ if (await exists(path8.join(candidate, "package.json"))) found.add(candidate);
1791
1947
  continue;
1792
1948
  }
1793
1949
  const segments = pattern.split("/");
@@ -1796,13 +1952,13 @@ async function expandWorkspaceGlobs(scanPath, globs) {
1796
1952
  if (seg.includes("*")) break;
1797
1953
  staticSegments.push(seg);
1798
1954
  }
1799
- const start = path7.join(scanPath, ...staticSegments);
1955
+ const start = path8.join(scanPath, ...staticSegments);
1800
1956
  if (!await exists(start)) continue;
1801
1957
  const hasDoubleStar = pattern.includes("**");
1802
1958
  const walkDepth = hasDoubleStar ? scanDepth : Math.max(0, segments.length - staticSegments.length - 1);
1803
1959
  await walkDirs(start, scanPath, { maxDepth: walkDepth, ig: null }, async (dir) => {
1804
- const rel = path7.relative(scanPath, dir).split(path7.sep).join("/");
1805
- if (minimatch2(rel, pattern) && await exists(path7.join(dir, "package.json"))) {
1960
+ const rel = path8.relative(scanPath, dir).split(path8.sep).join("/");
1961
+ if (minimatch2(rel, pattern) && await exists(path8.join(dir, "package.json"))) {
1806
1962
  found.add(dir);
1807
1963
  }
1808
1964
  });
@@ -1810,15 +1966,13 @@ async function expandWorkspaceGlobs(scanPath, globs) {
1810
1966
  return [...found];
1811
1967
  }
1812
1968
  async function discoverNodeService(scanPath, dir) {
1813
- const pkgPath = path7.join(dir, "package.json");
1969
+ const pkgPath = path8.join(dir, "package.json");
1814
1970
  if (!await exists(pkgPath)) return null;
1815
1971
  let pkg;
1816
1972
  try {
1817
1973
  pkg = await readJson(pkgPath);
1818
1974
  } catch (err) {
1819
- console.warn(
1820
- `[neat] services skipped ${path7.relative(scanPath, pkgPath)}: ${err.message}`
1821
- );
1975
+ recordExtractionError("services", path8.relative(scanPath, pkgPath), err);
1822
1976
  return null;
1823
1977
  }
1824
1978
  if (!pkg.name) return null;
@@ -1829,7 +1983,7 @@ async function discoverNodeService(scanPath, dir) {
1829
1983
  language: "javascript",
1830
1984
  version: pkg.version,
1831
1985
  dependencies: pkg.dependencies ?? {},
1832
- repoPath: path7.relative(scanPath, dir),
1986
+ repoPath: path8.relative(scanPath, dir),
1833
1987
  ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
1834
1988
  };
1835
1989
  return { pkg, dir, node };
@@ -1845,19 +1999,21 @@ async function discoverPyService(scanPath, dir) {
1845
1999
  language: "python",
1846
2000
  version: py.version,
1847
2001
  dependencies: py.dependencies,
1848
- repoPath: path7.relative(scanPath, dir)
2002
+ repoPath: path8.relative(scanPath, dir)
1849
2003
  };
1850
2004
  return { pkg, dir, node };
1851
2005
  }
1852
2006
  async function discoverServices(scanPath) {
1853
- const rootPkgPath = path7.join(scanPath, "package.json");
2007
+ const rootPkgPath = path8.join(scanPath, "package.json");
1854
2008
  let rootPkg = null;
1855
2009
  if (await exists(rootPkgPath)) {
1856
2010
  try {
1857
2011
  rootPkg = await readJson(rootPkgPath);
1858
2012
  } catch (err) {
1859
- console.warn(
1860
- `[neat] services workspaces skipped ${path7.relative(scanPath, rootPkgPath)}: ${err.message}`
2013
+ recordExtractionError(
2014
+ "services workspaces",
2015
+ path8.relative(scanPath, rootPkgPath),
2016
+ err
1861
2017
  );
1862
2018
  }
1863
2019
  }
@@ -1873,9 +2029,9 @@ async function discoverServices(scanPath) {
1873
2029
  scanPath,
1874
2030
  { maxDepth: parseScanDepth(), ig },
1875
2031
  async (dir) => {
1876
- if (await exists(path7.join(dir, "package.json"))) {
2032
+ if (await exists(path8.join(dir, "package.json"))) {
1877
2033
  candidateDirs.push(dir);
1878
- } else if (await exists(path7.join(dir, "pyproject.toml")) || await exists(path7.join(dir, "requirements.txt")) || await exists(path7.join(dir, "setup.py"))) {
2034
+ } else if (await exists(path8.join(dir, "pyproject.toml")) || await exists(path8.join(dir, "requirements.txt")) || await exists(path8.join(dir, "setup.py"))) {
1879
2035
  candidateDirs.push(dir);
1880
2036
  }
1881
2037
  }
@@ -1889,8 +2045,8 @@ async function discoverServices(scanPath) {
1889
2045
  if (!service) continue;
1890
2046
  const existingDir = seen.get(service.node.name);
1891
2047
  if (existingDir !== void 0) {
1892
- const a = path7.relative(scanPath, existingDir) || ".";
1893
- const b = path7.relative(scanPath, dir) || ".";
2048
+ const a = path8.relative(scanPath, existingDir) || ".";
2049
+ const b = path8.relative(scanPath, dir) || ".";
1894
2050
  console.warn(
1895
2051
  `[neat] duplicate package name "${service.node.name}" \u2014 keeping ${a}, ignoring ${b}`
1896
2052
  );
@@ -1926,8 +2082,8 @@ function addServiceNodes(graph, services) {
1926
2082
  }
1927
2083
 
1928
2084
  // src/extract/aliases.ts
1929
- import path8 from "path";
1930
- import { promises as fs8 } from "fs";
2085
+ import path9 from "path";
2086
+ import { promises as fs9 } from "fs";
1931
2087
  import { parseAllDocuments } from "yaml";
1932
2088
  import { NodeType as NodeType5 } from "@neat.is/types";
1933
2089
  var K8S_KINDS_WITH_HOSTNAMES = /* @__PURE__ */ new Set([
@@ -1954,14 +2110,14 @@ function indexServicesByName(services) {
1954
2110
  const map = /* @__PURE__ */ new Map();
1955
2111
  for (const s of services) {
1956
2112
  map.set(s.node.name, s.node.id);
1957
- map.set(path8.basename(s.dir), s.node.id);
2113
+ map.set(path9.basename(s.dir), s.node.id);
1958
2114
  }
1959
2115
  return map;
1960
2116
  }
1961
2117
  async function collectComposeAliases(graph, scanPath, serviceIndex) {
1962
2118
  let composePath = null;
1963
2119
  for (const name of ["docker-compose.yml", "docker-compose.yaml"]) {
1964
- const abs = path8.join(scanPath, name);
2120
+ const abs = path9.join(scanPath, name);
1965
2121
  if (await exists(abs)) {
1966
2122
  composePath = abs;
1967
2123
  break;
@@ -1972,8 +2128,10 @@ async function collectComposeAliases(graph, scanPath, serviceIndex) {
1972
2128
  try {
1973
2129
  compose = await readYaml(composePath);
1974
2130
  } catch (err) {
1975
- console.warn(
1976
- `[neat] aliases compose skipped ${path8.relative(scanPath, composePath)}: ${err.message}`
2131
+ recordExtractionError(
2132
+ "aliases compose",
2133
+ path9.relative(scanPath, composePath),
2134
+ err
1977
2135
  );
1978
2136
  return;
1979
2137
  }
@@ -2015,15 +2173,13 @@ function parseDockerfileLabels(content) {
2015
2173
  }
2016
2174
  async function collectDockerfileAliases(graph, services) {
2017
2175
  for (const service of services) {
2018
- const dockerfilePath = path8.join(service.dir, "Dockerfile");
2176
+ const dockerfilePath = path9.join(service.dir, "Dockerfile");
2019
2177
  if (!await exists(dockerfilePath)) continue;
2020
2178
  let content;
2021
2179
  try {
2022
- content = await fs8.readFile(dockerfilePath, "utf8");
2180
+ content = await fs9.readFile(dockerfilePath, "utf8");
2023
2181
  } catch (err) {
2024
- console.warn(
2025
- `[neat] aliases dockerfile skipped ${dockerfilePath}: ${err.message}`
2026
- );
2182
+ recordExtractionError("aliases dockerfile", dockerfilePath, err);
2027
2183
  continue;
2028
2184
  }
2029
2185
  const aliases = parseDockerfileLabels(content);
@@ -2033,13 +2189,13 @@ async function collectDockerfileAliases(graph, services) {
2033
2189
  async function walkYamlFiles(start, depth = 0, max = 5) {
2034
2190
  if (depth > max) return [];
2035
2191
  const out = [];
2036
- const entries = await fs8.readdir(start, { withFileTypes: true }).catch(() => []);
2192
+ const entries = await fs9.readdir(start, { withFileTypes: true }).catch(() => []);
2037
2193
  for (const entry of entries) {
2038
2194
  if (entry.isDirectory()) {
2039
2195
  if (IGNORED_DIRS.has(entry.name)) continue;
2040
- out.push(...await walkYamlFiles(path8.join(start, entry.name), depth + 1, max));
2041
- } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(path8.extname(entry.name))) {
2042
- out.push(path8.join(start, entry.name));
2196
+ out.push(...await walkYamlFiles(path9.join(start, entry.name), depth + 1, max));
2197
+ } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(path9.extname(entry.name))) {
2198
+ out.push(path9.join(start, entry.name));
2043
2199
  }
2044
2200
  }
2045
2201
  return out;
@@ -2066,7 +2222,7 @@ function k8sServiceTarget(doc, byName) {
2066
2222
  async function collectK8sAliases(graph, scanPath, serviceIndex) {
2067
2223
  const files = await walkYamlFiles(scanPath);
2068
2224
  for (const file of files) {
2069
- const content = await fs8.readFile(file, "utf8");
2225
+ const content = await fs9.readFile(file, "utf8");
2070
2226
  let docs;
2071
2227
  try {
2072
2228
  docs = parseAllDocuments(content).map((d) => d.toJSON());
@@ -2090,13 +2246,19 @@ async function addServiceAliases(graph, scanPath, services) {
2090
2246
  }
2091
2247
 
2092
2248
  // src/extract/databases/index.ts
2093
- import path16 from "path";
2094
- import { EdgeType as EdgeType3, NodeType as NodeType6, Provenance as Provenance4, databaseId as databaseId2 } from "@neat.is/types";
2249
+ import path17 from "path";
2250
+ import {
2251
+ EdgeType as EdgeType3,
2252
+ NodeType as NodeType6,
2253
+ Provenance as Provenance4,
2254
+ databaseId as databaseId2,
2255
+ confidenceForExtracted
2256
+ } from "@neat.is/types";
2095
2257
 
2096
2258
  // src/extract/databases/db-config-yaml.ts
2097
- import path9 from "path";
2259
+ import path10 from "path";
2098
2260
  async function parse(serviceDir) {
2099
- const yamlPath = path9.join(serviceDir, "db-config.yaml");
2261
+ const yamlPath = path10.join(serviceDir, "db-config.yaml");
2100
2262
  if (!await exists(yamlPath)) return [];
2101
2263
  const raw = await readYaml(yamlPath);
2102
2264
  return [
@@ -2113,12 +2275,12 @@ async function parse(serviceDir) {
2113
2275
  var dbConfigYamlParser = { name: "db-config.yaml", parse };
2114
2276
 
2115
2277
  // src/extract/databases/dotenv.ts
2116
- import { promises as fs10 } from "fs";
2117
- import path11 from "path";
2278
+ import { promises as fs11 } from "fs";
2279
+ import path12 from "path";
2118
2280
 
2119
2281
  // src/extract/databases/shared.ts
2120
- import { promises as fs9 } from "fs";
2121
- import path10 from "path";
2282
+ import { promises as fs10 } from "fs";
2283
+ import path11 from "path";
2122
2284
  function schemeToEngine(scheme) {
2123
2285
  const s = scheme.toLowerCase().split("+")[0];
2124
2286
  switch (s) {
@@ -2157,14 +2319,14 @@ function parseConnectionString(url) {
2157
2319
  }
2158
2320
  async function readIfExists(filePath) {
2159
2321
  try {
2160
- return await fs9.readFile(filePath, "utf8");
2322
+ return await fs10.readFile(filePath, "utf8");
2161
2323
  } catch {
2162
2324
  return null;
2163
2325
  }
2164
2326
  }
2165
2327
  async function findFirst(serviceDir, candidates) {
2166
2328
  for (const rel of candidates) {
2167
- const abs = path10.join(serviceDir, rel);
2329
+ const abs = path11.join(serviceDir, rel);
2168
2330
  const content = await readIfExists(abs);
2169
2331
  if (content !== null) return abs;
2170
2332
  }
@@ -2215,15 +2377,15 @@ function parseDotenvLine(line) {
2215
2377
  return { key, value };
2216
2378
  }
2217
2379
  async function parse2(serviceDir) {
2218
- const entries = await fs10.readdir(serviceDir, { withFileTypes: true }).catch(() => []);
2380
+ const entries = await fs11.readdir(serviceDir, { withFileTypes: true }).catch(() => []);
2219
2381
  const configs = [];
2220
2382
  const seen = /* @__PURE__ */ new Set();
2221
2383
  for (const entry of entries) {
2222
2384
  if (!entry.isFile()) continue;
2223
2385
  const match = isConfigFile(entry.name);
2224
2386
  if (!match.match || match.fileType !== "env") continue;
2225
- const filePath = path11.join(serviceDir, entry.name);
2226
- const content = await fs10.readFile(filePath, "utf8");
2387
+ const filePath = path12.join(serviceDir, entry.name);
2388
+ const content = await fs11.readFile(filePath, "utf8");
2227
2389
  for (const line of content.split("\n")) {
2228
2390
  const parsed = parseDotenvLine(line);
2229
2391
  if (!parsed) continue;
@@ -2241,9 +2403,9 @@ async function parse2(serviceDir) {
2241
2403
  var dotenvParser = { name: ".env", parse: parse2 };
2242
2404
 
2243
2405
  // src/extract/databases/prisma.ts
2244
- import path12 from "path";
2406
+ import path13 from "path";
2245
2407
  async function parse3(serviceDir) {
2246
- const schemaPath = path12.join(serviceDir, "prisma", "schema.prisma");
2408
+ const schemaPath = path13.join(serviceDir, "prisma", "schema.prisma");
2247
2409
  const content = await readIfExists(schemaPath);
2248
2410
  if (!content) return [];
2249
2411
  const block = content.match(/datasource\s+\w+\s*\{([^}]*)\}/s);
@@ -2372,10 +2534,10 @@ async function parse5(serviceDir) {
2372
2534
  var knexParser = { name: "knex", parse: parse5 };
2373
2535
 
2374
2536
  // src/extract/databases/ormconfig.ts
2375
- import path13 from "path";
2537
+ import path14 from "path";
2376
2538
  async function parse6(serviceDir) {
2377
2539
  for (const candidate of ["ormconfig.json", "ormconfig.yaml", "ormconfig.yml"]) {
2378
- const abs = path13.join(serviceDir, candidate);
2540
+ const abs = path14.join(serviceDir, candidate);
2379
2541
  if (!await exists(abs)) continue;
2380
2542
  const raw = candidate.endsWith(".json") ? await readJson(abs) : await readYaml(abs);
2381
2543
  const entries = Array.isArray(raw) ? raw : [raw];
@@ -2433,9 +2595,9 @@ async function parse7(serviceDir) {
2433
2595
  var typeormParser = { name: "typeorm", parse: parse7 };
2434
2596
 
2435
2597
  // src/extract/databases/sequelize.ts
2436
- import path14 from "path";
2598
+ import path15 from "path";
2437
2599
  async function parse8(serviceDir) {
2438
- const configPath = path14.join(serviceDir, "config", "config.json");
2600
+ const configPath = path15.join(serviceDir, "config", "config.json");
2439
2601
  if (!await exists(configPath)) return [];
2440
2602
  const raw = await readJson(configPath);
2441
2603
  const out = [];
@@ -2461,7 +2623,7 @@ async function parse8(serviceDir) {
2461
2623
  var sequelizeParser = { name: "sequelize", parse: parse8 };
2462
2624
 
2463
2625
  // src/extract/databases/docker-compose.ts
2464
- import path15 from "path";
2626
+ import path16 from "path";
2465
2627
  function portFromService(svc) {
2466
2628
  for (const raw of svc.ports ?? []) {
2467
2629
  const str = String(raw);
@@ -2488,7 +2650,7 @@ function databaseFromEnv(svc) {
2488
2650
  }
2489
2651
  async function parse9(serviceDir) {
2490
2652
  for (const name of ["docker-compose.yml", "docker-compose.yaml"]) {
2491
- const abs = path15.join(serviceDir, name);
2653
+ const abs = path16.join(serviceDir, name);
2492
2654
  if (!await exists(abs)) continue;
2493
2655
  const raw = await readYaml(abs);
2494
2656
  if (!raw?.services) return [];
@@ -2666,11 +2828,13 @@ async function addDatabasesAndCompat(graph, services, scanPath) {
2666
2828
  target: dbNode.id,
2667
2829
  type: EdgeType3.CONNECTS_TO,
2668
2830
  provenance: Provenance4.EXTRACTED,
2669
- ...config.sourceFile ? {
2670
- evidence: {
2671
- file: path16.relative(scanPath, config.sourceFile).split(path16.sep).join("/")
2672
- }
2673
- } : {}
2831
+ confidence: confidenceForExtracted("structural"),
2832
+ // ADR-032 / #140 — every EXTRACTED edge carries evidence.file.
2833
+ // Ghost-edge cleanup keys retirement on this; the conditional
2834
+ // sourceFile spread that used to live here was a v0.1.x leftover.
2835
+ evidence: {
2836
+ file: path17.relative(scanPath, config.sourceFile).split(path17.sep).join("/")
2837
+ }
2674
2838
  };
2675
2839
  if (!graph.hasEdge(edge.id)) {
2676
2840
  graph.addEdgeWithKey(edge.id, edge.source, edge.target, edge);
@@ -2695,15 +2859,21 @@ async function addDatabasesAndCompat(graph, services, scanPath) {
2695
2859
  }
2696
2860
 
2697
2861
  // src/extract/configs.ts
2698
- import { promises as fs11 } from "fs";
2699
- import path17 from "path";
2700
- import { EdgeType as EdgeType4, NodeType as NodeType7, Provenance as Provenance5, configId } from "@neat.is/types";
2862
+ import { promises as fs12 } from "fs";
2863
+ import path18 from "path";
2864
+ import {
2865
+ EdgeType as EdgeType4,
2866
+ NodeType as NodeType7,
2867
+ Provenance as Provenance5,
2868
+ configId,
2869
+ confidenceForExtracted as confidenceForExtracted2
2870
+ } from "@neat.is/types";
2701
2871
  async function walkConfigFiles(dir) {
2702
2872
  const out = [];
2703
2873
  async function walk(current) {
2704
- const entries = await fs11.readdir(current, { withFileTypes: true });
2874
+ const entries = await fs12.readdir(current, { withFileTypes: true });
2705
2875
  for (const entry of entries) {
2706
- const full = path17.join(current, entry.name);
2876
+ const full = path18.join(current, entry.name);
2707
2877
  if (entry.isDirectory()) {
2708
2878
  if (!IGNORED_DIRS.has(entry.name)) await walk(full);
2709
2879
  } else if (entry.isFile() && isConfigFile(entry.name).match) {
@@ -2720,13 +2890,13 @@ async function addConfigNodes(graph, services, scanPath) {
2720
2890
  for (const service of services) {
2721
2891
  const configFiles = await walkConfigFiles(service.dir);
2722
2892
  for (const file of configFiles) {
2723
- const relPath = path17.relative(scanPath, file);
2893
+ const relPath = path18.relative(scanPath, file);
2724
2894
  const node = {
2725
2895
  id: configId(relPath),
2726
2896
  type: NodeType7.ConfigNode,
2727
- name: path17.basename(file),
2897
+ name: path18.basename(file),
2728
2898
  path: relPath,
2729
- fileType: isConfigFile(path17.basename(file)).fileType
2899
+ fileType: isConfigFile(path18.basename(file)).fileType
2730
2900
  };
2731
2901
  if (!graph.hasNode(node.id)) {
2732
2902
  graph.addNode(node.id, node);
@@ -2738,7 +2908,8 @@ async function addConfigNodes(graph, services, scanPath) {
2738
2908
  target: node.id,
2739
2909
  type: EdgeType4.CONFIGURED_BY,
2740
2910
  provenance: Provenance5.EXTRACTED,
2741
- evidence: { file: relPath.split(path17.sep).join("/") }
2911
+ confidence: confidenceForExtracted2("structural"),
2912
+ evidence: { file: relPath.split(path18.sep).join("/") }
2742
2913
  };
2743
2914
  if (!graph.hasEdge(edge.id)) {
2744
2915
  graph.addEdgeWithKey(edge.id, edge.source, edge.target, edge);
@@ -2750,27 +2921,38 @@ async function addConfigNodes(graph, services, scanPath) {
2750
2921
  }
2751
2922
 
2752
2923
  // src/extract/calls/index.ts
2753
- import { EdgeType as EdgeType6, NodeType as NodeType8, Provenance as Provenance7 } from "@neat.is/types";
2924
+ import {
2925
+ EdgeType as EdgeType6,
2926
+ NodeType as NodeType8,
2927
+ Provenance as Provenance7,
2928
+ confidenceForExtracted as confidenceForExtracted4,
2929
+ passesExtractedFloor as passesExtractedFloor2
2930
+ } from "@neat.is/types";
2754
2931
 
2755
2932
  // src/extract/calls/http.ts
2756
- import path19 from "path";
2933
+ import path20 from "path";
2757
2934
  import Parser from "tree-sitter";
2758
2935
  import JavaScript from "tree-sitter-javascript";
2759
2936
  import Python from "tree-sitter-python";
2760
- import { EdgeType as EdgeType5, Provenance as Provenance6 } from "@neat.is/types";
2937
+ import {
2938
+ EdgeType as EdgeType5,
2939
+ Provenance as Provenance6,
2940
+ confidenceForExtracted as confidenceForExtracted3,
2941
+ passesExtractedFloor
2942
+ } from "@neat.is/types";
2761
2943
 
2762
2944
  // src/extract/calls/shared.ts
2763
- import { promises as fs12 } from "fs";
2764
- import path18 from "path";
2945
+ import { promises as fs13 } from "fs";
2946
+ import path19 from "path";
2765
2947
  async function walkSourceFiles(dir) {
2766
2948
  const out = [];
2767
2949
  async function walk(current) {
2768
- const entries = await fs12.readdir(current, { withFileTypes: true }).catch(() => []);
2950
+ const entries = await fs13.readdir(current, { withFileTypes: true }).catch(() => []);
2769
2951
  for (const entry of entries) {
2770
- const full = path18.join(current, entry.name);
2952
+ const full = path19.join(current, entry.name);
2771
2953
  if (entry.isDirectory()) {
2772
2954
  if (!IGNORED_DIRS.has(entry.name)) await walk(full);
2773
- } else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(path18.extname(entry.name))) {
2955
+ } else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(path19.extname(entry.name))) {
2774
2956
  out.push(full);
2775
2957
  }
2776
2958
  }
@@ -2783,7 +2965,7 @@ async function loadSourceFiles(dir) {
2783
2965
  const out = [];
2784
2966
  for (const p of paths) {
2785
2967
  try {
2786
- const content = await fs12.readFile(p, "utf8");
2968
+ const content = await fs13.readFile(p, "utf8");
2787
2969
  out.push({ path: p, content });
2788
2970
  } catch {
2789
2971
  }
@@ -2802,8 +2984,27 @@ function snippet(text, line) {
2802
2984
 
2803
2985
  // src/extract/calls/http.ts
2804
2986
  var STRING_LITERAL_NODE_TYPES = /* @__PURE__ */ new Set(["string_fragment", "string_content"]);
2987
+ var JSX_EXTERNAL_LINK_TAGS = /* @__PURE__ */ new Set(["a", "Link", "NavLink", "ExternalLink", "Anchor"]);
2988
+ function isInsideJsxExternalLink(node) {
2989
+ let cursor = node.parent;
2990
+ while (cursor) {
2991
+ if (cursor.type === "jsx_attribute") {
2992
+ let owner = cursor.parent;
2993
+ while (owner && owner.type !== "jsx_opening_element" && owner.type !== "jsx_self_closing_element") {
2994
+ owner = owner.parent;
2995
+ }
2996
+ if (!owner) return false;
2997
+ const tagNode = owner.namedChild(0);
2998
+ const tagName = tagNode?.text ?? "";
2999
+ const right = tagName.includes(".") ? tagName.split(".").pop() : tagName;
3000
+ return JSX_EXTERNAL_LINK_TAGS.has(right);
3001
+ }
3002
+ cursor = cursor.parent;
3003
+ }
3004
+ return false;
3005
+ }
2805
3006
  function collectStringLiterals(node, out) {
2806
- if (STRING_LITERAL_NODE_TYPES.has(node.type)) out.push(node.text);
3007
+ if (STRING_LITERAL_NODE_TYPES.has(node.type)) out.push({ text: node.text, node });
2807
3008
  for (let i = 0; i < node.namedChildCount; i++) {
2808
3009
  const child = node.namedChild(i);
2809
3010
  if (child) collectStringLiterals(child, out);
@@ -2815,8 +3016,9 @@ function callsFromSource(source, parser, knownHosts) {
2815
3016
  collectStringLiterals(tree.rootNode, literals);
2816
3017
  const targets = /* @__PURE__ */ new Set();
2817
3018
  for (const lit of literals) {
3019
+ if (isInsideJsxExternalLink(lit.node)) continue;
2818
3020
  for (const host of knownHosts) {
2819
- if (lit.includes(`//${host}`) || lit.includes(`//${host}:`)) {
3021
+ if (urlMatchesHost(lit.text, host)) {
2820
3022
  targets.add(host);
2821
3023
  }
2822
3024
  }
@@ -2839,9 +3041,9 @@ async function addHttpCallEdges(graph, services) {
2839
3041
  const knownHosts = /* @__PURE__ */ new Set();
2840
3042
  const hostToNodeId = /* @__PURE__ */ new Map();
2841
3043
  for (const service of services) {
2842
- knownHosts.add(path19.basename(service.dir));
3044
+ knownHosts.add(path20.basename(service.dir));
2843
3045
  knownHosts.add(service.pkg.name);
2844
- hostToNodeId.set(path19.basename(service.dir), service.node.id);
3046
+ hostToNodeId.set(path20.basename(service.dir), service.node.id);
2845
3047
  hostToNodeId.set(service.pkg.name, service.node.id);
2846
3048
  }
2847
3049
  let edgesAdded = 0;
@@ -2849,14 +3051,13 @@ async function addHttpCallEdges(graph, services) {
2849
3051
  const files = await loadSourceFiles(service.dir);
2850
3052
  const seenTargets = /* @__PURE__ */ new Map();
2851
3053
  for (const file of files) {
2852
- const parser = path19.extname(file.path) === ".py" ? pyParser : jsParser;
3054
+ if (isTestPath(file.path)) continue;
3055
+ const parser = path20.extname(file.path) === ".py" ? pyParser : jsParser;
2853
3056
  let targets;
2854
3057
  try {
2855
3058
  targets = callsFromSource(file.content, parser, knownHosts);
2856
3059
  } catch (err) {
2857
- console.warn(
2858
- `[neat] http call extraction skipped ${file.path}: ${err.message}`
2859
- );
3060
+ recordExtractionError("http call extraction", file.path, err);
2860
3061
  continue;
2861
3062
  }
2862
3063
  for (const t of targets) {
@@ -2870,17 +3071,32 @@ async function addHttpCallEdges(graph, services) {
2870
3071
  for (const [targetId, evidenceFile] of seenTargets) {
2871
3072
  const fileContent = files.find((f) => f.path === evidenceFile.file)?.content ?? "";
2872
3073
  const line = lineOf(fileContent, `//${evidenceFile.host}`);
3074
+ const confidence = confidenceForExtracted3("hostname-shape-match");
3075
+ const ev = {
3076
+ file: path20.relative(service.dir, evidenceFile.file),
3077
+ line,
3078
+ snippet: snippet(fileContent, line)
3079
+ };
3080
+ const edgeId = extractedEdgeId2(service.node.id, targetId, EdgeType5.CALLS);
3081
+ if (!passesExtractedFloor(confidence)) {
3082
+ noteExtractedDropped({
3083
+ source: service.node.id,
3084
+ target: targetId,
3085
+ type: EdgeType5.CALLS,
3086
+ confidence,
3087
+ confidenceKind: "hostname-shape-match",
3088
+ evidence: ev
3089
+ });
3090
+ continue;
3091
+ }
2873
3092
  const edge = {
2874
- id: extractedEdgeId2(service.node.id, targetId, EdgeType5.CALLS),
3093
+ id: edgeId,
2875
3094
  source: service.node.id,
2876
3095
  target: targetId,
2877
3096
  type: EdgeType5.CALLS,
2878
3097
  provenance: Provenance6.EXTRACTED,
2879
- evidence: {
2880
- file: path19.relative(service.dir, evidenceFile.file),
2881
- line,
2882
- snippet: snippet(fileContent, line)
2883
- }
3098
+ confidence,
3099
+ evidence: ev
2884
3100
  };
2885
3101
  if (!graph.hasEdge(edge.id)) {
2886
3102
  graph.addEdgeWithKey(edge.id, edge.source, edge.target, edge);
@@ -2892,7 +3108,7 @@ async function addHttpCallEdges(graph, services) {
2892
3108
  }
2893
3109
 
2894
3110
  // src/extract/calls/kafka.ts
2895
- import path20 from "path";
3111
+ import path21 from "path";
2896
3112
  import { infraId } from "@neat.is/types";
2897
3113
  var PRODUCER_TOPIC_RE = /(?:producer|kafkaProducer)[\s\S]{0,40}?\.send\s*\(\s*\{[\s\S]{0,200}?topic\s*:\s*['"`]([^'"`]+)['"`]/g;
2898
3114
  var CONSUMER_TOPIC_RE = /(?:consumer|kafkaConsumer)[\s\S]{0,40}?\.(?:subscribe|run)\s*\(\s*\{[\s\S]{0,200}?topic[s]?\s*:\s*(?:\[\s*)?['"`]([^'"`]+)['"`]/g;
@@ -2918,8 +3134,12 @@ function kafkaEndpointsFromFile(file, serviceDir) {
2918
3134
  name: topic,
2919
3135
  kind: "kafka-topic",
2920
3136
  edgeType,
3137
+ // `producer.send({topic: 'x'})` / `consumer.subscribe({topic: 'x'})` —
3138
+ // framework-aware (kafkajs / node-rdkafka shape). Verified-call-site
3139
+ // tier (ADR-066).
3140
+ confidenceKind: "verified-call-site",
2921
3141
  evidence: {
2922
- file: path20.relative(serviceDir, file.path),
3142
+ file: path21.relative(serviceDir, file.path),
2923
3143
  line,
2924
3144
  snippet: snippet(file.content, line)
2925
3145
  }
@@ -2931,7 +3151,7 @@ function kafkaEndpointsFromFile(file, serviceDir) {
2931
3151
  }
2932
3152
 
2933
3153
  // src/extract/calls/redis.ts
2934
- import path21 from "path";
3154
+ import path22 from "path";
2935
3155
  import { infraId as infraId2 } from "@neat.is/types";
2936
3156
  var REDIS_URL_RE = /redis(?:s)?:\/\/(?:[^@'"`\s]+@)?([^:/'"`\s]+)(?::(\d+))?/g;
2937
3157
  function redisEndpointsFromFile(file, serviceDir) {
@@ -2949,8 +3169,12 @@ function redisEndpointsFromFile(file, serviceDir) {
2949
3169
  name: host,
2950
3170
  kind: "redis",
2951
3171
  edgeType: "CALLS",
3172
+ // `redis://host` URL literal — the scheme is structural support, but no
3173
+ // call expression is verified to wire it through. URL-with-structural-
3174
+ // support tier (ADR-066).
3175
+ confidenceKind: "url-with-structural-support",
2952
3176
  evidence: {
2953
- file: path21.relative(serviceDir, file.path),
3177
+ file: path22.relative(serviceDir, file.path),
2954
3178
  line,
2955
3179
  snippet: snippet(file.content, line)
2956
3180
  }
@@ -2960,7 +3184,7 @@ function redisEndpointsFromFile(file, serviceDir) {
2960
3184
  }
2961
3185
 
2962
3186
  // src/extract/calls/aws.ts
2963
- import path22 from "path";
3187
+ import path23 from "path";
2964
3188
  import { infraId as infraId3 } from "@neat.is/types";
2965
3189
  var S3_BUCKET_RE = /Bucket\s*:\s*['"`]([^'"`]+)['"`]/g;
2966
3190
  var DYNAMO_TABLE_RE = /TableName\s*:\s*['"`]([^'"`]+)['"`]/g;
@@ -2989,8 +3213,12 @@ function awsEndpointsFromFile(file, serviceDir) {
2989
3213
  name,
2990
3214
  kind,
2991
3215
  edgeType: "CALLS",
3216
+ // SDK marker (S3Client, GetCommand, etc.) plus a Bucket/TableName
3217
+ // literal — framework-aware recognizer, verified-call-site tier
3218
+ // (ADR-066).
3219
+ confidenceKind: "verified-call-site",
2992
3220
  evidence: {
2993
- file: path22.relative(serviceDir, file.path),
3221
+ file: path23.relative(serviceDir, file.path),
2994
3222
  line,
2995
3223
  snippet: snippet(file.content, line)
2996
3224
  }
@@ -3014,16 +3242,42 @@ function awsEndpointsFromFile(file, serviceDir) {
3014
3242
  }
3015
3243
 
3016
3244
  // src/extract/calls/grpc.ts
3017
- import path23 from "path";
3245
+ import path24 from "path";
3018
3246
  import { infraId as infraId4 } from "@neat.is/types";
3019
3247
  var GRPC_CLIENT_RE = /new\s+([A-Z][A-Za-z0-9_]*)Client\s*\(\s*['"`]?([^,'"`)]+)?/g;
3248
+ var AWS_SDK_IMPORT_RE = /(?:from\s+['"`]|require\(\s*['"`])@aws-sdk\/client-([a-z0-9-]+)['"`]/g;
3249
+ var GRPC_IMPORT_RE = /(?:from\s+['"`]|require\(\s*['"`])@grpc\/grpc-js['"`]|_grpc_pb['"`]/;
3020
3250
  function isLikelyAddress(value) {
3021
3251
  if (!value) return false;
3022
3252
  return /:\d{2,5}$/.test(value) || value.includes(".");
3023
3253
  }
3254
+ function normaliseForMatch(s) {
3255
+ return s.toLowerCase().replace(/[^a-z0-9]/g, "");
3256
+ }
3257
+ function readImports(content) {
3258
+ const awsSdkSuffixes = /* @__PURE__ */ new Map();
3259
+ AWS_SDK_IMPORT_RE.lastIndex = 0;
3260
+ let m;
3261
+ while ((m = AWS_SDK_IMPORT_RE.exec(content)) !== null) {
3262
+ const raw = m[1];
3263
+ awsSdkSuffixes.set(normaliseForMatch(raw), raw);
3264
+ }
3265
+ return {
3266
+ awsSdkSuffixes,
3267
+ hasGrpcImport: GRPC_IMPORT_RE.test(content)
3268
+ };
3269
+ }
3270
+ function classifyClient(symbol, ctx) {
3271
+ const key = normaliseForMatch(symbol);
3272
+ const awsRaw = ctx.awsSdkSuffixes.get(key);
3273
+ if (awsRaw) return { kind: `aws-${awsRaw}` };
3274
+ if (ctx.hasGrpcImport) return { kind: "grpc-service" };
3275
+ return null;
3276
+ }
3024
3277
  function grpcEndpointsFromFile(file, serviceDir) {
3025
3278
  const out = [];
3026
3279
  const seen = /* @__PURE__ */ new Set();
3280
+ const ctx = readImports(file.content);
3027
3281
  GRPC_CLIENT_RE.lastIndex = 0;
3028
3282
  let m;
3029
3283
  while ((m = GRPC_CLIENT_RE.exec(file.content)) !== null) {
@@ -3031,15 +3285,22 @@ function grpcEndpointsFromFile(file, serviceDir) {
3031
3285
  const addr = m[2]?.trim();
3032
3286
  const name = isLikelyAddress(addr) ? addr : symbol;
3033
3287
  if (seen.has(name)) continue;
3288
+ const classified = classifyClient(symbol, ctx);
3289
+ if (!classified) continue;
3034
3290
  seen.add(name);
3291
+ const { kind } = classified;
3035
3292
  const line = lineOf(file.content, m[0]);
3036
3293
  out.push({
3037
- infraId: infraId4("grpc-service", name),
3294
+ infraId: infraId4(kind, name),
3038
3295
  name,
3039
- kind: "grpc-service",
3296
+ kind,
3040
3297
  edgeType: "CALLS",
3298
+ // `new <Name>Client(...)` with @aws-sdk/* or @grpc/grpc-js import
3299
+ // context — import-aware classification per #238. Verified-call-site
3300
+ // tier (ADR-066).
3301
+ confidenceKind: "verified-call-site",
3041
3302
  evidence: {
3042
- file: path23.relative(serviceDir, file.path),
3303
+ file: path24.relative(serviceDir, file.path),
3043
3304
  line,
3044
3305
  snippet: snippet(file.content, line)
3045
3306
  }
@@ -3059,6 +3320,9 @@ function edgeTypeFromEndpoint(ep) {
3059
3320
  return EdgeType6.CALLS;
3060
3321
  }
3061
3322
  }
3323
+ function isAwsKind(kind) {
3324
+ return kind.startsWith("aws-") || kind.startsWith("s3") || kind.startsWith("dynamodb");
3325
+ }
3062
3326
  async function addExternalEndpointEdges(graph, services) {
3063
3327
  let nodesAdded = 0;
3064
3328
  let edgesAdded = 0;
@@ -3066,10 +3330,13 @@ async function addExternalEndpointEdges(graph, services) {
3066
3330
  const files = await loadSourceFiles(service.dir);
3067
3331
  const endpoints = [];
3068
3332
  for (const file of files) {
3069
- endpoints.push(...kafkaEndpointsFromFile(file, service.dir));
3070
- endpoints.push(...redisEndpointsFromFile(file, service.dir));
3071
- endpoints.push(...awsEndpointsFromFile(file, service.dir));
3072
- endpoints.push(...grpcEndpointsFromFile(file, service.dir));
3333
+ if (isTestPath(file.path)) continue;
3334
+ const masked = maskCommentsInSource(file.content);
3335
+ const maskedFile = { path: file.path, content: masked };
3336
+ endpoints.push(...kafkaEndpointsFromFile(maskedFile, service.dir));
3337
+ endpoints.push(...redisEndpointsFromFile(maskedFile, service.dir));
3338
+ endpoints.push(...awsEndpointsFromFile(maskedFile, service.dir));
3339
+ endpoints.push(...grpcEndpointsFromFile(maskedFile, service.dir));
3073
3340
  }
3074
3341
  if (endpoints.length === 0) continue;
3075
3342
  const seenEdges = /* @__PURE__ */ new Set();
@@ -3079,7 +3346,10 @@ async function addExternalEndpointEdges(graph, services) {
3079
3346
  id: ep.infraId,
3080
3347
  type: NodeType8.InfraNode,
3081
3348
  name: ep.name,
3082
- provider: ep.kind.startsWith("s3") || ep.kind.startsWith("dynamodb") ? "aws" : "self",
3349
+ // #238 `aws-*` covers AWS-SDK client kinds (aws-s3, aws-dynamodb,
3350
+ // aws-cognito-identity-provider, …); `s3-` / `dynamodb-` cover the
3351
+ // bucket / table kinds from aws.ts.
3352
+ provider: isAwsKind(ep.kind) ? "aws" : "self",
3083
3353
  kind: ep.kind
3084
3354
  };
3085
3355
  graph.addNode(node.id, node);
@@ -3089,6 +3359,18 @@ async function addExternalEndpointEdges(graph, services) {
3089
3359
  const edgeId = extractedEdgeId2(service.node.id, ep.infraId, edgeType);
3090
3360
  if (seenEdges.has(edgeId)) continue;
3091
3361
  seenEdges.add(edgeId);
3362
+ const confidence = confidenceForExtracted4(ep.confidenceKind);
3363
+ if (!passesExtractedFloor2(confidence)) {
3364
+ noteExtractedDropped({
3365
+ source: service.node.id,
3366
+ target: ep.infraId,
3367
+ type: edgeType,
3368
+ confidence,
3369
+ confidenceKind: ep.confidenceKind,
3370
+ evidence: ep.evidence
3371
+ });
3372
+ continue;
3373
+ }
3092
3374
  if (!graph.hasEdge(edgeId)) {
3093
3375
  const edge = {
3094
3376
  id: edgeId,
@@ -3096,6 +3378,7 @@ async function addExternalEndpointEdges(graph, services) {
3096
3378
  target: ep.infraId,
3097
3379
  type: edgeType,
3098
3380
  provenance: Provenance7.EXTRACTED,
3381
+ confidence,
3099
3382
  evidence: ep.evidence
3100
3383
  };
3101
3384
  graph.addEdgeWithKey(edgeId, edge.source, edge.target, edge);
@@ -3115,8 +3398,8 @@ async function addCallEdges(graph, services) {
3115
3398
  }
3116
3399
 
3117
3400
  // src/extract/infra/docker-compose.ts
3118
- import path24 from "path";
3119
- import { EdgeType as EdgeType7, Provenance as Provenance8 } from "@neat.is/types";
3401
+ import path25 from "path";
3402
+ import { EdgeType as EdgeType7, Provenance as Provenance8, confidenceForExtracted as confidenceForExtracted5 } from "@neat.is/types";
3120
3403
 
3121
3404
  // src/extract/infra/shared.ts
3122
3405
  import { NodeType as NodeType9, infraId as infraId5 } from "@neat.is/types";
@@ -3152,7 +3435,7 @@ function dependsOnList(value) {
3152
3435
  }
3153
3436
  function serviceNameToServiceNode(name, services) {
3154
3437
  for (const s of services) {
3155
- if (s.node.name === name || path24.basename(s.dir) === name) return s.node.id;
3438
+ if (s.node.name === name || path25.basename(s.dir) === name) return s.node.id;
3156
3439
  }
3157
3440
  return null;
3158
3441
  }
@@ -3161,7 +3444,7 @@ async function addComposeInfra(graph, scanPath, services) {
3161
3444
  let edgesAdded = 0;
3162
3445
  let composePath = null;
3163
3446
  for (const name of ["docker-compose.yml", "docker-compose.yaml"]) {
3164
- const abs = path24.join(scanPath, name);
3447
+ const abs = path25.join(scanPath, name);
3165
3448
  if (await exists(abs)) {
3166
3449
  composePath = abs;
3167
3450
  break;
@@ -3172,13 +3455,15 @@ async function addComposeInfra(graph, scanPath, services) {
3172
3455
  try {
3173
3456
  compose = await readYaml(composePath);
3174
3457
  } catch (err) {
3175
- console.warn(
3176
- `[neat] infra docker-compose skipped ${path24.relative(scanPath, composePath)}: ${err.message}`
3458
+ recordExtractionError(
3459
+ "infra docker-compose",
3460
+ path25.relative(scanPath, composePath),
3461
+ err
3177
3462
  );
3178
3463
  return { nodesAdded, edgesAdded };
3179
3464
  }
3180
3465
  if (!compose?.services) return { nodesAdded, edgesAdded };
3181
- const evidenceFile = path24.relative(scanPath, composePath).split(path24.sep).join("/");
3466
+ const evidenceFile = path25.relative(scanPath, composePath).split(path25.sep).join("/");
3182
3467
  const composeNameToNodeId = /* @__PURE__ */ new Map();
3183
3468
  for (const [composeName, svc] of Object.entries(compose.services)) {
3184
3469
  const matchedServiceId = serviceNameToServiceNode(composeName, services);
@@ -3208,6 +3493,7 @@ async function addComposeInfra(graph, scanPath, services) {
3208
3493
  target: targetId,
3209
3494
  type: EdgeType7.DEPENDS_ON,
3210
3495
  provenance: Provenance8.EXTRACTED,
3496
+ confidence: confidenceForExtracted5("structural"),
3211
3497
  evidence: { file: evidenceFile }
3212
3498
  };
3213
3499
  graph.addEdgeWithKey(edgeId, edge.source, edge.target, edge);
@@ -3218,9 +3504,9 @@ async function addComposeInfra(graph, scanPath, services) {
3218
3504
  }
3219
3505
 
3220
3506
  // src/extract/infra/dockerfile.ts
3221
- import path25 from "path";
3222
- import { promises as fs13 } from "fs";
3223
- import { EdgeType as EdgeType8, Provenance as Provenance9 } from "@neat.is/types";
3507
+ import path26 from "path";
3508
+ import { promises as fs14 } from "fs";
3509
+ import { EdgeType as EdgeType8, Provenance as Provenance9, confidenceForExtracted as confidenceForExtracted6 } from "@neat.is/types";
3224
3510
  function runtimeImage(content) {
3225
3511
  const lines = content.split("\n");
3226
3512
  let last = null;
@@ -3239,14 +3525,16 @@ async function addDockerfileRuntimes(graph, services, scanPath) {
3239
3525
  let nodesAdded = 0;
3240
3526
  let edgesAdded = 0;
3241
3527
  for (const service of services) {
3242
- const dockerfilePath = path25.join(service.dir, "Dockerfile");
3528
+ const dockerfilePath = path26.join(service.dir, "Dockerfile");
3243
3529
  if (!await exists(dockerfilePath)) continue;
3244
3530
  let content;
3245
3531
  try {
3246
- content = await fs13.readFile(dockerfilePath, "utf8");
3532
+ content = await fs14.readFile(dockerfilePath, "utf8");
3247
3533
  } catch (err) {
3248
- console.warn(
3249
- `[neat] infra dockerfile skipped ${path25.relative(scanPath, dockerfilePath)}: ${err.message}`
3534
+ recordExtractionError(
3535
+ "infra dockerfile",
3536
+ path26.relative(scanPath, dockerfilePath),
3537
+ err
3250
3538
  );
3251
3539
  continue;
3252
3540
  }
@@ -3265,8 +3553,9 @@ async function addDockerfileRuntimes(graph, services, scanPath) {
3265
3553
  target: node.id,
3266
3554
  type: EdgeType8.RUNS_ON,
3267
3555
  provenance: Provenance9.EXTRACTED,
3556
+ confidence: confidenceForExtracted6("structural"),
3268
3557
  evidence: {
3269
- file: path25.relative(scanPath, dockerfilePath).split(path25.sep).join("/")
3558
+ file: path26.relative(scanPath, dockerfilePath).split(path26.sep).join("/")
3270
3559
  }
3271
3560
  };
3272
3561
  graph.addEdgeWithKey(edgeId, edge.source, edge.target, edge);
@@ -3277,19 +3566,19 @@ async function addDockerfileRuntimes(graph, services, scanPath) {
3277
3566
  }
3278
3567
 
3279
3568
  // src/extract/infra/terraform.ts
3280
- import { promises as fs14 } from "fs";
3281
- import path26 from "path";
3569
+ import { promises as fs15 } from "fs";
3570
+ import path27 from "path";
3282
3571
  var RESOURCE_RE = /resource\s+"(aws_[A-Za-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/g;
3283
3572
  async function walkTfFiles(start, depth = 0, max = 5) {
3284
3573
  if (depth > max) return [];
3285
3574
  const out = [];
3286
- const entries = await fs14.readdir(start, { withFileTypes: true }).catch(() => []);
3575
+ const entries = await fs15.readdir(start, { withFileTypes: true }).catch(() => []);
3287
3576
  for (const entry of entries) {
3288
3577
  if (entry.isDirectory()) {
3289
3578
  if (IGNORED_DIRS.has(entry.name) || entry.name === ".terraform") continue;
3290
- out.push(...await walkTfFiles(path26.join(start, entry.name), depth + 1, max));
3579
+ out.push(...await walkTfFiles(path27.join(start, entry.name), depth + 1, max));
3291
3580
  } else if (entry.isFile() && entry.name.endsWith(".tf")) {
3292
- out.push(path26.join(start, entry.name));
3581
+ out.push(path27.join(start, entry.name));
3293
3582
  }
3294
3583
  }
3295
3584
  return out;
@@ -3298,7 +3587,7 @@ async function addTerraformResources(graph, scanPath) {
3298
3587
  let nodesAdded = 0;
3299
3588
  const files = await walkTfFiles(scanPath);
3300
3589
  for (const file of files) {
3301
- const content = await fs14.readFile(file, "utf8");
3590
+ const content = await fs15.readFile(file, "utf8");
3302
3591
  RESOURCE_RE.lastIndex = 0;
3303
3592
  let m;
3304
3593
  while ((m = RESOURCE_RE.exec(content)) !== null) {
@@ -3315,8 +3604,8 @@ async function addTerraformResources(graph, scanPath) {
3315
3604
  }
3316
3605
 
3317
3606
  // src/extract/infra/k8s.ts
3318
- import { promises as fs15 } from "fs";
3319
- import path27 from "path";
3607
+ import { promises as fs16 } from "fs";
3608
+ import path28 from "path";
3320
3609
  import { parseAllDocuments as parseAllDocuments2 } from "yaml";
3321
3610
  var K8S_KIND_TO_INFRA_KIND = {
3322
3611
  Service: "k8s-service",
@@ -3330,13 +3619,13 @@ var K8S_KIND_TO_INFRA_KIND = {
3330
3619
  async function walkYamlFiles2(start, depth = 0, max = 5) {
3331
3620
  if (depth > max) return [];
3332
3621
  const out = [];
3333
- const entries = await fs15.readdir(start, { withFileTypes: true }).catch(() => []);
3622
+ const entries = await fs16.readdir(start, { withFileTypes: true }).catch(() => []);
3334
3623
  for (const entry of entries) {
3335
3624
  if (entry.isDirectory()) {
3336
3625
  if (IGNORED_DIRS.has(entry.name)) continue;
3337
- out.push(...await walkYamlFiles2(path27.join(start, entry.name), depth + 1, max));
3338
- } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(path27.extname(entry.name))) {
3339
- out.push(path27.join(start, entry.name));
3626
+ out.push(...await walkYamlFiles2(path28.join(start, entry.name), depth + 1, max));
3627
+ } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(path28.extname(entry.name))) {
3628
+ out.push(path28.join(start, entry.name));
3340
3629
  }
3341
3630
  }
3342
3631
  return out;
@@ -3345,7 +3634,7 @@ async function addK8sResources(graph, scanPath) {
3345
3634
  let nodesAdded = 0;
3346
3635
  const files = await walkYamlFiles2(scanPath);
3347
3636
  for (const file of files) {
3348
- const content = await fs15.readFile(file, "utf8");
3637
+ const content = await fs16.readFile(file, "utf8");
3349
3638
  let docs;
3350
3639
  try {
3351
3640
  docs = parseAllDocuments2(content).map((d) => d.toJSON());
@@ -3379,9 +3668,48 @@ async function addInfra(graph, scanPath, services) {
3379
3668
  };
3380
3669
  }
3381
3670
 
3671
+ // src/extract/index.ts
3672
+ import path30 from "path";
3673
+
3674
+ // src/extract/retire.ts
3675
+ import { existsSync } from "fs";
3676
+ import path29 from "path";
3677
+ import { Provenance as Provenance10 } from "@neat.is/types";
3678
+ function retireEdgesByFile(graph, file) {
3679
+ const normalized = file.split("\\").join("/");
3680
+ const toDrop = [];
3681
+ graph.forEachEdge((id, attrs) => {
3682
+ const edge = attrs;
3683
+ if (edge.provenance !== Provenance10.EXTRACTED) return;
3684
+ if (!edge.evidence?.file) return;
3685
+ if (edge.evidence.file === normalized) toDrop.push(id);
3686
+ });
3687
+ for (const id of toDrop) graph.dropEdge(id);
3688
+ return toDrop.length;
3689
+ }
3690
+ function retireExtractedEdgesByMissingFile(graph, scanPath, serviceDirs = []) {
3691
+ const toDrop = [];
3692
+ const bases = [scanPath, ...serviceDirs];
3693
+ graph.forEachEdge((id, attrs) => {
3694
+ const edge = attrs;
3695
+ if (edge.provenance !== Provenance10.EXTRACTED) return;
3696
+ const evidenceFile = edge.evidence?.file;
3697
+ if (!evidenceFile) return;
3698
+ if (path29.isAbsolute(evidenceFile)) {
3699
+ if (!existsSync(evidenceFile)) toDrop.push(id);
3700
+ return;
3701
+ }
3702
+ const found = bases.some((base) => existsSync(path29.join(base, evidenceFile)));
3703
+ if (!found) toDrop.push(id);
3704
+ });
3705
+ for (const id of toDrop) graph.dropEdge(id);
3706
+ return toDrop.length;
3707
+ }
3708
+
3382
3709
  // src/extract/index.ts
3383
3710
  async function extractFromDirectory(graph, scanPath, opts = {}) {
3384
3711
  await ensureCompatLoaded();
3712
+ drainExtractionErrors();
3385
3713
  const services = await discoverServices(scanPath);
3386
3714
  const phase1Nodes = addServiceNodes(graph, services);
3387
3715
  await addServiceAliases(graph, scanPath, services);
@@ -3389,12 +3717,43 @@ async function extractFromDirectory(graph, scanPath, opts = {}) {
3389
3717
  const phase3 = await addConfigNodes(graph, services, scanPath);
3390
3718
  const phase4 = await addCallEdges(graph, services);
3391
3719
  const phase5 = await addInfra(graph, scanPath, services);
3720
+ const ghostsRetired = retireExtractedEdgesByMissingFile(
3721
+ graph,
3722
+ scanPath,
3723
+ services.map((s) => s.dir)
3724
+ );
3392
3725
  const frontiersPromoted = promoteFrontierNodes(graph);
3393
3726
  if (opts.onPolicyTrigger) await opts.onPolicyTrigger(graph);
3727
+ const errorEntries = drainExtractionErrors();
3728
+ if (opts.errorsPath && errorEntries.length > 0) {
3729
+ try {
3730
+ await writeExtractionErrors(errorEntries, opts.errorsPath);
3731
+ } catch (err) {
3732
+ console.warn(
3733
+ `[neat] failed to write extraction errors to ${opts.errorsPath}: ${err.message}`
3734
+ );
3735
+ }
3736
+ }
3737
+ const droppedEntries = drainDroppedExtracted();
3738
+ if (isRejectedLogEnabled() && opts.errorsPath && droppedEntries.length > 0) {
3739
+ const rejectedPath = path30.join(path30.dirname(opts.errorsPath), "rejected.ndjson");
3740
+ try {
3741
+ await writeRejectedExtracted(droppedEntries, rejectedPath);
3742
+ } catch (err) {
3743
+ console.warn(
3744
+ `[neat] failed to write rejected extracted edges to ${rejectedPath}: ${err.message}`
3745
+ );
3746
+ }
3747
+ }
3394
3748
  const result = {
3395
3749
  nodesAdded: phase1Nodes + phase2.nodesAdded + phase3.nodesAdded + phase4.nodesAdded + phase5.nodesAdded,
3396
3750
  edgesAdded: phase2.edgesAdded + phase3.edgesAdded + phase4.edgesAdded + phase5.edgesAdded,
3397
- frontiersPromoted
3751
+ frontiersPromoted,
3752
+ extractionErrors: errorEntries.length,
3753
+ errorEntries,
3754
+ ghostsRetired,
3755
+ extractedDropped: droppedEntries.length,
3756
+ droppedEntries
3398
3757
  };
3399
3758
  emitNeatEvent({
3400
3759
  type: "extraction-complete",
@@ -3410,8 +3769,8 @@ async function extractFromDirectory(graph, scanPath, opts = {}) {
3410
3769
  }
3411
3770
 
3412
3771
  // src/persist.ts
3413
- import { promises as fs16 } from "fs";
3414
- import path28 from "path";
3772
+ import { promises as fs17 } from "fs";
3773
+ import path31 from "path";
3415
3774
  var SCHEMA_VERSION = 2;
3416
3775
  function migrateV1ToV2(payload) {
3417
3776
  const nodes = payload.graph.nodes;
@@ -3425,7 +3784,7 @@ function migrateV1ToV2(payload) {
3425
3784
  return { ...payload, schemaVersion: 2 };
3426
3785
  }
3427
3786
  async function ensureDir(filePath) {
3428
- await fs16.mkdir(path28.dirname(filePath), { recursive: true });
3787
+ await fs17.mkdir(path31.dirname(filePath), { recursive: true });
3429
3788
  }
3430
3789
  async function saveGraphToDisk(graph, outPath) {
3431
3790
  await ensureDir(outPath);
@@ -3435,13 +3794,13 @@ async function saveGraphToDisk(graph, outPath) {
3435
3794
  graph: graph.export()
3436
3795
  };
3437
3796
  const tmp = `${outPath}.tmp`;
3438
- await fs16.writeFile(tmp, JSON.stringify(payload), "utf8");
3439
- await fs16.rename(tmp, outPath);
3797
+ await fs17.writeFile(tmp, JSON.stringify(payload), "utf8");
3798
+ await fs17.rename(tmp, outPath);
3440
3799
  }
3441
3800
  async function loadGraphFromDisk(graph, outPath) {
3442
3801
  let raw;
3443
3802
  try {
3444
- raw = await fs16.readFile(outPath, "utf8");
3803
+ raw = await fs17.readFile(outPath, "utf8");
3445
3804
  } catch (err) {
3446
3805
  if (err.code === "ENOENT") return;
3447
3806
  throw err;
@@ -3493,7 +3852,7 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
3493
3852
  }
3494
3853
 
3495
3854
  // src/diff.ts
3496
- import { promises as fs17 } from "fs";
3855
+ import { promises as fs18 } from "fs";
3497
3856
  async function loadSnapshotForDiff(target) {
3498
3857
  if (/^https?:\/\//i.test(target)) {
3499
3858
  const res = await fetch(target);
@@ -3502,7 +3861,7 @@ async function loadSnapshotForDiff(target) {
3502
3861
  }
3503
3862
  return await res.json();
3504
3863
  }
3505
- const raw = await fs17.readFile(target, "utf8");
3864
+ const raw = await fs18.readFile(target, "utf8");
3506
3865
  return JSON.parse(raw);
3507
3866
  }
3508
3867
  function indexEntries(entries) {
@@ -3569,23 +3928,23 @@ function canonicalJson(value) {
3569
3928
  }
3570
3929
 
3571
3930
  // src/projects.ts
3572
- import path29 from "path";
3931
+ import path32 from "path";
3573
3932
  function pathsForProject(project, baseDir) {
3574
3933
  if (project === DEFAULT_PROJECT) {
3575
3934
  return {
3576
- snapshotPath: path29.join(baseDir, "graph.json"),
3577
- errorsPath: path29.join(baseDir, "errors.ndjson"),
3578
- staleEventsPath: path29.join(baseDir, "stale-events.ndjson"),
3579
- embeddingsCachePath: path29.join(baseDir, "embeddings.json"),
3580
- policyViolationsPath: path29.join(baseDir, "policy-violations.ndjson")
3935
+ snapshotPath: path32.join(baseDir, "graph.json"),
3936
+ errorsPath: path32.join(baseDir, "errors.ndjson"),
3937
+ staleEventsPath: path32.join(baseDir, "stale-events.ndjson"),
3938
+ embeddingsCachePath: path32.join(baseDir, "embeddings.json"),
3939
+ policyViolationsPath: path32.join(baseDir, "policy-violations.ndjson")
3581
3940
  };
3582
3941
  }
3583
3942
  return {
3584
- snapshotPath: path29.join(baseDir, `${project}.json`),
3585
- errorsPath: path29.join(baseDir, `errors.${project}.ndjson`),
3586
- staleEventsPath: path29.join(baseDir, `stale-events.${project}.ndjson`),
3587
- embeddingsCachePath: path29.join(baseDir, `embeddings.${project}.json`),
3588
- policyViolationsPath: path29.join(baseDir, `policy-violations.${project}.ndjson`)
3943
+ snapshotPath: path32.join(baseDir, `${project}.json`),
3944
+ errorsPath: path32.join(baseDir, `errors.${project}.ndjson`),
3945
+ staleEventsPath: path32.join(baseDir, `stale-events.${project}.ndjson`),
3946
+ embeddingsCachePath: path32.join(baseDir, `embeddings.${project}.json`),
3947
+ policyViolationsPath: path32.join(baseDir, `policy-violations.${project}.ndjson`)
3589
3948
  };
3590
3949
  }
3591
3950
  var Projects = class {
@@ -3624,9 +3983,9 @@ function parseExtraProjects(raw) {
3624
3983
  }
3625
3984
 
3626
3985
  // src/registry.ts
3627
- import { promises as fs18 } from "fs";
3986
+ import { promises as fs19 } from "fs";
3628
3987
  import os2 from "os";
3629
- import path30 from "path";
3988
+ import path33 from "path";
3630
3989
  import {
3631
3990
  RegistryFileSchema
3632
3991
  } from "@neat.is/types";
@@ -3634,41 +3993,41 @@ var LOCK_TIMEOUT_MS = 5e3;
3634
3993
  var LOCK_RETRY_MS = 50;
3635
3994
  function neatHome() {
3636
3995
  const override = process.env.NEAT_HOME;
3637
- if (override && override.length > 0) return path30.resolve(override);
3638
- return path30.join(os2.homedir(), ".neat");
3996
+ if (override && override.length > 0) return path33.resolve(override);
3997
+ return path33.join(os2.homedir(), ".neat");
3639
3998
  }
3640
3999
  function registryPath() {
3641
- return path30.join(neatHome(), "projects.json");
4000
+ return path33.join(neatHome(), "projects.json");
3642
4001
  }
3643
4002
  function registryLockPath() {
3644
- return path30.join(neatHome(), "projects.json.lock");
4003
+ return path33.join(neatHome(), "projects.json.lock");
3645
4004
  }
3646
4005
  async function normalizeProjectPath(input) {
3647
- const resolved = path30.resolve(input);
4006
+ const resolved = path33.resolve(input);
3648
4007
  try {
3649
- return await fs18.realpath(resolved);
4008
+ return await fs19.realpath(resolved);
3650
4009
  } catch {
3651
4010
  return resolved;
3652
4011
  }
3653
4012
  }
3654
4013
  async function writeAtomically(target, contents) {
3655
- await fs18.mkdir(path30.dirname(target), { recursive: true });
4014
+ await fs19.mkdir(path33.dirname(target), { recursive: true });
3656
4015
  const tmp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
3657
- const fd = await fs18.open(tmp, "w");
4016
+ const fd = await fs19.open(tmp, "w");
3658
4017
  try {
3659
4018
  await fd.writeFile(contents, "utf8");
3660
4019
  await fd.sync();
3661
4020
  } finally {
3662
4021
  await fd.close();
3663
4022
  }
3664
- await fs18.rename(tmp, target);
4023
+ await fs19.rename(tmp, target);
3665
4024
  }
3666
4025
  async function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
3667
4026
  const deadline = Date.now() + timeoutMs;
3668
- await fs18.mkdir(path30.dirname(lockPath), { recursive: true });
4027
+ await fs19.mkdir(path33.dirname(lockPath), { recursive: true });
3669
4028
  while (true) {
3670
4029
  try {
3671
- const fd = await fs18.open(lockPath, "wx");
4030
+ const fd = await fs19.open(lockPath, "wx");
3672
4031
  await fd.close();
3673
4032
  return;
3674
4033
  } catch (err) {
@@ -3684,7 +4043,7 @@ async function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
3684
4043
  }
3685
4044
  }
3686
4045
  async function releaseLock(lockPath) {
3687
- await fs18.unlink(lockPath).catch(() => {
4046
+ await fs19.unlink(lockPath).catch(() => {
3688
4047
  });
3689
4048
  }
3690
4049
  async function withLock(fn) {
@@ -3700,7 +4059,7 @@ async function readRegistry() {
3700
4059
  const file = registryPath();
3701
4060
  let raw;
3702
4061
  try {
3703
- raw = await fs18.readFile(file, "utf8");
4062
+ raw = await fs19.readFile(file, "utf8");
3704
4063
  } catch (err) {
3705
4064
  if (err.code === "ENOENT") {
3706
4065
  return { version: 1, projects: [] };
@@ -3803,7 +4162,7 @@ import {
3803
4162
  EdgeType as EdgeType9,
3804
4163
  NodeType as NodeType10,
3805
4164
  parseEdgeId,
3806
- Provenance as Provenance10
4165
+ Provenance as Provenance11
3807
4166
  } from "@neat.is/types";
3808
4167
  function bucketKey(source, target, type) {
3809
4168
  return `${type}|${source}|${target}`;
@@ -3817,20 +4176,20 @@ function bucketEdges(graph) {
3817
4176
  const key = bucketKey(e.source, e.target, e.type);
3818
4177
  const cur = buckets.get(key) ?? { source: e.source, target: e.target, type: e.type };
3819
4178
  switch (provenance) {
3820
- case Provenance10.EXTRACTED:
4179
+ case Provenance11.EXTRACTED:
3821
4180
  cur.extracted = e;
3822
4181
  break;
3823
- case Provenance10.OBSERVED:
4182
+ case Provenance11.OBSERVED:
3824
4183
  cur.observed = e;
3825
4184
  break;
3826
- case Provenance10.INFERRED:
4185
+ case Provenance11.INFERRED:
3827
4186
  cur.inferred = e;
3828
4187
  break;
3829
- case Provenance10.FRONTIER:
4188
+ case Provenance11.FRONTIER:
3830
4189
  cur.frontier = e;
3831
4190
  break;
3832
4191
  default:
3833
- if (e.provenance === Provenance10.STALE) cur.stale = e;
4192
+ if (e.provenance === Provenance11.STALE) cur.stale = e;
3834
4193
  }
3835
4194
  buckets.set(key, cur);
3836
4195
  });
@@ -3841,14 +4200,6 @@ function nodeIsFrontier(graph, nodeId) {
3841
4200
  const attrs = graph.getNodeAttributes(nodeId);
3842
4201
  return attrs.type === NodeType10.FrontierNode;
3843
4202
  }
3844
- function hasAnyObservedFromSource(graph, sourceId) {
3845
- if (!graph.hasNode(sourceId)) return false;
3846
- for (const edgeId of graph.outboundEdges(sourceId)) {
3847
- const e = graph.getEdgeAttributes(edgeId);
3848
- if (e.provenance === Provenance10.OBSERVED) return true;
3849
- }
3850
- return false;
3851
- }
3852
4203
  function clampConfidence(n) {
3853
4204
  if (!Number.isFinite(n)) return 0;
3854
4205
  return Math.max(0, Math.min(1, n));
@@ -3862,32 +4213,34 @@ function reasonForMissingExtracted(source, target, type) {
3862
4213
  var RECOMMENDATION_MISSING_OBSERVED = "Verify the code path is exercised in production; check feature flags or conditional branches that might gate the call.";
3863
4214
  var RECOMMENDATION_MISSING_EXTRACTED = "Likely dynamic dispatch, reflection, or a coverage gap in tree-sitter extraction. Consider an `aliases` entry on the source service or file an extractor issue.";
3864
4215
  var RECOMMENDATION_HOST_MISMATCH = "Check environment-specific config overrides \u2014 the runtime host differs from what static configuration declares.";
4216
+ function gradedConfidence(edge) {
4217
+ if (typeof edge.confidence === "number") return clampConfidence(edge.confidence);
4218
+ return clampConfidence(confidenceForEdge(edge));
4219
+ }
3865
4220
  function detectMissingDivergences(graph, bucket) {
3866
4221
  const out = [];
3867
4222
  if (bucket.extracted && !bucket.observed) {
3868
4223
  if (!nodeIsFrontier(graph, bucket.target)) {
3869
- const sourceHasTraffic = hasAnyObservedFromSource(graph, bucket.source);
3870
4224
  out.push({
3871
4225
  type: "missing-observed",
3872
4226
  source: bucket.source,
3873
4227
  target: bucket.target,
3874
4228
  edgeType: bucket.type,
3875
4229
  extracted: bucket.extracted,
3876
- confidence: sourceHasTraffic ? 1 : 0.5,
4230
+ confidence: gradedConfidence(bucket.extracted),
3877
4231
  reason: reasonForMissingObserved(bucket.source, bucket.target, bucket.type),
3878
4232
  recommendation: RECOMMENDATION_MISSING_OBSERVED
3879
4233
  });
3880
4234
  }
3881
4235
  }
3882
4236
  if (bucket.observed && !bucket.extracted) {
3883
- const cascaded = clampConfidence(confidenceForEdge(bucket.observed));
3884
4237
  out.push({
3885
4238
  type: "missing-extracted",
3886
4239
  source: bucket.source,
3887
4240
  target: bucket.target,
3888
4241
  edgeType: bucket.type,
3889
4242
  observed: bucket.observed,
3890
- confidence: cascaded,
4243
+ confidence: gradedConfidence(bucket.observed),
3891
4244
  reason: reasonForMissingExtracted(bucket.source, bucket.target, bucket.type),
3892
4245
  recommendation: RECOMMENDATION_MISSING_EXTRACTED
3893
4246
  });
@@ -3906,7 +4259,7 @@ function declaredHostFor(svc) {
3906
4259
  function hasExtractedConfiguredBy(graph, svcId) {
3907
4260
  for (const edgeId of graph.outboundEdges(svcId)) {
3908
4261
  const e = graph.getEdgeAttributes(edgeId);
3909
- if (e.type === EdgeType9.CONFIGURED_BY && e.provenance === Provenance10.EXTRACTED) {
4262
+ if (e.type === EdgeType9.CONFIGURED_BY && e.provenance === Provenance11.EXTRACTED) {
3910
4263
  return true;
3911
4264
  }
3912
4265
  }
@@ -3920,7 +4273,7 @@ function detectHostMismatch(graph, svcId, svc) {
3920
4273
  for (const edgeId of graph.outboundEdges(svcId)) {
3921
4274
  const edge = graph.getEdgeAttributes(edgeId);
3922
4275
  if (edge.type !== EdgeType9.CONNECTS_TO) continue;
3923
- if (edge.provenance !== Provenance10.OBSERVED) continue;
4276
+ if (edge.provenance !== Provenance11.OBSERVED) continue;
3924
4277
  const target = graph.getNodeAttributes(edge.target);
3925
4278
  if (target.type !== NodeType10.DatabaseNode) continue;
3926
4279
  const observedHost = target.host?.trim();
@@ -3945,7 +4298,7 @@ function detectCompatDivergences(graph, svcId, svc) {
3945
4298
  for (const edgeId of graph.outboundEdges(svcId)) {
3946
4299
  const edge = graph.getEdgeAttributes(edgeId);
3947
4300
  if (edge.type !== EdgeType9.CONNECTS_TO) continue;
3948
- if (edge.provenance !== Provenance10.OBSERVED) continue;
4301
+ if (edge.provenance !== Provenance11.OBSERVED) continue;
3949
4302
  const target = graph.getNodeAttributes(edge.target);
3950
4303
  if (target.type !== NodeType10.DatabaseNode) continue;
3951
4304
  for (const pair of compatPairs()) {
@@ -4026,8 +4379,17 @@ function computeDivergences(graph, opts = {}) {
4026
4379
  const target = opts.node;
4027
4380
  filtered = filtered.filter((d) => involvesNode(d, target));
4028
4381
  }
4382
+ const TYPE_LEADERSHIP = {
4383
+ "missing-extracted": 0,
4384
+ "missing-observed": 1,
4385
+ "version-mismatch": 2,
4386
+ "host-mismatch": 3,
4387
+ "compat-violation": 4
4388
+ };
4029
4389
  filtered.sort((a, b) => {
4030
4390
  if (b.confidence !== a.confidence) return b.confidence - a.confidence;
4391
+ const lead = TYPE_LEADERSHIP[a.type] - TYPE_LEADERSHIP[b.type];
4392
+ if (lead !== 0) return lead;
4031
4393
  if (a.type !== b.type) return a.type.localeCompare(b.type);
4032
4394
  if (a.source !== b.source) return a.source.localeCompare(b.source);
4033
4395
  return a.target.localeCompare(b.target);
@@ -4545,6 +4907,9 @@ export {
4545
4907
  readStaleEvents,
4546
4908
  startStalenessLoop,
4547
4909
  readErrorEvents,
4910
+ isStrictExtractionEnabled,
4911
+ formatExtractionBanner,
4912
+ formatPrecisionFloorBanner,
4548
4913
  discoverServices,
4549
4914
  addServiceNodes,
4550
4915
  addServiceAliases,
@@ -4552,6 +4917,7 @@ export {
4552
4917
  addConfigNodes,
4553
4918
  addCallEdges,
4554
4919
  addInfra,
4920
+ retireEdgesByFile,
4555
4921
  extractFromDirectory,
4556
4922
  saveGraphToDisk,
4557
4923
  loadGraphFromDisk,
@@ -4575,4 +4941,4 @@ export {
4575
4941
  removeProject,
4576
4942
  buildApi
4577
4943
  };
4578
- //# sourceMappingURL=chunk-FIXKIYNF.js.map
4944
+ //# sourceMappingURL=chunk-33ZZ2ZID.js.map