@neat.is/core 0.4.10 → 0.4.12

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.
@@ -454,19 +454,19 @@ function confidenceFromMix(edges, now = Date.now()) {
454
454
  function longestIncomingWalk(graph, start, maxDepth) {
455
455
  let best = { path: [start], edges: [] };
456
456
  const visited = /* @__PURE__ */ new Set([start]);
457
- function step(node, path34, edges) {
458
- if (path34.length > best.path.length) {
459
- best = { path: [...path34], edges: [...edges] };
457
+ function step(node, path36, edges) {
458
+ if (path36.length > best.path.length) {
459
+ best = { path: [...path36], edges: [...edges] };
460
460
  }
461
- if (path34.length - 1 >= maxDepth) return;
461
+ if (path36.length - 1 >= maxDepth) return;
462
462
  const incoming = bestEdgeBySource(graph, graph.inboundEdges(node));
463
463
  for (const [srcId, edge] of incoming) {
464
464
  if (visited.has(srcId)) continue;
465
465
  visited.add(srcId);
466
- path34.push(srcId);
466
+ path36.push(srcId);
467
467
  edges.push(edge);
468
- step(srcId, path34, edges);
469
- path34.pop();
468
+ step(srcId, path36, edges);
469
+ path36.pop();
470
470
  edges.pop();
471
471
  visited.delete(srcId);
472
472
  }
@@ -655,8 +655,9 @@ function getTransitiveDependencies(graph, nodeId, depth = TRANSITIVE_DEPENDENCIE
655
655
  }
656
656
 
657
657
  // src/ingest.ts
658
- import { promises as fs3 } from "fs";
658
+ import { promises as fs3, existsSync, readFileSync } from "fs";
659
659
  import path3 from "path";
660
+ import * as sourceMapJs from "source-map-js";
660
661
 
661
662
  // src/policy.ts
662
663
  import { promises as fs2 } from "fs";
@@ -1103,6 +1104,14 @@ function warnUnidentifiedSpan(project) {
1103
1104
  `[neatd] span lacked service.name; routed to 'unidentified' in project ${project}; check your OTel SDK config.`
1104
1105
  );
1105
1106
  }
1107
+ var noSourceMapWarnedServices = /* @__PURE__ */ new Set();
1108
+ function warnNoSourceMaps(serviceName) {
1109
+ if (noSourceMapWarnedServices.has(serviceName)) return;
1110
+ noSourceMapWarnedServices.add(serviceName);
1111
+ console.warn(
1112
+ `[neat] ${serviceName}: no .map files found under dist/; observed file edges will land on dist paths, not src. Set sourceMap: true in tsconfig to enable file-level reconciliation.`
1113
+ );
1114
+ }
1106
1115
  function pickAttr(span, ...keys) {
1107
1116
  for (const k of keys) {
1108
1117
  const v = span.attributes[k];
@@ -1145,8 +1154,13 @@ function languageForExt(relPath) {
1145
1154
  return void 0;
1146
1155
  }
1147
1156
  }
1148
- function relPathForRuntimeFile(filepath, serviceNode) {
1157
+ function relPathForRuntimeFile(filepath, serviceNode, scanPath) {
1149
1158
  let p = toPosix(filepath).replace(/^file:\/\//, "");
1159
+ if (scanPath && scanPath.length > 0) {
1160
+ const absRoot = toPosix(path3.resolve(scanPath, serviceNode?.repoPath ?? ""));
1161
+ const anchor = absRoot.endsWith("/") ? absRoot : `${absRoot}/`;
1162
+ if (p.startsWith(anchor)) return p.slice(anchor.length);
1163
+ }
1150
1164
  const root = serviceNode?.repoPath;
1151
1165
  if (root && root !== "." && root.length > 0) {
1152
1166
  const rootPosix = toPosix(root);
@@ -1163,16 +1177,65 @@ function relPathForRuntimeFile(filepath, serviceNode) {
1163
1177
  p = p.replace(/^[A-Za-z]:/, "").replace(/^\/+/, "");
1164
1178
  return p.length > 0 ? p : null;
1165
1179
  }
1166
- function callSiteFromSpan(span, serviceNode) {
1180
+ var sourceMapCache = /* @__PURE__ */ new Map();
1181
+ function resolveDistToSrc(absFilepath, line) {
1182
+ if (!absFilepath.endsWith(".js")) return null;
1183
+ let entry = sourceMapCache.get(absFilepath);
1184
+ if (entry === void 0) {
1185
+ entry = null;
1186
+ const mapPath = `${absFilepath}.map`;
1187
+ try {
1188
+ if (existsSync(mapPath)) {
1189
+ const raw = JSON.parse(readFileSync(mapPath, "utf8"));
1190
+ const consumer = new sourceMapJs.SourceMapConsumer(raw);
1191
+ entry = { consumer, dir: path3.dirname(mapPath) };
1192
+ }
1193
+ } catch {
1194
+ entry = null;
1195
+ }
1196
+ sourceMapCache.set(absFilepath, entry);
1197
+ }
1198
+ if (!entry) return null;
1199
+ try {
1200
+ const pos = entry.consumer.originalPositionFor({
1201
+ line: line !== void 0 && Number.isFinite(line) ? line : 1,
1202
+ column: 0
1203
+ });
1204
+ if (!pos || !pos.source) return null;
1205
+ const root = entry.consumer.sourceRoot ?? "";
1206
+ const resolved = path3.resolve(entry.dir, root, pos.source);
1207
+ return { filepath: resolved, ...pos.line ? { line: pos.line } : {} };
1208
+ } catch {
1209
+ return null;
1210
+ }
1211
+ }
1212
+ function callSiteFromSpan(span, serviceNode, scanPath) {
1167
1213
  const filepath = span.attributes[CODE_FILEPATH_ATTR];
1168
1214
  if (typeof filepath !== "string" || filepath.length === 0) return null;
1169
- const relPath = relPathForRuntimeFile(filepath, serviceNode);
1170
- if (!relPath) return null;
1171
1215
  const linenoRaw = span.attributes[CODE_LINENO_ATTR];
1172
- const line = typeof linenoRaw === "number" && Number.isFinite(linenoRaw) ? linenoRaw : void 0;
1216
+ let line = typeof linenoRaw === "number" && Number.isFinite(linenoRaw) ? linenoRaw : void 0;
1217
+ const abs = toPosix(filepath).replace(/^file:\/\//, "");
1218
+ const resolved = resolveDistToSrc(abs, line);
1219
+ let effectivePath = filepath;
1220
+ let originalRelPath;
1221
+ if (resolved) {
1222
+ originalRelPath = relPathForRuntimeFile(filepath, serviceNode, scanPath) ?? void 0;
1223
+ effectivePath = resolved.filepath;
1224
+ if (resolved.line !== void 0) line = resolved.line;
1225
+ }
1226
+ const relPath = relPathForRuntimeFile(effectivePath, serviceNode, scanPath);
1227
+ if (!relPath) return null;
1228
+ if (!resolved && abs.endsWith(".js") && relPath.startsWith("dist/") && serviceNode?.name) {
1229
+ warnNoSourceMaps(serviceNode.name);
1230
+ }
1173
1231
  const fnRaw = span.attributes[CODE_FUNCTION_ATTR];
1174
1232
  const fn = typeof fnRaw === "string" && fnRaw.length > 0 ? fnRaw : void 0;
1175
- return { relPath, ...line !== void 0 ? { line } : {}, ...fn ? { fn } : {} };
1233
+ return {
1234
+ relPath,
1235
+ ...line !== void 0 ? { line } : {},
1236
+ ...fn ? { fn } : {},
1237
+ ...originalRelPath && originalRelPath !== relPath ? { originalRelPath } : {}
1238
+ };
1176
1239
  }
1177
1240
  function ensureObservedFileNode(graph, serviceName, serviceNodeId, callSite) {
1178
1241
  const fileNodeId = fileId(serviceName, callSite.relPath);
@@ -1184,6 +1247,7 @@ function ensureObservedFileNode(graph, serviceName, serviceNodeId, callSite) {
1184
1247
  service: serviceName,
1185
1248
  path: callSite.relPath,
1186
1249
  ...language ? { language } : {},
1250
+ ...callSite.originalRelPath ? { originalPath: callSite.originalRelPath } : {},
1187
1251
  discoveredVia: "otel"
1188
1252
  };
1189
1253
  graph.addNode(fileNodeId, node);
@@ -1209,6 +1273,12 @@ function makeInferredEdgeId(type, source, target) {
1209
1273
  }
1210
1274
  var INFERRED_CONFIDENCE = 0.6;
1211
1275
  var STITCH_MAX_DEPTH = 2;
1276
+ var WIRE_SPAN_KIND_CLIENT = 3;
1277
+ var WIRE_SPAN_KIND_PRODUCER = 4;
1278
+ function spanMintsObservedEdge(kind) {
1279
+ if (kind === void 0 || kind === 0) return true;
1280
+ return kind === WIRE_SPAN_KIND_CLIENT || kind === WIRE_SPAN_KIND_PRODUCER;
1281
+ }
1212
1282
  var PARENT_SPAN_CACHE_SIZE = 1e4;
1213
1283
  var PARENT_SPAN_CACHE_TTL_MS = 5 * 60 * 1e3;
1214
1284
  var parentSpanCache = /* @__PURE__ */ new Map();
@@ -1315,7 +1385,7 @@ function ensureFrontierNode(graph, host, ts) {
1315
1385
  graph.addNode(id, node);
1316
1386
  return id;
1317
1387
  }
1318
- function upsertObservedEdge(graph, type, source, target, ts, isError = false) {
1388
+ function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence) {
1319
1389
  if (!graph.hasNode(source) || !graph.hasNode(target)) return null;
1320
1390
  const id = makeObservedEdgeId(type, source, target);
1321
1391
  if (graph.hasEdge(id)) {
@@ -1352,7 +1422,10 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false) {
1352
1422
  confidence: confidenceForObservedSignal(signal),
1353
1423
  lastObserved: ts,
1354
1424
  callCount: 1,
1355
- signal
1425
+ signal,
1426
+ // Call-site evidence from span code.* semconv (file-awareness.md §4 + §6).
1427
+ // Only set when code.filepath was present on the span — never fabricated.
1428
+ ...evidence ? { evidence } : {}
1356
1429
  };
1357
1430
  graph.addEdgeWithKey(id, source, target, edge);
1358
1431
  return { edge, created: true };
@@ -1444,12 +1517,14 @@ async function handleSpan(ctx, span) {
1444
1517
  const isError = span.statusCode === 2;
1445
1518
  cacheSpanService(span, nowMs);
1446
1519
  const sourceServiceNode = ctx.graph.getNodeAttributes(sourceId);
1447
- const callSite = callSiteFromSpan(span, sourceServiceNode);
1520
+ const callSite = callSiteFromSpan(span, sourceServiceNode, ctx.scanPath);
1448
1521
  const observedSource = () => callSite ? ensureObservedFileNode(ctx.graph, span.service, sourceId, callSite) : sourceId;
1522
+ const callSiteEvidence = callSite ? { file: callSite.relPath, ...callSite.line !== void 0 ? { line: callSite.line } : {} } : void 0;
1449
1523
  let affectedNode = sourceId;
1524
+ const mintsFromCallerSide = spanMintsObservedEdge(span.kind);
1450
1525
  if (span.dbSystem) {
1451
1526
  const host = pickAddress(span);
1452
- if (host) {
1527
+ if (mintsFromCallerSide && host) {
1453
1528
  ensureDatabaseNode(ctx.graph, host, span.dbSystem);
1454
1529
  const targetId = databaseId(host);
1455
1530
  const result = upsertObservedEdge(
@@ -1458,14 +1533,15 @@ async function handleSpan(ctx, span) {
1458
1533
  observedSource(),
1459
1534
  targetId,
1460
1535
  ts,
1461
- isError
1536
+ isError,
1537
+ callSiteEvidence
1462
1538
  );
1463
1539
  if (result) affectedNode = targetId;
1464
1540
  }
1465
1541
  } else {
1466
1542
  const host = pickAddress(span);
1467
1543
  let resolvedViaAddress = false;
1468
- if (host && host !== span.service) {
1544
+ if (mintsFromCallerSide && host && host !== span.service) {
1469
1545
  const targetId = resolveServiceId(ctx.graph, host, env);
1470
1546
  if (targetId && targetId !== sourceId) {
1471
1547
  upsertObservedEdge(
@@ -1474,7 +1550,8 @@ async function handleSpan(ctx, span) {
1474
1550
  observedSource(),
1475
1551
  targetId,
1476
1552
  ts,
1477
- isError
1553
+ isError,
1554
+ callSiteEvidence
1478
1555
  );
1479
1556
  affectedNode = targetId;
1480
1557
  resolvedViaAddress = true;
@@ -1486,7 +1563,8 @@ async function handleSpan(ctx, span) {
1486
1563
  observedSource(),
1487
1564
  frontierNodeId,
1488
1565
  ts,
1489
- isError
1566
+ isError,
1567
+ callSiteEvidence
1490
1568
  );
1491
1569
  affectedNode = frontierNodeId;
1492
1570
  resolvedViaAddress = true;
@@ -2442,19 +2520,122 @@ async function addServiceAliases(graph, scanPath, services) {
2442
2520
  }
2443
2521
 
2444
2522
  // src/extract/databases/index.ts
2445
- import path17 from "path";
2523
+ import path18 from "path";
2524
+ import {
2525
+ EdgeType as EdgeType5,
2526
+ NodeType as NodeType7,
2527
+ Provenance as Provenance3,
2528
+ databaseId as databaseId2,
2529
+ confidenceForExtracted as confidenceForExtracted2
2530
+ } from "@neat.is/types";
2531
+
2532
+ // src/extract/calls/shared.ts
2533
+ import { promises as fs10 } from "fs";
2534
+ import path10 from "path";
2446
2535
  import {
2447
2536
  EdgeType as EdgeType4,
2448
2537
  NodeType as NodeType6,
2449
2538
  Provenance as Provenance2,
2450
- databaseId as databaseId2,
2451
- confidenceForExtracted
2539
+ confidenceForExtracted,
2540
+ extractedEdgeId as extractedEdgeId3,
2541
+ fileId as fileId2
2452
2542
  } from "@neat.is/types";
2543
+ async function walkSourceFiles(dir) {
2544
+ const out = [];
2545
+ async function walk(current) {
2546
+ const entries = await fs10.readdir(current, { withFileTypes: true }).catch(() => []);
2547
+ for (const entry of entries) {
2548
+ const full = path10.join(current, entry.name);
2549
+ if (entry.isDirectory()) {
2550
+ if (IGNORED_DIRS.has(entry.name)) continue;
2551
+ if (await isPythonVenvDir(full)) continue;
2552
+ await walk(full);
2553
+ } else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(path10.extname(entry.name))) {
2554
+ out.push(full);
2555
+ }
2556
+ }
2557
+ }
2558
+ await walk(dir);
2559
+ return out;
2560
+ }
2561
+ async function loadSourceFiles(dir) {
2562
+ const paths = await walkSourceFiles(dir);
2563
+ const out = [];
2564
+ for (const p of paths) {
2565
+ try {
2566
+ const content = await fs10.readFile(p, "utf8");
2567
+ out.push({ path: p, content });
2568
+ } catch {
2569
+ }
2570
+ }
2571
+ return out;
2572
+ }
2573
+ function lineOf(text, needle) {
2574
+ const idx = text.indexOf(needle);
2575
+ if (idx < 0) return 1;
2576
+ return text.slice(0, idx).split("\n").length;
2577
+ }
2578
+ function snippet(text, line) {
2579
+ const lines = text.split("\n");
2580
+ return (lines[line - 1] ?? "").trim();
2581
+ }
2582
+ function toPosix2(p) {
2583
+ return p.split("\\").join("/");
2584
+ }
2585
+ function languageForPath(relPath) {
2586
+ switch (path10.extname(relPath).toLowerCase()) {
2587
+ case ".py":
2588
+ return "python";
2589
+ case ".ts":
2590
+ case ".tsx":
2591
+ return "typescript";
2592
+ case ".js":
2593
+ case ".jsx":
2594
+ case ".mjs":
2595
+ case ".cjs":
2596
+ return "javascript";
2597
+ default:
2598
+ return void 0;
2599
+ }
2600
+ }
2601
+ function ensureFileNode(graph, serviceName, serviceNodeId, relPath) {
2602
+ let nodesAdded = 0;
2603
+ let edgesAdded = 0;
2604
+ const fileNodeId = fileId2(serviceName, relPath);
2605
+ if (!graph.hasNode(fileNodeId)) {
2606
+ const language = languageForPath(relPath);
2607
+ const node = {
2608
+ id: fileNodeId,
2609
+ type: NodeType6.FileNode,
2610
+ service: serviceName,
2611
+ path: relPath,
2612
+ ...language ? { language } : {},
2613
+ discoveredVia: "static"
2614
+ };
2615
+ graph.addNode(fileNodeId, node);
2616
+ nodesAdded++;
2617
+ }
2618
+ const containsId = extractedEdgeId3(serviceNodeId, fileNodeId, EdgeType4.CONTAINS);
2619
+ if (!graph.hasEdge(containsId)) {
2620
+ const edge = {
2621
+ id: containsId,
2622
+ source: serviceNodeId,
2623
+ target: fileNodeId,
2624
+ type: EdgeType4.CONTAINS,
2625
+ provenance: Provenance2.EXTRACTED,
2626
+ confidence: confidenceForExtracted("structural"),
2627
+ evidence: { file: relPath }
2628
+ };
2629
+ graph.addEdgeWithKey(containsId, serviceNodeId, fileNodeId, edge);
2630
+ edgesAdded++;
2631
+ }
2632
+ return { fileNodeId, nodesAdded, edgesAdded };
2633
+ }
2453
2634
 
2454
2635
  // src/extract/databases/db-config-yaml.ts
2455
- import path10 from "path";
2636
+ import path11 from "path";
2456
2637
  async function parse(serviceDir) {
2457
- const yamlPath = path10.join(serviceDir, "db-config.yaml");
2638
+ const yamlPath = path11.join(serviceDir, "db-config.yaml");
2458
2639
  if (!await exists(yamlPath)) return [];
2459
2640
  const raw = await readYaml(yamlPath);
2460
2641
  return [
@@ -2471,12 +2652,12 @@ async function parse(serviceDir) {
2471
2652
  var dbConfigYamlParser = { name: "db-config.yaml", parse };
2472
2653
 
2473
2654
  // src/extract/databases/dotenv.ts
2474
- import { promises as fs11 } from "fs";
2475
- import path12 from "path";
2655
+ import { promises as fs12 } from "fs";
2656
+ import path13 from "path";
2476
2657
 
2477
2658
  // src/extract/databases/shared.ts
2478
- import { promises as fs10 } from "fs";
2479
- import path11 from "path";
2659
+ import { promises as fs11 } from "fs";
2660
+ import path12 from "path";
2480
2661
  function schemeToEngine(scheme) {
2481
2662
  const s = scheme.toLowerCase().split("+")[0];
2482
2663
  switch (s) {
@@ -2515,14 +2696,14 @@ function parseConnectionString(url) {
2515
2696
  }
2516
2697
  async function readIfExists(filePath) {
2517
2698
  try {
2518
- return await fs10.readFile(filePath, "utf8");
2699
+ return await fs11.readFile(filePath, "utf8");
2519
2700
  } catch {
2520
2701
  return null;
2521
2702
  }
2522
2703
  }
2523
2704
  async function findFirst(serviceDir, candidates) {
2524
2705
  for (const rel of candidates) {
2525
- const abs = path11.join(serviceDir, rel);
2706
+ const abs = path12.join(serviceDir, rel);
2526
2707
  const content = await readIfExists(abs);
2527
2708
  if (content !== null) return abs;
2528
2709
  }
@@ -2573,15 +2754,15 @@ function parseDotenvLine(line) {
2573
2754
  return { key, value };
2574
2755
  }
2575
2756
  async function parse2(serviceDir) {
2576
- const entries = await fs11.readdir(serviceDir, { withFileTypes: true }).catch(() => []);
2757
+ const entries = await fs12.readdir(serviceDir, { withFileTypes: true }).catch(() => []);
2577
2758
  const configs = [];
2578
2759
  const seen = /* @__PURE__ */ new Set();
2579
2760
  for (const entry of entries) {
2580
2761
  if (!entry.isFile()) continue;
2581
2762
  const match = isConfigFile(entry.name);
2582
2763
  if (!match.match || match.fileType !== "env") continue;
2583
- const filePath = path12.join(serviceDir, entry.name);
2584
- const content = await fs11.readFile(filePath, "utf8");
2764
+ const filePath = path13.join(serviceDir, entry.name);
2765
+ const content = await fs12.readFile(filePath, "utf8");
2585
2766
  for (const line of content.split("\n")) {
2586
2767
  const parsed = parseDotenvLine(line);
2587
2768
  if (!parsed) continue;
@@ -2599,9 +2780,9 @@ async function parse2(serviceDir) {
2599
2780
  var dotenvParser = { name: ".env", parse: parse2 };
2600
2781
 
2601
2782
  // src/extract/databases/prisma.ts
2602
- import path13 from "path";
2783
+ import path14 from "path";
2603
2784
  async function parse3(serviceDir) {
2604
- const schemaPath = path13.join(serviceDir, "prisma", "schema.prisma");
2785
+ const schemaPath = path14.join(serviceDir, "prisma", "schema.prisma");
2605
2786
  const content = await readIfExists(schemaPath);
2606
2787
  if (!content) return [];
2607
2788
  const block = content.match(/datasource\s+\w+\s*\{([^}]*)\}/s);
@@ -2730,10 +2911,10 @@ async function parse5(serviceDir) {
2730
2911
  var knexParser = { name: "knex", parse: parse5 };
2731
2912
 
2732
2913
  // src/extract/databases/ormconfig.ts
2733
- import path14 from "path";
2914
+ import path15 from "path";
2734
2915
  async function parse6(serviceDir) {
2735
2916
  for (const candidate of ["ormconfig.json", "ormconfig.yaml", "ormconfig.yml"]) {
2736
- const abs = path14.join(serviceDir, candidate);
2917
+ const abs = path15.join(serviceDir, candidate);
2737
2918
  if (!await exists(abs)) continue;
2738
2919
  const raw = candidate.endsWith(".json") ? await readJson(abs) : await readYaml(abs);
2739
2920
  const entries = Array.isArray(raw) ? raw : [raw];
@@ -2791,9 +2972,9 @@ async function parse7(serviceDir) {
2791
2972
  var typeormParser = { name: "typeorm", parse: parse7 };
2792
2973
 
2793
2974
  // src/extract/databases/sequelize.ts
2794
- import path15 from "path";
2975
+ import path16 from "path";
2795
2976
  async function parse8(serviceDir) {
2796
- const configPath = path15.join(serviceDir, "config", "config.json");
2977
+ const configPath = path16.join(serviceDir, "config", "config.json");
2797
2978
  if (!await exists(configPath)) return [];
2798
2979
  const raw = await readJson(configPath);
2799
2980
  const out = [];
@@ -2819,7 +3000,7 @@ async function parse8(serviceDir) {
2819
3000
  var sequelizeParser = { name: "sequelize", parse: parse8 };
2820
3001
 
2821
3002
  // src/extract/databases/docker-compose.ts
2822
- import path16 from "path";
3003
+ import path17 from "path";
2823
3004
  function portFromService(svc) {
2824
3005
  for (const raw of svc.ports ?? []) {
2825
3006
  const str = String(raw);
@@ -2846,7 +3027,7 @@ function databaseFromEnv(svc) {
2846
3027
  }
2847
3028
  async function parse9(serviceDir) {
2848
3029
  for (const name of ["docker-compose.yml", "docker-compose.yaml"]) {
2849
- const abs = path16.join(serviceDir, name);
3030
+ const abs = path17.join(serviceDir, name);
2850
3031
  if (!await exists(abs)) continue;
2851
3032
  const raw = await readYaml(abs);
2852
3033
  if (!raw?.services) return [];
@@ -2888,7 +3069,7 @@ function compatibleDriversFor(engine) {
2888
3069
  function toDatabaseNode(config) {
2889
3070
  return {
2890
3071
  id: databaseId2(config.host),
2891
- type: NodeType6.DatabaseNode,
3072
+ type: NodeType7.DatabaseNode,
2892
3073
  name: config.database || config.host,
2893
3074
  engine: config.engine,
2894
3075
  engineVersion: config.engineVersion,
@@ -3018,19 +3199,24 @@ async function addDatabasesAndCompat(graph, services, scanPath) {
3018
3199
  discoveredVia: mergedDiscoveredVia
3019
3200
  });
3020
3201
  }
3202
+ const relConfigFile = toPosix2(path18.relative(service.dir, config.sourceFile));
3203
+ const { fileNodeId, nodesAdded: fn, edgesAdded: fe } = ensureFileNode(
3204
+ graph,
3205
+ service.pkg.name,
3206
+ service.node.id,
3207
+ relConfigFile
3208
+ );
3209
+ nodesAdded += fn;
3210
+ edgesAdded += fe;
3211
+ const evidenceFile = toPosix2(path18.relative(scanPath, config.sourceFile));
3021
3212
  const edge = {
3022
- id: extractedEdgeId2(service.node.id, dbNode.id, EdgeType4.CONNECTS_TO),
3023
- source: service.node.id,
3213
+ id: extractedEdgeId2(fileNodeId, dbNode.id, EdgeType5.CONNECTS_TO),
3214
+ source: fileNodeId,
3024
3215
  target: dbNode.id,
3025
- type: EdgeType4.CONNECTS_TO,
3026
- provenance: Provenance2.EXTRACTED,
3027
- confidence: confidenceForExtracted("structural"),
3028
- // ADR-032 / #140 — every EXTRACTED edge carries evidence.file.
3029
- // Ghost-edge cleanup keys retirement on this; the conditional
3030
- // sourceFile spread that used to live here was a v0.1.x leftover.
3031
- evidence: {
3032
- file: path17.relative(scanPath, config.sourceFile).split(path17.sep).join("/")
3033
- }
3216
+ type: EdgeType5.CONNECTS_TO,
3217
+ provenance: Provenance3.EXTRACTED,
3218
+ confidence: confidenceForExtracted2("structural"),
3219
+ evidence: { file: evidenceFile }
3034
3220
  };
3035
3221
  if (!graph.hasEdge(edge.id)) {
3036
3222
  graph.addEdgeWithKey(edge.id, edge.source, edge.target, edge);
@@ -3055,21 +3241,21 @@ async function addDatabasesAndCompat(graph, services, scanPath) {
3055
3241
  }
3056
3242
 
3057
3243
  // src/extract/configs.ts
3058
- import { promises as fs12 } from "fs";
3059
- import path18 from "path";
3244
+ import { promises as fs13 } from "fs";
3245
+ import path19 from "path";
3060
3246
  import {
3061
- EdgeType as EdgeType5,
3062
- NodeType as NodeType7,
3063
- Provenance as Provenance3,
3247
+ EdgeType as EdgeType6,
3248
+ NodeType as NodeType8,
3249
+ Provenance as Provenance4,
3064
3250
  configId,
3065
- confidenceForExtracted as confidenceForExtracted2
3251
+ confidenceForExtracted as confidenceForExtracted3
3066
3252
  } from "@neat.is/types";
3067
3253
  async function walkConfigFiles(dir) {
3068
3254
  const out = [];
3069
3255
  async function walk(current) {
3070
- const entries = await fs12.readdir(current, { withFileTypes: true });
3256
+ const entries = await fs13.readdir(current, { withFileTypes: true });
3071
3257
  for (const entry of entries) {
3072
- const full = path18.join(current, entry.name);
3258
+ const full = path19.join(current, entry.name);
3073
3259
  if (entry.isDirectory()) {
3074
3260
  if (IGNORED_DIRS.has(entry.name)) continue;
3075
3261
  if (await isPythonVenvDir(full)) continue;
@@ -3088,26 +3274,35 @@ async function addConfigNodes(graph, services, scanPath) {
3088
3274
  for (const service of services) {
3089
3275
  const configFiles = await walkConfigFiles(service.dir);
3090
3276
  for (const file of configFiles) {
3091
- const relPath = path18.relative(scanPath, file);
3277
+ const relPath = path19.relative(scanPath, file);
3092
3278
  const node = {
3093
3279
  id: configId(relPath),
3094
- type: NodeType7.ConfigNode,
3095
- name: path18.basename(file),
3280
+ type: NodeType8.ConfigNode,
3281
+ name: path19.basename(file),
3096
3282
  path: relPath,
3097
- fileType: isConfigFile(path18.basename(file)).fileType
3283
+ fileType: isConfigFile(path19.basename(file)).fileType
3098
3284
  };
3099
3285
  if (!graph.hasNode(node.id)) {
3100
3286
  graph.addNode(node.id, node);
3101
3287
  nodesAdded++;
3102
3288
  }
3289
+ const relToService = toPosix2(path19.relative(service.dir, file));
3290
+ const { fileNodeId, nodesAdded: fn, edgesAdded: fe } = ensureFileNode(
3291
+ graph,
3292
+ service.pkg.name,
3293
+ service.node.id,
3294
+ relToService
3295
+ );
3296
+ nodesAdded += fn;
3297
+ edgesAdded += fe;
3103
3298
  const edge = {
3104
- id: extractedEdgeId2(service.node.id, node.id, EdgeType5.CONFIGURED_BY),
3105
- source: service.node.id,
3299
+ id: extractedEdgeId2(fileNodeId, node.id, EdgeType6.CONFIGURED_BY),
3300
+ source: fileNodeId,
3106
3301
  target: node.id,
3107
- type: EdgeType5.CONFIGURED_BY,
3108
- provenance: Provenance3.EXTRACTED,
3109
- confidence: confidenceForExtracted2("structural"),
3110
- evidence: { file: relPath.split(path18.sep).join("/") }
3302
+ type: EdgeType6.CONFIGURED_BY,
3303
+ provenance: Provenance4.EXTRACTED,
3304
+ confidence: confidenceForExtracted3("structural"),
3305
+ evidence: { file: relPath.split(path19.sep).join("/") }
3111
3306
  };
3112
3307
  if (!graph.hasEdge(edge.id)) {
3113
3308
  graph.addEdgeWithKey(edge.id, edge.source, edge.target, edge);
@@ -3138,111 +3333,6 @@ import {
3138
3333
  confidenceForExtracted as confidenceForExtracted4,
3139
3334
  passesExtractedFloor
3140
3335
  } from "@neat.is/types";
3141
-
3142
- // src/extract/calls/shared.ts
3143
- import { promises as fs13 } from "fs";
3144
- import path19 from "path";
3145
- import {
3146
- EdgeType as EdgeType6,
3147
- NodeType as NodeType8,
3148
- Provenance as Provenance4,
3149
- confidenceForExtracted as confidenceForExtracted3,
3150
- extractedEdgeId as extractedEdgeId3,
3151
- fileId as fileId2
3152
- } from "@neat.is/types";
3153
- async function walkSourceFiles(dir) {
3154
- const out = [];
3155
- async function walk(current) {
3156
- const entries = await fs13.readdir(current, { withFileTypes: true }).catch(() => []);
3157
- for (const entry of entries) {
3158
- const full = path19.join(current, entry.name);
3159
- if (entry.isDirectory()) {
3160
- if (IGNORED_DIRS.has(entry.name)) continue;
3161
- if (await isPythonVenvDir(full)) continue;
3162
- await walk(full);
3163
- } else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(path19.extname(entry.name))) {
3164
- out.push(full);
3165
- }
3166
- }
3167
- }
3168
- await walk(dir);
3169
- return out;
3170
- }
3171
- async function loadSourceFiles(dir) {
3172
- const paths = await walkSourceFiles(dir);
3173
- const out = [];
3174
- for (const p of paths) {
3175
- try {
3176
- const content = await fs13.readFile(p, "utf8");
3177
- out.push({ path: p, content });
3178
- } catch {
3179
- }
3180
- }
3181
- return out;
3182
- }
3183
- function lineOf(text, needle) {
3184
- const idx = text.indexOf(needle);
3185
- if (idx < 0) return 1;
3186
- return text.slice(0, idx).split("\n").length;
3187
- }
3188
- function snippet(text, line) {
3189
- const lines = text.split("\n");
3190
- return (lines[line - 1] ?? "").trim();
3191
- }
3192
- function toPosix2(p) {
3193
- return p.split("\\").join("/");
3194
- }
3195
- function languageForPath(relPath) {
3196
- switch (path19.extname(relPath).toLowerCase()) {
3197
- case ".py":
3198
- return "python";
3199
- case ".ts":
3200
- case ".tsx":
3201
- return "typescript";
3202
- case ".js":
3203
- case ".jsx":
3204
- case ".mjs":
3205
- case ".cjs":
3206
- return "javascript";
3207
- default:
3208
- return void 0;
3209
- }
3210
- }
3211
- function ensureFileNode(graph, serviceName, serviceNodeId, relPath) {
3212
- let nodesAdded = 0;
3213
- let edgesAdded = 0;
3214
- const fileNodeId = fileId2(serviceName, relPath);
3215
- if (!graph.hasNode(fileNodeId)) {
3216
- const language = languageForPath(relPath);
3217
- const node = {
3218
- id: fileNodeId,
3219
- type: NodeType8.FileNode,
3220
- service: serviceName,
3221
- path: relPath,
3222
- ...language ? { language } : {},
3223
- discoveredVia: "static"
3224
- };
3225
- graph.addNode(fileNodeId, node);
3226
- nodesAdded++;
3227
- }
3228
- const containsId = extractedEdgeId3(serviceNodeId, fileNodeId, EdgeType6.CONTAINS);
3229
- if (!graph.hasEdge(containsId)) {
3230
- const edge = {
3231
- id: containsId,
3232
- source: serviceNodeId,
3233
- target: fileNodeId,
3234
- type: EdgeType6.CONTAINS,
3235
- provenance: Provenance4.EXTRACTED,
3236
- confidence: confidenceForExtracted3("structural"),
3237
- evidence: { file: relPath }
3238
- };
3239
- graph.addEdgeWithKey(containsId, serviceNodeId, fileNodeId, edge);
3240
- edgesAdded++;
3241
- }
3242
- return { fileNodeId, nodesAdded, edgesAdded };
3243
- }
3244
-
3245
- // src/extract/calls/http.ts
3246
3336
  var STRING_LITERAL_NODE_TYPES = /* @__PURE__ */ new Set(["string_fragment", "string_content"]);
3247
3337
  var JSX_EXTERNAL_LINK_TAGS = /* @__PURE__ */ new Set(["a", "Link", "NavLink", "ExternalLink", "Anchor"]);
3248
3338
  function isInsideJsxExternalLink(node) {
@@ -3270,8 +3360,14 @@ function collectStringLiterals(node, out) {
3270
3360
  if (child) collectStringLiterals(child, out);
3271
3361
  }
3272
3362
  }
3363
+ var PARSE_CHUNK = 16384;
3364
+ function parseSource(parser, source) {
3365
+ return parser.parse(
3366
+ (index) => index >= source.length ? "" : source.slice(index, index + PARSE_CHUNK)
3367
+ );
3368
+ }
3273
3369
  function callsFromSource(source, parser, knownHosts) {
3274
- const tree = parser.parse(source);
3370
+ const tree = parseSource(parser, source);
3275
3371
  const literals = [];
3276
3372
  collectStringLiterals(tree.rootNode, literals);
3277
3373
  const out = [];
@@ -3516,6 +3612,7 @@ var AWS_SDK_IMPORT_RE = /(?:from\s+['"`]|require\(\s*['"`])@aws-sdk\/client-([a-
3516
3612
  var GRPC_IMPORT_RE = /(?:from\s+['"`]|require\(\s*['"`])@grpc\/grpc-js['"`]|_grpc_pb['"`]/;
3517
3613
  function isLikelyAddress(value) {
3518
3614
  if (!value) return false;
3615
+ if (/\s/.test(value) || value.startsWith("{")) return false;
3519
3616
  return /:\d{2,5}$/.test(value) || value.includes(".");
3520
3617
  }
3521
3618
  function normaliseForMatch(s) {
@@ -3821,17 +3918,26 @@ async function addDockerfileRuntimes(graph, services, scanPath) {
3821
3918
  graph.addNode(node.id, node);
3822
3919
  nodesAdded++;
3823
3920
  }
3824
- const edgeId = extractedEdgeId2(service.node.id, node.id, EdgeType10.RUNS_ON);
3921
+ const relDockerfile = toPosix2(path26.relative(service.dir, dockerfilePath));
3922
+ const { fileNodeId, nodesAdded: fn, edgesAdded: fe } = ensureFileNode(
3923
+ graph,
3924
+ service.pkg.name,
3925
+ service.node.id,
3926
+ relDockerfile
3927
+ );
3928
+ nodesAdded += fn;
3929
+ edgesAdded += fe;
3930
+ const edgeId = extractedEdgeId2(fileNodeId, node.id, EdgeType10.RUNS_ON);
3825
3931
  if (!graph.hasEdge(edgeId)) {
3826
3932
  const edge = {
3827
3933
  id: edgeId,
3828
- source: service.node.id,
3934
+ source: fileNodeId,
3829
3935
  target: node.id,
3830
3936
  type: EdgeType10.RUNS_ON,
3831
3937
  provenance: Provenance8.EXTRACTED,
3832
3938
  confidence: confidenceForExtracted7("structural"),
3833
3939
  evidence: {
3834
- file: path26.relative(scanPath, dockerfilePath).split(path26.sep).join("/")
3940
+ file: toPosix2(path26.relative(scanPath, dockerfilePath))
3835
3941
  }
3836
3942
  };
3837
3943
  graph.addEdgeWithKey(edgeId, edge.source, edge.target, edge);
@@ -3952,7 +4058,7 @@ async function addInfra(graph, scanPath, services) {
3952
4058
  import path30 from "path";
3953
4059
 
3954
4060
  // src/extract/retire.ts
3955
- import { existsSync } from "fs";
4061
+ import { existsSync as existsSync2 } from "fs";
3956
4062
  import path29 from "path";
3957
4063
  import { NodeType as NodeType11, Provenance as Provenance9 } from "@neat.is/types";
3958
4064
  function dropOrphanedFileNodes(graph) {
@@ -3988,10 +4094,10 @@ function retireExtractedEdgesByMissingFile(graph, scanPath, serviceDirs = []) {
3988
4094
  const evidenceFile = edge.evidence?.file;
3989
4095
  if (!evidenceFile) return;
3990
4096
  if (path29.isAbsolute(evidenceFile)) {
3991
- if (!existsSync(evidenceFile)) toDrop.push(id);
4097
+ if (!existsSync2(evidenceFile)) toDrop.push(id);
3992
4098
  return;
3993
4099
  }
3994
- const found = bases.some((base) => existsSync(path29.join(base, evidenceFile)));
4100
+ const found = bases.some((base) => existsSync2(path29.join(base, evidenceFile)));
3995
4101
  if (!found) toDrop.push(id);
3996
4102
  });
3997
4103
  for (const id of toDrop) graph.dropEdge(id);
@@ -4567,6 +4673,66 @@ function registryPath() {
4567
4673
  function registryLockPath() {
4568
4674
  return path33.join(neatHome(), "projects.json.lock");
4569
4675
  }
4676
+ function daemonPidPath() {
4677
+ return path33.join(neatHome(), "neatd.pid");
4678
+ }
4679
+ function isPidAliveDefault(pid) {
4680
+ try {
4681
+ process.kill(pid, 0);
4682
+ return true;
4683
+ } catch (err) {
4684
+ return err.code === "EPERM";
4685
+ }
4686
+ }
4687
+ async function readPidFile(file) {
4688
+ try {
4689
+ const raw = await fs19.readFile(file, "utf8");
4690
+ const pid = Number.parseInt(raw.trim(), 10);
4691
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
4692
+ } catch {
4693
+ return void 0;
4694
+ }
4695
+ }
4696
+ var defaultLockHolderProbe = {
4697
+ isPidAlive: isPidAliveDefault,
4698
+ daemonPidFromFile: () => readPidFile(daemonPidPath()),
4699
+ async daemonResponds() {
4700
+ const base = process.env.NEAT_API_URL ?? "http://localhost:8080";
4701
+ try {
4702
+ await fetch(`${base}/health`, { signal: AbortSignal.timeout(750) });
4703
+ return true;
4704
+ } catch {
4705
+ return false;
4706
+ }
4707
+ }
4708
+ };
4709
+ async function readLockPid(lockPath) {
4710
+ return readPidFile(lockPath);
4711
+ }
4712
+ async function classifyLockHolder(lockPath, probe = defaultLockHolderProbe) {
4713
+ const lockPid = await readLockPid(lockPath);
4714
+ const daemonPid = await probe.daemonPidFromFile();
4715
+ if (daemonPid !== void 0 && daemonPid !== process.pid && probe.isPidAlive(daemonPid) && // The lock already names the daemon, or the daemon answers on its port.
4716
+ // Either confirms a live daemon is in the picture (the second guards
4717
+ // against a stale pidfile whose PID got reused).
4718
+ (daemonPid === lockPid || await probe.daemonResponds())) {
4719
+ return { kind: "daemon", pid: daemonPid };
4720
+ }
4721
+ if (lockPid !== void 0 && lockPid !== process.pid && probe.isPidAlive(lockPid)) {
4722
+ return { kind: "command", pid: lockPid };
4723
+ }
4724
+ return { kind: "stale" };
4725
+ }
4726
+ function lockHolderMessage(holder, lockPath, timeoutMs) {
4727
+ switch (holder.kind) {
4728
+ case "daemon":
4729
+ return `The neat daemon (pid ${holder.pid}) is holding the registry lock. Register this project through the daemon, or stop neatd and re-run \`neat init\`.`;
4730
+ case "command":
4731
+ return `Another neat command (pid ${holder.pid}) is holding the registry lock. Wait for it to finish, or check \`ps\` if you're not sure what's running.`;
4732
+ case "stale":
4733
+ return `neat registry: timed out after ${timeoutMs}ms waiting for ${lockPath}. Another neat process is holding the lock; if no such process exists, remove the file by hand.`;
4734
+ }
4735
+ }
4570
4736
  async function normalizeProjectPath(input) {
4571
4737
  const resolved = path33.resolve(input);
4572
4738
  try {
@@ -4587,21 +4753,31 @@ async function writeAtomically(target, contents) {
4587
4753
  }
4588
4754
  await fs19.rename(tmp, target);
4589
4755
  }
4590
- async function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
4756
+ async function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS, probe = defaultLockHolderProbe) {
4591
4757
  const deadline = Date.now() + timeoutMs;
4592
4758
  await fs19.mkdir(path33.dirname(lockPath), { recursive: true });
4759
+ let probedHolder = false;
4593
4760
  while (true) {
4594
4761
  try {
4595
4762
  const fd = await fs19.open(lockPath, "wx");
4596
- await fd.close();
4763
+ try {
4764
+ await fd.writeFile(`${process.pid}
4765
+ `, "utf8");
4766
+ } finally {
4767
+ await fd.close();
4768
+ }
4597
4769
  return;
4598
4770
  } catch (err) {
4599
4771
  const code = err.code;
4600
4772
  if (code !== "EEXIST") throw err;
4773
+ if (!probedHolder) {
4774
+ probedHolder = true;
4775
+ const holder = await classifyLockHolder(lockPath, probe);
4776
+ if (holder.kind === "daemon") throw new Error(lockHolderMessage(holder, lockPath, timeoutMs));
4777
+ }
4601
4778
  if (Date.now() >= deadline) {
4602
- throw new Error(
4603
- `neat registry: timed out after ${timeoutMs}ms waiting for ${lockPath}. Another neat process is holding the lock; if no such process exists, remove the file by hand.`
4604
- );
4779
+ const holder = await classifyLockHolder(lockPath, probe);
4780
+ throw new Error(lockHolderMessage(holder, lockPath, timeoutMs));
4605
4781
  }
4606
4782
  await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
4607
4783
  }
@@ -4721,6 +4897,319 @@ import Fastify from "fastify";
4721
4897
  import cors from "@fastify/cors";
4722
4898
  import { DivergenceTypeSchema, PoliciesCheckBodySchema, PolicySeveritySchema } from "@neat.is/types";
4723
4899
 
4900
+ // src/extend/index.ts
4901
+ import { promises as fs21 } from "fs";
4902
+ import path35 from "path";
4903
+ import os3 from "os";
4904
+ import { resolve as registryResolve, list as registryList } from "@neat.is/instrumentation-registry";
4905
+
4906
+ // src/installers/package-manager.ts
4907
+ import { promises as fs20 } from "fs";
4908
+ import path34 from "path";
4909
+ import { spawn } from "child_process";
4910
+ var LOCKFILE_PRIORITY = [
4911
+ { lockfile: "bun.lockb", pm: "bun", args: ["install", "--no-summary"] },
4912
+ { lockfile: "pnpm-lock.yaml", pm: "pnpm", args: ["install", "--no-summary"] },
4913
+ { lockfile: "yarn.lock", pm: "yarn", args: ["install", "--silent"] },
4914
+ {
4915
+ lockfile: "package-lock.json",
4916
+ pm: "npm",
4917
+ args: ["install", "--no-audit", "--no-fund", "--prefer-offline"]
4918
+ }
4919
+ ];
4920
+ var NPM_FALLBACK_ARGS = ["install", "--no-audit", "--no-fund", "--prefer-offline"];
4921
+ async function exists2(p) {
4922
+ try {
4923
+ await fs20.access(p);
4924
+ return true;
4925
+ } catch {
4926
+ return false;
4927
+ }
4928
+ }
4929
+ async function detectPackageManager(serviceDir) {
4930
+ let dir = path34.resolve(serviceDir);
4931
+ const stops = /* @__PURE__ */ new Set();
4932
+ for (let i = 0; i < 64; i++) {
4933
+ if (stops.has(dir)) break;
4934
+ stops.add(dir);
4935
+ for (const candidate of LOCKFILE_PRIORITY) {
4936
+ const lockPath = path34.join(dir, candidate.lockfile);
4937
+ if (await exists2(lockPath)) {
4938
+ return { pm: candidate.pm, cwd: dir, args: [...candidate.args] };
4939
+ }
4940
+ }
4941
+ const parent = path34.dirname(dir);
4942
+ if (parent === dir) break;
4943
+ dir = parent;
4944
+ }
4945
+ return { pm: "npm", cwd: path34.resolve(serviceDir), args: [...NPM_FALLBACK_ARGS] };
4946
+ }
4947
+ async function runPackageManagerInstall(cmd) {
4948
+ return new Promise((resolve) => {
4949
+ const child = spawn(cmd.pm, cmd.args, {
4950
+ cwd: cmd.cwd,
4951
+ // Inherit PATH + HOME so the user's installed managers resolve.
4952
+ env: process.env,
4953
+ // `false` keeps the parent in control of cleanup if the orchestrator
4954
+ // exits before install finishes. Cross-platform-safe.
4955
+ shell: false,
4956
+ stdio: ["ignore", "ignore", "pipe"]
4957
+ });
4958
+ let stderr = "";
4959
+ child.stderr?.on("data", (chunk) => {
4960
+ stderr += chunk.toString("utf8");
4961
+ });
4962
+ child.on("error", (err) => {
4963
+ resolve({
4964
+ pm: cmd.pm,
4965
+ cwd: cmd.cwd,
4966
+ args: cmd.args,
4967
+ exitCode: 127,
4968
+ stderr: stderr + `
4969
+ ${err.message}`
4970
+ });
4971
+ });
4972
+ child.on("close", (code) => {
4973
+ resolve({
4974
+ pm: cmd.pm,
4975
+ cwd: cmd.cwd,
4976
+ args: cmd.args,
4977
+ exitCode: code ?? 1,
4978
+ stderr: stderr.trim()
4979
+ });
4980
+ });
4981
+ });
4982
+ }
4983
+
4984
+ // src/extend/index.ts
4985
+ async function fileExists(p) {
4986
+ try {
4987
+ await fs21.access(p);
4988
+ return true;
4989
+ } catch {
4990
+ return false;
4991
+ }
4992
+ }
4993
+ async function readPackageJson(scanPath) {
4994
+ const pkgPath = path35.join(scanPath, "package.json");
4995
+ const raw = await fs21.readFile(pkgPath, "utf8");
4996
+ return JSON.parse(raw);
4997
+ }
4998
+ async function findHookFiles(scanPath) {
4999
+ const entries = await fs21.readdir(scanPath);
5000
+ return entries.filter(
5001
+ (e) => (e.startsWith("instrumentation") || e.startsWith("otel-init")) && /\.(ts|js)$/.test(e)
5002
+ ).sort();
5003
+ }
5004
+ function extendLogPath() {
5005
+ return process.env.NEAT_EXTEND_LOG ?? path35.join(os3.homedir(), ".neat", "extend-log.ndjson");
5006
+ }
5007
+ async function appendExtendLog(entry) {
5008
+ const logPath = extendLogPath();
5009
+ await fs21.mkdir(path35.dirname(logPath), { recursive: true });
5010
+ await fs21.appendFile(logPath, JSON.stringify(entry) + "\n", "utf8");
5011
+ }
5012
+ function splicedContent(fileContent, snippet2) {
5013
+ if (fileContent.includes("__INSTRUMENTATION_BLOCK__")) {
5014
+ return fileContent.replace("__INSTRUMENTATION_BLOCK__", `${snippet2}
5015
+ __INSTRUMENTATION_BLOCK__`);
5016
+ }
5017
+ const lines = fileContent.split("\n");
5018
+ let lastPushIdx = -1;
5019
+ for (let i = 0; i < lines.length; i++) {
5020
+ if (lines[i].includes("instrumentations.push(")) lastPushIdx = i;
5021
+ }
5022
+ if (lastPushIdx >= 0) {
5023
+ lines.splice(lastPushIdx + 1, 0, snippet2);
5024
+ return lines.join("\n");
5025
+ }
5026
+ for (let i = 0; i < lines.length; i++) {
5027
+ if (lines[i].includes("new NodeSDK(")) {
5028
+ lines.splice(i, 0, snippet2);
5029
+ return lines.join("\n");
5030
+ }
5031
+ }
5032
+ return null;
5033
+ }
5034
+ async function listUninstrumented(ctx) {
5035
+ const pkg = await readPackageJson(ctx.scanPath);
5036
+ const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
5037
+ const results = [];
5038
+ for (const [library, installedVersion] of Object.entries(allDeps)) {
5039
+ const entry = registryResolve(library, installedVersion);
5040
+ if (!entry) continue;
5041
+ if (entry.coverage === "bundled" || entry.coverage === "http-only") continue;
5042
+ results.push({
5043
+ library,
5044
+ coverage: entry.coverage,
5045
+ installedVersion,
5046
+ instrumentation_package: entry.instrumentation_package,
5047
+ package_version: entry.package_version,
5048
+ registration: entry.registration,
5049
+ notes: entry.notes
5050
+ });
5051
+ }
5052
+ return results;
5053
+ }
5054
+ function lookupInstrumentation(library, installedVersion) {
5055
+ const entry = registryResolve(library, installedVersion);
5056
+ if (!entry) return null;
5057
+ return {
5058
+ library: entry.library,
5059
+ coverage: entry.coverage,
5060
+ instrumentation_package: entry.instrumentation_package,
5061
+ package_version: entry.package_version,
5062
+ registration: entry.registration,
5063
+ notes: entry.notes
5064
+ };
5065
+ }
5066
+ async function describeProjectInstrumentation(ctx) {
5067
+ const hookFiles = await findHookFiles(ctx.scanPath);
5068
+ const envNeat = await fileExists(path35.join(ctx.scanPath, ".env.neat"));
5069
+ const registryInstrPackages = new Set(
5070
+ registryList().map((e) => e.instrumentation_package).filter((p) => !!p)
5071
+ );
5072
+ const pkg = await readPackageJson(ctx.scanPath);
5073
+ const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
5074
+ const installedDeps = {};
5075
+ for (const [key, version] of Object.entries(allDeps)) {
5076
+ if (key.startsWith("@opentelemetry/") || registryInstrPackages.has(key)) {
5077
+ installedDeps[key] = version;
5078
+ }
5079
+ }
5080
+ return { hookFiles, envNeat, installedDeps };
5081
+ }
5082
+ async function applyExtension(ctx, args, options) {
5083
+ const hookFiles = await findHookFiles(ctx.scanPath);
5084
+ if (hookFiles.length === 0) {
5085
+ throw new Error(
5086
+ `No instrumentation hook files found in ${ctx.scanPath}. Run \`neat init\` first.`
5087
+ );
5088
+ }
5089
+ for (const file of hookFiles) {
5090
+ const content = await fs21.readFile(path35.join(ctx.scanPath, file), "utf8");
5091
+ if (content.includes(args.registration_snippet)) {
5092
+ return { library: args.library, filesTouched: [], depsAdded: [], installOutput: "", alreadyApplied: true };
5093
+ }
5094
+ }
5095
+ const primaryFile = hookFiles[0];
5096
+ const primaryPath = path35.join(ctx.scanPath, primaryFile);
5097
+ const filesTouched = [];
5098
+ const depsAdded = [];
5099
+ const pkgPath = path35.join(ctx.scanPath, "package.json");
5100
+ const pkg = await readPackageJson(ctx.scanPath);
5101
+ if (!(pkg.dependencies ?? {})[args.instrumentation_package]) {
5102
+ pkg.dependencies = { ...pkg.dependencies ?? {}, [args.instrumentation_package]: args.version };
5103
+ await fs21.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
5104
+ filesTouched.push("package.json");
5105
+ depsAdded.push(`${args.instrumentation_package}@${args.version}`);
5106
+ }
5107
+ const hookContent = await fs21.readFile(primaryPath, "utf8");
5108
+ const patched = splicedContent(hookContent, args.registration_snippet);
5109
+ if (!patched) {
5110
+ throw new Error(
5111
+ `Could not find instrumentation insertion point in ${primaryFile}. Expected __INSTRUMENTATION_BLOCK__, instrumentations.push(, or new NodeSDK(.`
5112
+ );
5113
+ }
5114
+ await fs21.writeFile(primaryPath, patched, "utf8");
5115
+ filesTouched.push(primaryFile);
5116
+ const cmd = await detectPackageManager(ctx.scanPath);
5117
+ const installer = options?.runInstall ?? runPackageManagerInstall;
5118
+ const install = await installer(cmd);
5119
+ const installOutput = install.exitCode === 0 ? `${cmd.pm} install succeeded` : install.stderr || `${cmd.pm} install failed (exit ${install.exitCode})`;
5120
+ await appendExtendLog({
5121
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5122
+ project: ctx.project,
5123
+ library: args.library,
5124
+ instrumentation_package: args.instrumentation_package,
5125
+ version: args.version,
5126
+ registration_snippet: args.registration_snippet,
5127
+ filesTouched,
5128
+ depsAdded,
5129
+ installOutput
5130
+ });
5131
+ return { library: args.library, filesTouched, depsAdded, installOutput, alreadyApplied: false };
5132
+ }
5133
+ async function dryRunExtension(ctx, args) {
5134
+ const hookFiles = await findHookFiles(ctx.scanPath);
5135
+ if (hookFiles.length === 0) {
5136
+ return {
5137
+ library: args.library,
5138
+ filesTouched: [],
5139
+ depsToAdd: [],
5140
+ packageJsonPatch: {},
5141
+ templatePatch: "No hook files found. Run 'neat init' first."
5142
+ };
5143
+ }
5144
+ for (const file of hookFiles) {
5145
+ const content = await fs21.readFile(path35.join(ctx.scanPath, file), "utf8");
5146
+ if (content.includes(args.registration_snippet)) {
5147
+ return {
5148
+ library: args.library,
5149
+ filesTouched: [],
5150
+ depsToAdd: [],
5151
+ packageJsonPatch: {},
5152
+ templatePatch: "Already applied \u2014 no changes would be made."
5153
+ };
5154
+ }
5155
+ }
5156
+ const primaryFile = hookFiles[0];
5157
+ const filesTouched = [];
5158
+ const depsToAdd = [];
5159
+ let packageJsonPatch = {};
5160
+ let templatePatch = "";
5161
+ const pkg = await readPackageJson(ctx.scanPath);
5162
+ if (!(pkg.dependencies ?? {})[args.instrumentation_package]) {
5163
+ packageJsonPatch = { dependencies: { [args.instrumentation_package]: args.version } };
5164
+ depsToAdd.push(`${args.instrumentation_package}@${args.version}`);
5165
+ filesTouched.push("package.json");
5166
+ }
5167
+ const hookContent = await fs21.readFile(path35.join(ctx.scanPath, primaryFile), "utf8");
5168
+ const patched = splicedContent(hookContent, args.registration_snippet);
5169
+ if (patched) {
5170
+ filesTouched.push(primaryFile);
5171
+ templatePatch = `+ ${args.registration_snippet}`;
5172
+ } else {
5173
+ templatePatch = "Could not find insertion point in hook file.";
5174
+ }
5175
+ return { library: args.library, filesTouched, depsToAdd, packageJsonPatch, templatePatch };
5176
+ }
5177
+ async function rollbackExtension(ctx, args) {
5178
+ const logPath = extendLogPath();
5179
+ if (!await fileExists(logPath)) {
5180
+ return { undone: false, message: "no apply found for library" };
5181
+ }
5182
+ const raw = await fs21.readFile(logPath, "utf8");
5183
+ const entries = raw.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
5184
+ const match = [...entries].reverse().find((e) => e.project === ctx.project && e.library === args.library);
5185
+ if (!match) {
5186
+ return { undone: false, message: "no apply found for library" };
5187
+ }
5188
+ const pkgPath = path35.join(ctx.scanPath, "package.json");
5189
+ if (await fileExists(pkgPath)) {
5190
+ const pkg = await readPackageJson(ctx.scanPath);
5191
+ if (pkg.dependencies?.[match.instrumentation_package]) {
5192
+ const { [match.instrumentation_package]: _removed, ...rest } = pkg.dependencies;
5193
+ pkg.dependencies = rest;
5194
+ await fs21.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
5195
+ }
5196
+ }
5197
+ const hookFiles = await findHookFiles(ctx.scanPath);
5198
+ for (const file of hookFiles) {
5199
+ const filePath = path35.join(ctx.scanPath, file);
5200
+ const content = await fs21.readFile(filePath, "utf8");
5201
+ if (content.includes(match.registration_snippet)) {
5202
+ const filtered = content.split("\n").filter((line) => !line.includes(match.registration_snippet)).join("\n");
5203
+ await fs21.writeFile(filePath, filtered, "utf8");
5204
+ break;
5205
+ }
5206
+ }
5207
+ return {
5208
+ undone: true,
5209
+ message: `rolled back ${match.library} (${match.instrumentation_package})`
5210
+ };
5211
+ }
5212
+
4724
5213
  // src/streaming.ts
4725
5214
  var SSE_HEARTBEAT_MS = 3e4;
4726
5215
  var SSE_BACKPRESSURE_CAP = 1e3;
@@ -5177,6 +5666,105 @@ function registerRoutes(scope, ctx) {
5177
5666
  violations
5178
5667
  };
5179
5668
  });
5669
+ scope.get("/extend/list-uninstrumented", async (req, reply) => {
5670
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5671
+ if (!proj) return;
5672
+ if (!proj.scanPath) {
5673
+ return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
5674
+ }
5675
+ try {
5676
+ const results = await listUninstrumented({ project: proj.name, scanPath: proj.scanPath });
5677
+ return { libraries: results };
5678
+ } catch (err) {
5679
+ return reply.code(500).send({ error: err.message });
5680
+ }
5681
+ });
5682
+ scope.get("/extend/lookup", async (req, reply) => {
5683
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5684
+ if (!proj) return;
5685
+ const { library, version } = req.query;
5686
+ if (!library) {
5687
+ return reply.code(400).send({ error: "query parameter `library` is required" });
5688
+ }
5689
+ const result = lookupInstrumentation(library, version);
5690
+ if (!result) {
5691
+ return reply.code(404).send({ error: "library not found in registry", library });
5692
+ }
5693
+ return result;
5694
+ });
5695
+ scope.get("/extend/describe", async (req, reply) => {
5696
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5697
+ if (!proj) return;
5698
+ if (!proj.scanPath) {
5699
+ return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
5700
+ }
5701
+ try {
5702
+ const state = await describeProjectInstrumentation({ project: proj.name, scanPath: proj.scanPath });
5703
+ return state;
5704
+ } catch (err) {
5705
+ return reply.code(500).send({ error: err.message });
5706
+ }
5707
+ });
5708
+ scope.post("/extend/apply", async (req, reply) => {
5709
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5710
+ if (!proj) return;
5711
+ if (!proj.scanPath) {
5712
+ return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
5713
+ }
5714
+ const { library, instrumentation_package, version, registration_snippet } = req.body ?? {};
5715
+ if (!library || !instrumentation_package || !version || !registration_snippet) {
5716
+ return reply.code(400).send({ error: "body must include library, instrumentation_package, version, registration_snippet" });
5717
+ }
5718
+ try {
5719
+ const result = await applyExtension(
5720
+ { project: proj.name, scanPath: proj.scanPath },
5721
+ { library, instrumentation_package, version, registration_snippet }
5722
+ );
5723
+ return result;
5724
+ } catch (err) {
5725
+ return reply.code(500).send({ error: err.message });
5726
+ }
5727
+ });
5728
+ scope.post("/extend/dry-run", async (req, reply) => {
5729
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5730
+ if (!proj) return;
5731
+ if (!proj.scanPath) {
5732
+ return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
5733
+ }
5734
+ const { library, instrumentation_package, version, registration_snippet } = req.body ?? {};
5735
+ if (!library || !instrumentation_package || !version || !registration_snippet) {
5736
+ return reply.code(400).send({ error: "body must include library, instrumentation_package, version, registration_snippet" });
5737
+ }
5738
+ try {
5739
+ const result = await dryRunExtension(
5740
+ { project: proj.name, scanPath: proj.scanPath },
5741
+ { library, instrumentation_package, version, registration_snippet }
5742
+ );
5743
+ return result;
5744
+ } catch (err) {
5745
+ return reply.code(500).send({ error: err.message });
5746
+ }
5747
+ });
5748
+ scope.post("/extend/rollback", async (req, reply) => {
5749
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5750
+ if (!proj) return;
5751
+ if (!proj.scanPath) {
5752
+ return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
5753
+ }
5754
+ const { library } = req.body ?? {};
5755
+ if (!library) {
5756
+ return reply.code(400).send({ error: "body must include library" });
5757
+ }
5758
+ try {
5759
+ const result = await rollbackExtension(
5760
+ { project: proj.name, scanPath: proj.scanPath },
5761
+ { library }
5762
+ );
5763
+ return result;
5764
+ } catch (err) {
5765
+ return reply.code(500).send({ error: err.message });
5766
+ }
5767
+ });
5180
5768
  }
5181
5769
  async function buildApi(opts) {
5182
5770
  const app = Fastify({ logger: false });
@@ -5322,6 +5910,8 @@ export {
5322
5910
  saveGraphToDisk,
5323
5911
  loadGraphFromDisk,
5324
5912
  startPersistLoop,
5913
+ detectPackageManager,
5914
+ runPackageManagerInstall,
5325
5915
  loadSnapshotForDiff,
5326
5916
  computeGraphDiff,
5327
5917
  pathsForProject,
@@ -5341,4 +5931,4 @@ export {
5341
5931
  removeProject,
5342
5932
  buildApi
5343
5933
  };
5344
- //# sourceMappingURL=chunk-J5CEKCTR.js.map
5934
+ //# sourceMappingURL=chunk-WDG4QEFO.js.map