@dreamtree-org/graphify 1.0.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -31,23 +31,44 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  EXTRACTOR_REGISTRY: () => EXTRACTOR_REGISTRY,
34
+ ExtractionCache: () => ExtractionCache,
34
35
  ExtractionResultSchema: () => ExtractionResultSchema,
35
36
  ExtractionValidationError: () => ExtractionValidationError,
37
+ affectedBy: () => affectedBy,
36
38
  analyze: () => analyze,
39
+ buildContextPack: () => buildContextPack,
37
40
  buildGraph: () => buildGraph,
41
+ checkRules: () => checkRules,
38
42
  cluster: () => cluster,
39
43
  collectFiles: () => collectFiles,
44
+ diffGraphs: () => diffGraphs,
40
45
  explainNode: () => explainNode,
41
46
  exportGraph: () => exportGraph,
42
47
  extract: () => extract,
48
+ extractMysql: () => extractMysql,
49
+ globToRegExp: () => globToRegExp,
50
+ graphForDirectory: () => graphForDirectory,
51
+ isTestFile: () => isTestFile,
43
52
  loadGraph: () => loadGraph,
53
+ loadResults: () => loadResults,
54
+ mergeGraphs: () => mergeGraphs,
44
55
  queryGraph: () => queryGraph,
56
+ rankForTask: () => rankForTask,
57
+ renderContextPack: () => renderContextPack,
58
+ renderLessons: () => renderLessons,
45
59
  renderReport: () => renderReport,
60
+ renderReview: () => renderReview,
61
+ resolveCrossFileReferences: () => resolveCrossFileReferences,
46
62
  resolveNode: () => resolveNode,
63
+ reviewRevisions: () => reviewRevisions,
47
64
  runPipeline: () => runPipeline,
65
+ saveResult: () => saveResult,
48
66
  scoreNodes: () => scoreNodes,
49
67
  shortestPath: () => shortestPath,
50
- validateExtraction: () => validateExtraction
68
+ testsForChangedFiles: () => testsForChangedFiles,
69
+ testsForNode: () => testsForNode,
70
+ validateExtraction: () => validateExtraction,
71
+ validateRules: () => validateRules
51
72
  });
52
73
  module.exports = __toCommonJS(index_exports);
53
74
 
@@ -293,7 +314,7 @@ async function createParser(grammarName) {
293
314
  }
294
315
 
295
316
  // src/extractors/csharp.ts
296
- async function extractCSharp(filePath) {
317
+ async function extractCSharp(filePath, displayPath) {
297
318
  const parser = await createParser("c_sharp");
298
319
  const raw = await fs2.readFile(filePath);
299
320
  const content = raw.toString("utf-8");
@@ -302,7 +323,7 @@ async function extractCSharp(filePath) {
302
323
  throw new Error(`extractCSharp: parser produced no tree for ${filePath}`);
303
324
  }
304
325
  const root = tree.rootNode;
305
- const sourceFile = toPosix(filePath);
326
+ const sourceFile = toPosix(displayPath ?? filePath);
306
327
  const builder = new ExtractionBuilder();
307
328
  const fileId = sourceFile;
308
329
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -506,7 +527,7 @@ function defaultPackageQualifier(importPath) {
506
527
  const segments = importPath.split("/");
507
528
  return segments[segments.length - 1] ?? importPath;
508
529
  }
509
- async function extractGo(filePath) {
530
+ async function extractGo(filePath, displayPath) {
510
531
  const parser = await createParser("go");
511
532
  const raw = await fs3.readFile(filePath);
512
533
  const content = raw.toString("utf-8");
@@ -515,7 +536,7 @@ async function extractGo(filePath) {
515
536
  throw new Error(`extractGo: parser produced no tree for ${filePath}`);
516
537
  }
517
538
  const root = tree.rootNode;
518
- const sourceFile = toPosix(filePath);
539
+ const sourceFile = toPosix(displayPath ?? filePath);
519
540
  const builder = new ExtractionBuilder();
520
541
  const fileId = sourceFile;
521
542
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -713,7 +734,7 @@ async function extractGo(filePath) {
713
734
 
714
735
  // src/extractors/java.ts
715
736
  var fs4 = __toESM(require("fs/promises"), 1);
716
- async function extractJava(filePath) {
737
+ async function extractJava(filePath, displayPath) {
717
738
  const parser = await createParser("java");
718
739
  const raw = await fs4.readFile(filePath);
719
740
  const content = raw.toString("utf-8");
@@ -722,7 +743,7 @@ async function extractJava(filePath) {
722
743
  throw new Error(`extractJava: parser produced no tree for ${filePath}`);
723
744
  }
724
745
  const root = tree.rootNode;
725
- const sourceFile = toPosix(filePath);
746
+ const sourceFile = toPosix(displayPath ?? filePath);
726
747
  const builder = new ExtractionBuilder();
727
748
  const fileId = sourceFile;
728
749
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -914,7 +935,7 @@ async function extractJava(filePath) {
914
935
 
915
936
  // src/extractors/python.ts
916
937
  var fs5 = __toESM(require("fs/promises"), 1);
917
- async function extractPython(filePath) {
938
+ async function extractPython(filePath, displayPath) {
918
939
  const parser = await createParser("python");
919
940
  const raw = await fs5.readFile(filePath);
920
941
  const content = raw.toString("utf-8");
@@ -923,7 +944,7 @@ async function extractPython(filePath) {
923
944
  throw new Error(`extractPython: parser produced no tree for ${filePath}`);
924
945
  }
925
946
  const root = tree.rootNode;
926
- const sourceFile = toPosix(filePath);
947
+ const sourceFile = toPosix(displayPath ?? filePath);
927
948
  const builder = new ExtractionBuilder();
928
949
  const fileId = sourceFile;
929
950
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -1158,7 +1179,7 @@ function stringLiteralContent(node) {
1158
1179
  const content = namedChildren(node).find((c) => c.type === "string_content");
1159
1180
  return content ? content.text : "";
1160
1181
  }
1161
- async function extractRuby(filePath) {
1182
+ async function extractRuby(filePath, displayPath) {
1162
1183
  const parser = await createParser("ruby");
1163
1184
  const raw = await fs6.readFile(filePath);
1164
1185
  const content = raw.toString("utf-8");
@@ -1167,7 +1188,7 @@ async function extractRuby(filePath) {
1167
1188
  throw new Error(`extractRuby: parser produced no tree for ${filePath}`);
1168
1189
  }
1169
1190
  const root = tree.rootNode;
1170
- const sourceFile = toPosix(filePath);
1191
+ const sourceFile = toPosix(displayPath ?? filePath);
1171
1192
  const builder = new ExtractionBuilder();
1172
1193
  const fileId = sourceFile;
1173
1194
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -1360,7 +1381,7 @@ function convertUsePath(segments) {
1360
1381
  }
1361
1382
  return segments.join("::");
1362
1383
  }
1363
- async function extractRust(filePath) {
1384
+ async function extractRust(filePath, displayPath) {
1364
1385
  const parser = await createParser("rust");
1365
1386
  const raw = await fs7.readFile(filePath);
1366
1387
  const content = raw.toString("utf-8");
@@ -1369,7 +1390,7 @@ async function extractRust(filePath) {
1369
1390
  throw new Error(`extractRust: parser produced no tree for ${filePath}`);
1370
1391
  }
1371
1392
  const root = tree.rootNode;
1372
- const sourceFile = toPosix(filePath);
1393
+ const sourceFile = toPosix(displayPath ?? filePath);
1373
1394
  const builder = new ExtractionBuilder();
1374
1395
  const fileId = sourceFile;
1375
1396
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -1582,10 +1603,10 @@ async function extractRust(filePath) {
1582
1603
  }
1583
1604
  }
1584
1605
  } else if (fn.type === "scoped_identifier") {
1585
- const path8 = fn.childForFieldName("path")?.text;
1606
+ const path12 = fn.childForFieldName("path")?.text;
1586
1607
  const name = fn.childForFieldName("name")?.text;
1587
- if (path8 && name) {
1588
- const viaImport = importIndex.get(path8);
1608
+ if (path12 && name) {
1609
+ const viaImport = importIndex.get(path12);
1589
1610
  if (viaImport) {
1590
1611
  targetId = viaImport.id;
1591
1612
  confidence = "INFERRED";
@@ -1626,7 +1647,7 @@ function firstStringArgument(call) {
1626
1647
  if (!first || first.type !== "string") return null;
1627
1648
  return stripQuotes2(first.text);
1628
1649
  }
1629
- async function extractTypeScript(filePath) {
1650
+ async function extractTypeScript(filePath, displayPath) {
1630
1651
  const ext = path3.extname(filePath);
1631
1652
  const grammar = grammarForExtension(ext);
1632
1653
  const parser = await createParser(grammar);
@@ -1637,7 +1658,7 @@ async function extractTypeScript(filePath) {
1637
1658
  throw new Error(`extractTypeScript: parser produced no tree for ${filePath}`);
1638
1659
  }
1639
1660
  const root = tree.rootNode;
1640
- const sourceFile = toPosix(filePath);
1661
+ const sourceFile = toPosix(displayPath ?? filePath);
1641
1662
  const builder = new ExtractionBuilder();
1642
1663
  const fileId = sourceFile;
1643
1664
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -1954,13 +1975,13 @@ var EXTRACTOR_REGISTRY = {
1954
1975
  ".cs": extractCSharp,
1955
1976
  ".rb": extractRuby
1956
1977
  };
1957
- async function extract(filePath) {
1978
+ async function extract(filePath, displayPath) {
1958
1979
  const ext = path4.extname(filePath);
1959
1980
  const extractor = EXTRACTOR_REGISTRY[ext];
1960
1981
  if (!extractor) {
1961
1982
  return { nodes: [], edges: [] };
1962
1983
  }
1963
- return extractor(filePath);
1984
+ return extractor(filePath, displayPath);
1964
1985
  }
1965
1986
 
1966
1987
  // src/build.ts
@@ -2065,6 +2086,150 @@ function buildGraph(extractions) {
2065
2086
  return graph;
2066
2087
  }
2067
2088
 
2089
+ // src/resolve.ts
2090
+ var fs9 = __toESM(require("fs/promises"), 1);
2091
+ var path5 = __toESM(require("path"), 1);
2092
+ var CODE_EXTENSIONS2 = [
2093
+ ".ts",
2094
+ ".tsx",
2095
+ ".mts",
2096
+ ".cts",
2097
+ ".js",
2098
+ ".jsx",
2099
+ ".mjs",
2100
+ ".cjs",
2101
+ ".py",
2102
+ ".go",
2103
+ ".rs",
2104
+ ".java",
2105
+ ".cs",
2106
+ ".rb"
2107
+ ];
2108
+ function isOpaque(id) {
2109
+ return id.startsWith("module:") || id.startsWith("external:") || id.startsWith("db:");
2110
+ }
2111
+ var SELF_IMPORT_STEMS = (subpath) => subpath === "" ? ["index", "src/index", "lib/index", "source/index", "src/main"] : [
2112
+ subpath,
2113
+ `src/${subpath}`,
2114
+ `lib/${subpath}`,
2115
+ `source/${subpath}`,
2116
+ `${subpath}/index`,
2117
+ `src/${subpath}/index`,
2118
+ `lib/${subpath}/index`
2119
+ ];
2120
+ function selfImportCandidates(spec, selfNames) {
2121
+ const selfName = selfNames.find((n) => spec === n || spec.startsWith(`${n}/`));
2122
+ if (selfName === void 0) return null;
2123
+ const subpath = spec === selfName ? "" : spec.slice(selfName.length + 1);
2124
+ return SELF_IMPORT_STEMS(subpath).flatMap((stem) => CODE_EXTENSIONS2.map((ext) => `${stem}${ext}`));
2125
+ }
2126
+ async function readSelfNames(root) {
2127
+ try {
2128
+ const pkg = JSON.parse(await fs9.readFile(path5.join(root, "package.json"), "utf-8"));
2129
+ return typeof pkg.name === "string" && pkg.name.length > 0 ? [pkg.name] : [];
2130
+ } catch {
2131
+ return [];
2132
+ }
2133
+ }
2134
+ function stripKnownExtension(p) {
2135
+ for (const ext of CODE_EXTENSIONS2) {
2136
+ if (p.endsWith(ext)) return p.slice(0, -ext.length);
2137
+ }
2138
+ return null;
2139
+ }
2140
+ function candidatePaths(guessed) {
2141
+ const stem = stripKnownExtension(guessed) ?? guessed;
2142
+ const candidates = [];
2143
+ for (const ext of CODE_EXTENSIONS2) candidates.push(`${stem}${ext}`);
2144
+ for (const ext of CODE_EXTENSIONS2) candidates.push(`${guessed}/index${ext}`);
2145
+ if (stem !== guessed) {
2146
+ for (const ext of CODE_EXTENSIONS2) candidates.push(`${stem}/index${ext}`);
2147
+ }
2148
+ return candidates.filter((c) => c !== guessed);
2149
+ }
2150
+ function uniqueMatch(guessed, candidates, realIds) {
2151
+ const matches = /* @__PURE__ */ new Set();
2152
+ for (const candidate of candidates) {
2153
+ if (realIds.has(candidate)) matches.add(candidate);
2154
+ }
2155
+ if (matches.size !== 1) return null;
2156
+ return [...matches][0];
2157
+ }
2158
+ function resolveCrossFileReferences(extractions, options = {}) {
2159
+ const selfNames = options.selfNames ?? [];
2160
+ const realIds = /* @__PURE__ */ new Set();
2161
+ const realFiles = /* @__PURE__ */ new Set();
2162
+ for (const extraction of extractions) {
2163
+ for (const node of extraction.nodes) realIds.add(node.id);
2164
+ const first = extraction.nodes[0];
2165
+ if (first && first.id === first.sourceFile && !isOpaque(first.id)) {
2166
+ realFiles.add(first.id);
2167
+ }
2168
+ for (const node of extraction.nodes) {
2169
+ const sep3 = node.id.indexOf("::");
2170
+ if (sep3 > 0) realFiles.add(node.id.slice(0, sep3));
2171
+ }
2172
+ for (const edge of extraction.edges) {
2173
+ if (edge.relation === "contains") realFiles.add(edge.source);
2174
+ }
2175
+ }
2176
+ const remap = /* @__PURE__ */ new Map();
2177
+ const resolveId = (id) => {
2178
+ if (id.startsWith("module:") && selfNames.length > 0) {
2179
+ const cachedSelf = remap.get(id);
2180
+ if (cachedSelf !== void 0) return cachedSelf;
2181
+ const candidates = selfImportCandidates(id.slice("module:".length), selfNames);
2182
+ if (candidates !== null) {
2183
+ const resolved2 = uniqueMatch(id, candidates, realFiles);
2184
+ if (resolved2 !== null) {
2185
+ remap.set(id, resolved2);
2186
+ return resolved2;
2187
+ }
2188
+ }
2189
+ return null;
2190
+ }
2191
+ if (isOpaque(id)) return null;
2192
+ const cached = remap.get(id);
2193
+ if (cached !== void 0) return cached;
2194
+ const sep3 = id.indexOf("::");
2195
+ let resolved;
2196
+ if (sep3 > 0) {
2197
+ if (realIds.has(id)) return null;
2198
+ const guessedPath = id.slice(0, sep3);
2199
+ const name = id.slice(sep3);
2200
+ const candidates = candidatePaths(guessedPath).map((p) => `${p}${name}`);
2201
+ resolved = uniqueMatch(id, candidates, realIds);
2202
+ } else {
2203
+ if (realFiles.has(id)) return null;
2204
+ resolved = uniqueMatch(id, candidatePaths(id), realFiles);
2205
+ }
2206
+ if (resolved !== null) remap.set(id, resolved);
2207
+ return resolved;
2208
+ };
2209
+ let resolvedEndpoints = 0;
2210
+ for (const extraction of extractions) {
2211
+ for (const edge of extraction.edges) {
2212
+ const source = resolveId(edge.source);
2213
+ if (source !== null) {
2214
+ edge.source = source;
2215
+ resolvedEndpoints++;
2216
+ }
2217
+ const target = resolveId(edge.target);
2218
+ if (target !== null) {
2219
+ edge.target = target;
2220
+ resolvedEndpoints++;
2221
+ }
2222
+ }
2223
+ }
2224
+ let droppedPlaceholders = 0;
2225
+ for (const extraction of extractions) {
2226
+ const before = extraction.nodes.length;
2227
+ extraction.nodes = extraction.nodes.filter((node) => !remap.has(node.id));
2228
+ droppedPlaceholders += before - extraction.nodes.length;
2229
+ }
2230
+ return { resolvedEndpoints, droppedPlaceholders };
2231
+ }
2232
+
2068
2233
  // src/cluster.ts
2069
2234
  var import_node_crypto = require("crypto");
2070
2235
  var import_graphology2 = __toESM(require("graphology"), 1);
@@ -2331,39 +2496,69 @@ function renderReport(graph, analysis, now = /* @__PURE__ */ new Date()) {
2331
2496
 
2332
2497
  // src/export.ts
2333
2498
  var import_node_module2 = require("module");
2334
- var fs10 = __toESM(require("fs/promises"), 1);
2335
- var path5 = __toESM(require("path"), 1);
2499
+ var fs11 = __toESM(require("fs/promises"), 1);
2500
+ var path6 = __toESM(require("path"), 1);
2336
2501
 
2337
2502
  // src/security.ts
2338
2503
  var import_node_crypto2 = require("crypto");
2339
2504
  var dns = __toESM(require("dns"), 1);
2340
2505
  var http = __toESM(require("http"), 1);
2341
2506
  var https = __toESM(require("https"), 1);
2342
- var fs9 = __toESM(require("fs"), 1);
2507
+ var fs10 = __toESM(require("fs"), 1);
2343
2508
  var net = __toESM(require("net"), 1);
2344
2509
  var nodePath = __toESM(require("path"), 1);
2345
2510
  var MAX_FETCH_BYTES = 50 * 1024 * 1024;
2346
2511
  var MAX_TEXT_BYTES = 10 * 1024 * 1024;
2347
2512
  var MAX_LABEL_LEN = 256;
2348
- function validateGraphPath(path8, base) {
2513
+ var DEFAULT_MYSQL_PORT = 3306;
2514
+ function validateDsn(dsn) {
2515
+ let parsed;
2516
+ try {
2517
+ parsed = new URL(dsn);
2518
+ } catch {
2519
+ throw new Error("Invalid DSN \u2014 expected mysql://user:pass@host:port/database");
2520
+ }
2521
+ if (parsed.protocol !== "mysql:") {
2522
+ throw new Error(`DSN scheme not supported: "${parsed.protocol}" (only mysql: is supported)`);
2523
+ }
2524
+ const database = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
2525
+ if (!database || database.includes("/")) {
2526
+ throw new Error("DSN must name exactly one database, e.g. mysql://localhost:3306/mydb");
2527
+ }
2528
+ if (!parsed.hostname) {
2529
+ throw new Error("DSN must include a host, e.g. mysql://localhost:3306/mydb");
2530
+ }
2531
+ const port = parsed.port ? Number(parsed.port) : DEFAULT_MYSQL_PORT;
2532
+ return {
2533
+ safeDisplay: `mysql://${parsed.hostname}:${port}/${database}`,
2534
+ connection: {
2535
+ host: parsed.hostname,
2536
+ port,
2537
+ user: decodeURIComponent(parsed.username) || "root",
2538
+ password: decodeURIComponent(parsed.password),
2539
+ database
2540
+ }
2541
+ };
2542
+ }
2543
+ function validateGraphPath(path12, base) {
2349
2544
  const baseDir = base ?? nodePath.join(process.cwd(), "graphify-out");
2350
2545
  let resolvedBase;
2351
2546
  try {
2352
- resolvedBase = fs9.realpathSync(baseDir);
2547
+ resolvedBase = fs10.realpathSync(baseDir);
2353
2548
  } catch {
2354
2549
  throw new Error(`Base directory does not exist: ${baseDir}`);
2355
2550
  }
2356
- const candidate = nodePath.isAbsolute(path8) ? path8 : nodePath.join(resolvedBase, path8);
2551
+ const candidate = nodePath.isAbsolute(path12) ? path12 : nodePath.join(resolvedBase, path12);
2357
2552
  const resolvedCandidate = nodePath.resolve(candidate);
2358
2553
  let realCandidate = resolvedCandidate;
2359
2554
  try {
2360
- realCandidate = fs9.realpathSync(resolvedCandidate);
2555
+ realCandidate = fs10.realpathSync(resolvedCandidate);
2361
2556
  } catch {
2362
2557
  }
2363
2558
  const relative4 = nodePath.relative(resolvedBase, realCandidate);
2364
2559
  const escapes = relative4 === ".." || relative4.startsWith(`..${nodePath.sep}`) || nodePath.isAbsolute(relative4);
2365
2560
  if (escapes) {
2366
- throw new Error(`Path escapes graphify-out/: ${path8}`);
2561
+ throw new Error(`Path escapes graphify-out/: ${path12}`);
2367
2562
  }
2368
2563
  return realCandidate;
2369
2564
  }
@@ -2380,10 +2575,10 @@ function escapeHtml(text) {
2380
2575
  var require3 = (0, import_node_module2.createRequire)(importMetaUrl);
2381
2576
  function resolveVisNetworkAssets() {
2382
2577
  const packageJsonPath = require3.resolve("vis-network/package.json");
2383
- const root = path5.dirname(packageJsonPath);
2578
+ const root = path6.dirname(packageJsonPath);
2384
2579
  return {
2385
- jsPath: path5.join(root, "standalone", "umd", "vis-network.min.js"),
2386
- cssPath: path5.join(root, "styles", "vis-network.min.css")
2580
+ jsPath: path6.join(root, "standalone", "umd", "vis-network.min.js"),
2581
+ cssPath: path6.join(root, "styles", "vis-network.min.css")
2387
2582
  };
2388
2583
  }
2389
2584
  var COMMUNITY_PALETTE = [
@@ -2442,8 +2637,8 @@ community: ${communityLabel}` : ""}`,
2442
2637
  async function renderHtml(graph) {
2443
2638
  const { jsPath, cssPath } = resolveVisNetworkAssets();
2444
2639
  const [visJs, visCss] = await Promise.all([
2445
- fs10.readFile(jsPath, "utf-8"),
2446
- fs10.readFile(cssPath, "utf-8")
2640
+ fs11.readFile(jsPath, "utf-8"),
2641
+ fs11.readFile(cssPath, "utf-8")
2447
2642
  ]);
2448
2643
  const { nodes, edges } = buildVisNodesAndEdges(graph);
2449
2644
  const dataJson = safeInlineJson({ nodes, edges });
@@ -2483,17 +2678,17 @@ ${visJs}
2483
2678
  `;
2484
2679
  }
2485
2680
  async function exportGraph(graph, options, report) {
2486
- const outDir = path5.resolve(options.outDir);
2487
- await fs10.mkdir(outDir, { recursive: true });
2681
+ const outDir = path6.resolve(options.outDir);
2682
+ await fs11.mkdir(outDir, { recursive: true });
2488
2683
  const jsonPath = validateGraphPath("graph.json", outDir);
2489
- await fs10.writeFile(jsonPath, JSON.stringify(graph.toJSON(), null, 2), "utf-8");
2684
+ await fs11.writeFile(jsonPath, JSON.stringify(graph.toJSON(), null, 2), "utf-8");
2490
2685
  if (options.html !== false) {
2491
2686
  const htmlPath = validateGraphPath("graph.html", outDir);
2492
- await fs10.writeFile(htmlPath, await renderHtml(graph), "utf-8");
2687
+ await fs11.writeFile(htmlPath, await renderHtml(graph), "utf-8");
2493
2688
  }
2494
2689
  if (report !== void 0) {
2495
2690
  const reportPath = validateGraphPath("GRAPH_REPORT.md", outDir);
2496
- await fs10.writeFile(reportPath, report, "utf-8");
2691
+ await fs11.writeFile(reportPath, report, "utf-8");
2497
2692
  }
2498
2693
  const notImplemented = [];
2499
2694
  if (options.svg) notImplemented.push("--svg");
@@ -2506,28 +2701,84 @@ async function exportGraph(graph, options, report) {
2506
2701
  }
2507
2702
 
2508
2703
  // src/pipeline.ts
2509
- var path6 = __toESM(require("path"), 1);
2704
+ var fs13 = __toESM(require("fs/promises"), 1);
2705
+ var path8 = __toESM(require("path"), 1);
2706
+
2707
+ // src/extractionCache.ts
2708
+ var import_node_crypto3 = require("crypto");
2709
+ var fs12 = __toESM(require("fs/promises"), 1);
2710
+ var path7 = __toESM(require("path"), 1);
2711
+ var ExtractionCache = class {
2712
+ dir;
2713
+ constructor(outDir) {
2714
+ this.dir = path7.join(outDir, "cache");
2715
+ }
2716
+ key(relFile, content) {
2717
+ return (0, import_node_crypto3.createHash)("sha256").update(relFile).update("\0").update(content).digest("hex");
2718
+ }
2719
+ async get(key) {
2720
+ try {
2721
+ const raw = await fs12.readFile(path7.join(this.dir, `${key}.json`), "utf-8");
2722
+ const parsed = JSON.parse(raw);
2723
+ if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null;
2724
+ return parsed;
2725
+ } catch {
2726
+ return null;
2727
+ }
2728
+ }
2729
+ async put(key, extraction) {
2730
+ await fs12.mkdir(this.dir, { recursive: true });
2731
+ await fs12.writeFile(path7.join(this.dir, `${key}.json`), JSON.stringify(extraction), "utf-8");
2732
+ }
2733
+ };
2734
+
2735
+ // src/pipeline.ts
2510
2736
  async function runPipeline(root, options = {}) {
2511
2737
  const progress = options.onProgress ?? (() => {
2512
2738
  });
2513
- const resolvedRoot = path6.resolve(root);
2739
+ const resolvedRoot = path8.resolve(root);
2514
2740
  progress(`Scanning ${resolvedRoot} ...`);
2515
2741
  const manifest = collectFiles(resolvedRoot);
2516
2742
  progress(
2517
2743
  `Found ${manifest.totalFiles} file(s) (${manifest.files.code.length} code, ${manifest.skippedSensitive.length} skipped as sensitive).`
2518
2744
  );
2745
+ const outDir = options.outDir ?? path8.join(resolvedRoot, "graphify-out");
2746
+ const cache = new ExtractionCache(outDir);
2519
2747
  progress("Extracting...");
2520
2748
  const extractions = [];
2749
+ let cacheHits = 0;
2521
2750
  for (const relFile of manifest.files.code.sort((a, b) => a.localeCompare(b))) {
2522
- const filePath = path6.relative(process.cwd(), path6.join(resolvedRoot, relFile)) || relFile;
2751
+ const filePath = path8.relative(process.cwd(), path8.join(resolvedRoot, relFile)) || relFile;
2523
2752
  try {
2524
- const extraction = await extract(filePath);
2753
+ const key = cache.key(relFile, await fs13.readFile(path8.join(resolvedRoot, relFile)));
2754
+ let extraction = options.update ? await cache.get(key) : null;
2755
+ if (extraction) {
2756
+ cacheHits++;
2757
+ } else {
2758
+ extraction = await extract(filePath, relFile);
2759
+ await cache.put(key, extraction);
2760
+ }
2525
2761
  extractions.push(extraction);
2526
2762
  } catch (error) {
2527
2763
  progress(` extraction failed for ${relFile}: ${error.message}`);
2528
2764
  throw error;
2529
2765
  }
2530
2766
  }
2767
+ if (options.update) {
2768
+ progress(` cache: ${cacheHits} hit(s), ${extractions.length - cacheHits} re-extracted.`);
2769
+ }
2770
+ if (options.extraExtractions && options.extraExtractions.length > 0) {
2771
+ extractions.push(...options.extraExtractions);
2772
+ }
2773
+ progress("Resolving cross-file references...");
2774
+ const resolveStats = resolveCrossFileReferences(extractions, {
2775
+ selfNames: await readSelfNames(resolvedRoot)
2776
+ });
2777
+ if (resolveStats.resolvedEndpoints > 0) {
2778
+ progress(
2779
+ ` re-pointed ${resolveStats.resolvedEndpoints} guessed reference(s) at real nodes (${resolveStats.droppedPlaceholders} placeholder node(s) dropped).`
2780
+ );
2781
+ }
2531
2782
  progress("Building graph...");
2532
2783
  const graph = buildGraph(extractions);
2533
2784
  progress(`Clustering (${options.algorithm ?? "louvain"})...`);
@@ -2536,7 +2787,6 @@ async function runPipeline(root, options = {}) {
2536
2787
  const analysis = analyze(graph);
2537
2788
  progress("Rendering report...");
2538
2789
  const report = renderReport(graph, analysis);
2539
- const outDir = options.outDir ?? path6.join(resolvedRoot, "graphify-out");
2540
2790
  progress(`Exporting to ${outDir} ...`);
2541
2791
  await exportGraph(
2542
2792
  graph,
@@ -2555,12 +2805,12 @@ async function runPipeline(root, options = {}) {
2555
2805
  }
2556
2806
 
2557
2807
  // src/graphStore.ts
2558
- var fs11 = __toESM(require("fs/promises"), 1);
2559
- var path7 = __toESM(require("path"), 1);
2808
+ var fs14 = __toESM(require("fs/promises"), 1);
2809
+ var path9 = __toESM(require("path"), 1);
2560
2810
  var import_graphology3 = __toESM(require("graphology"), 1);
2561
- async function loadGraph(outDir = path7.join(process.cwd(), "graphify-out")) {
2811
+ async function loadGraph(outDir = path9.join(process.cwd(), "graphify-out")) {
2562
2812
  const jsonPath = validateGraphPath("graph.json", outDir);
2563
- const raw = await fs11.readFile(jsonPath, "utf-8");
2813
+ const raw = await fs14.readFile(jsonPath, "utf-8");
2564
2814
  const data = JSON.parse(raw);
2565
2815
  return import_graphology3.default.from(data);
2566
2816
  }
@@ -2597,8 +2847,48 @@ var STOPWORDS = /* @__PURE__ */ new Set([
2597
2847
  "does",
2598
2848
  "that's"
2599
2849
  ]);
2850
+ function splitCamelCase(text) {
2851
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
2852
+ }
2600
2853
  function tokenize(text) {
2601
- return text.toLowerCase().split(/[^a-z0-9_.]+/).filter((token) => token.length > 1 && !STOPWORDS.has(token));
2854
+ return splitCamelCase(text).toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 1 && !STOPWORDS.has(token));
2855
+ }
2856
+ function subtokenize(text) {
2857
+ return splitCamelCase(text).toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 1);
2858
+ }
2859
+ var FUZZY_MIN_TOKEN_LEN = 3;
2860
+ var FUZZY_THRESHOLD = 0.7;
2861
+ var SUBTOKEN_MATCH_SCORE = 1;
2862
+ var SUBSTRING_MATCH_SCORE = 0.6;
2863
+ var FUZZY_MATCH_WEIGHT = 0.5;
2864
+ function osaDistance(a, b) {
2865
+ let prev2 = [];
2866
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
2867
+ let curr = new Array(b.length + 1).fill(0);
2868
+ for (let i = 1; i <= a.length; i++) {
2869
+ curr[0] = i;
2870
+ for (let j = 1; j <= b.length; j++) {
2871
+ const substitution = a[i - 1] === b[j - 1] ? 0 : 1;
2872
+ let best = Math.min(
2873
+ prev[j] + 1,
2874
+ curr[j - 1] + 1,
2875
+ prev[j - 1] + substitution
2876
+ );
2877
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
2878
+ best = Math.min(best, prev2[j - 2] + 1);
2879
+ }
2880
+ curr[j] = best;
2881
+ }
2882
+ [prev2, prev, curr] = [prev, curr, new Array(b.length + 1).fill(0)];
2883
+ }
2884
+ return prev[b.length];
2885
+ }
2886
+ function fuzzySimilarity(a, b) {
2887
+ if (a === b) return 1;
2888
+ const maxLen = Math.max(a.length, b.length);
2889
+ if (maxLen === 0) return 1;
2890
+ if (Math.abs(a.length - b.length) / maxLen > 1 - FUZZY_THRESHOLD) return 0;
2891
+ return 1 - osaDistance(a, b) / maxLen;
2602
2892
  }
2603
2893
  function scoreNodes(graph, query) {
2604
2894
  const tokens = tokenize(query);
@@ -2606,9 +2896,25 @@ function scoreNodes(graph, query) {
2606
2896
  graph.forEachNode((id, attributes) => {
2607
2897
  const label = attributes.label ?? id;
2608
2898
  const haystack = `${label} ${id}`.toLowerCase();
2899
+ const subtokens = new Set(subtokenize(`${label} ${id}`));
2609
2900
  let score = 0;
2610
2901
  for (const token of tokens) {
2611
- if (haystack.includes(token)) score += 1;
2902
+ if (subtokens.has(token)) {
2903
+ score += SUBTOKEN_MATCH_SCORE;
2904
+ continue;
2905
+ }
2906
+ if (haystack.includes(token)) {
2907
+ score += SUBSTRING_MATCH_SCORE;
2908
+ continue;
2909
+ }
2910
+ if (token.length >= FUZZY_MIN_TOKEN_LEN) {
2911
+ let best = 0;
2912
+ for (const subtoken of subtokens) {
2913
+ const similarity = fuzzySimilarity(token, subtoken);
2914
+ if (similarity > best) best = similarity;
2915
+ }
2916
+ if (best >= FUZZY_THRESHOLD) score += best * FUZZY_MATCH_WEIGHT;
2917
+ }
2612
2918
  }
2613
2919
  if (score > 0) matches.push({ id, label, score });
2614
2920
  });
@@ -2622,22 +2928,36 @@ function resolveNode(graph, query) {
2622
2928
  var DEFAULT_MAX_DEPTH = 2;
2623
2929
  var DEFAULT_MAX_SEEDS = 5;
2624
2930
  var DEFAULT_BUDGET = 40;
2931
+ var DEFAULT_TOKEN_BUDGET = 2e3;
2932
+ var NODES_PER_TOKEN = 1 / 10;
2933
+ function nodeTokenCost(graph, id) {
2934
+ const attrs = graph.getNodeAttributes(id);
2935
+ const label = attrs.label ?? id;
2936
+ const sourceFile = attrs.sourceFile ?? "";
2937
+ return Math.ceil((id.length * 2 + label.length + sourceFile.length + 24) / 4);
2938
+ }
2625
2939
  function queryGraph(graph, question, options = {}) {
2626
2940
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
2627
2941
  const maxSeeds = options.maxSeeds ?? DEFAULT_MAX_SEEDS;
2628
- const budget = options.budget ?? DEFAULT_BUDGET;
2942
+ const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
2943
+ const budget = options.budget ?? Math.max(DEFAULT_BUDGET, Math.ceil(tokenBudget * NODES_PER_TOKEN));
2629
2944
  const allMatches = scoreNodes(graph, question);
2630
2945
  const seeds = allMatches.length > 0 ? allMatches.slice(0, maxSeeds) : [];
2631
2946
  const visited = /* @__PURE__ */ new Map();
2632
2947
  const frontier = seeds.map((s) => ({ id: s.id, depth: 0 }));
2633
- for (const seed of seeds) visited.set(seed.id, 0);
2634
- while (frontier.length > 0 && visited.size < budget) {
2948
+ let spentTokens = 0;
2949
+ for (const seed of seeds) {
2950
+ visited.set(seed.id, 0);
2951
+ spentTokens += nodeTokenCost(graph, seed.id);
2952
+ }
2953
+ while (frontier.length > 0 && visited.size < budget && spentTokens < tokenBudget) {
2635
2954
  const current = options.dfs ? frontier.pop() : frontier.shift();
2636
2955
  if (current.depth >= maxDepth) continue;
2637
2956
  const neighbors = [...graph.neighbors(current.id)].sort((a, b) => a.localeCompare(b));
2638
2957
  for (const neighbor of neighbors) {
2639
- if (visited.has(neighbor) || visited.size >= budget) continue;
2958
+ if (visited.has(neighbor) || visited.size >= budget || spentTokens >= tokenBudget) continue;
2640
2959
  visited.set(neighbor, current.depth + 1);
2960
+ spentTokens += nodeTokenCost(graph, neighbor);
2641
2961
  frontier.push({ id: neighbor, depth: current.depth + 1 });
2642
2962
  }
2643
2963
  }
@@ -2665,7 +2985,30 @@ function queryGraph(graph, question, options = {}) {
2665
2985
  edges.sort(
2666
2986
  (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) || a.relation.localeCompare(b.relation)
2667
2987
  );
2668
- return { seeds, visited: visitedNodes, edges };
2988
+ return enforceTokenBudget({ seeds, visited: visitedNodes, edges }, tokenBudget);
2989
+ }
2990
+ function enforceTokenBudget(result, tokenBudget) {
2991
+ const cost = (value) => Math.ceil(JSON.stringify(value).length / 4);
2992
+ const visited = [...result.visited];
2993
+ let edges = [...result.edges];
2994
+ let total = cost(result.seeds) + visited.reduce((s, n) => s + cost(n), 0) + edges.reduce((s, e) => s + cost(e), 0);
2995
+ const seedIds = new Set(result.seeds.map((s) => s.id));
2996
+ while (total > tokenBudget && visited.length > 0) {
2997
+ const last = visited[visited.length - 1];
2998
+ if (seedIds.has(last.id)) break;
2999
+ visited.pop();
3000
+ total -= cost(last);
3001
+ const remaining = [];
3002
+ for (const edge of edges) {
3003
+ if (edge.source === last.id || edge.target === last.id) {
3004
+ total -= cost(edge);
3005
+ } else {
3006
+ remaining.push(edge);
3007
+ }
3008
+ }
3009
+ edges = remaining;
3010
+ }
3011
+ return { seeds: result.seeds, visited, edges };
2669
3012
  }
2670
3013
  function shortestPath(graph, fromQuery, toQuery) {
2671
3014
  const from = resolveNode(graph, fromQuery);
@@ -2691,25 +3034,25 @@ function shortestPath(graph, fromQuery, toQuery) {
2691
3034
  if (!visited.has(to.id)) {
2692
3035
  return { from, to, found: false, path: [], edges: [] };
2693
3036
  }
2694
- const path8 = [to.id];
3037
+ const path12 = [to.id];
2695
3038
  let cursor = to.id;
2696
3039
  while (cursor !== from.id) {
2697
3040
  const prev = predecessor.get(cursor);
2698
3041
  if (!prev) break;
2699
- path8.unshift(prev);
3042
+ path12.unshift(prev);
2700
3043
  cursor = prev;
2701
3044
  }
2702
3045
  const edges = [];
2703
- for (let i = 0; i < path8.length - 1; i++) {
2704
- const a = path8[i];
2705
- const b = path8[i + 1];
3046
+ for (let i = 0; i < path12.length - 1; i++) {
3047
+ const a = path12[i];
3048
+ const b = path12[i + 1];
2706
3049
  const key = graph.edges(a, b)[0] ?? graph.edges(b, a)[0];
2707
3050
  if (key) {
2708
3051
  const attrs = graph.getEdgeAttributes(key);
2709
3052
  edges.push({ source: a, target: b, relation: String(attrs.relation), confidence: String(attrs.confidence) });
2710
3053
  }
2711
3054
  }
2712
- return { from, to, found: true, path: path8, edges };
3055
+ return { from, to, found: true, path: path12, edges };
2713
3056
  }
2714
3057
  function explainNode(graph, query) {
2715
3058
  const match = resolveNode(graph, query);
@@ -2735,25 +3078,813 @@ function explainNode(graph, query) {
2735
3078
  incoming
2736
3079
  };
2737
3080
  }
3081
+
3082
+ // src/impact.ts
3083
+ var DEPENDENCY_RELATIONS = /* @__PURE__ */ new Set([
3084
+ "calls",
3085
+ "imports",
3086
+ "imports_from",
3087
+ "inherits",
3088
+ "implements",
3089
+ "mixes_in",
3090
+ "embeds",
3091
+ "references",
3092
+ "re_exports",
3093
+ "method",
3094
+ "contains"
3095
+ ]);
3096
+ var DEFAULT_IMPACT_DEPTH = 3;
3097
+ var DEFAULT_IMPACT_LIMIT = 200;
3098
+ var CONFIDENCE_RANK2 = { EXTRACTED: 3, INFERRED: 2, AMBIGUOUS: 1 };
3099
+ function affectedBy(graph, nodeQuery, options = {}) {
3100
+ const target = resolveNode(graph, nodeQuery);
3101
+ if (!target) return null;
3102
+ const maxDepth = options.maxDepth ?? DEFAULT_IMPACT_DEPTH;
3103
+ const limit = options.limit ?? DEFAULT_IMPACT_LIMIT;
3104
+ const affected = [];
3105
+ const visited = /* @__PURE__ */ new Set([target.id]);
3106
+ let frontier = [target.id];
3107
+ let truncated = false;
3108
+ for (let depth = 1; depth <= maxDepth && frontier.length > 0 && !truncated; depth++) {
3109
+ const nextFrontier = /* @__PURE__ */ new Map();
3110
+ for (const current of [...frontier].sort((a, b) => a.localeCompare(b))) {
3111
+ graph.forEachInEdge(current, (_edge, edgeAttrs, source) => {
3112
+ if (visited.has(source)) return;
3113
+ const relation = edgeAttrs.relation;
3114
+ if (!DEPENDENCY_RELATIONS.has(relation)) return;
3115
+ const confidence = edgeAttrs.confidence;
3116
+ const existing = nextFrontier.get(source);
3117
+ if (existing && CONFIDENCE_RANK2[existing.via.confidence] >= CONFIDENCE_RANK2[confidence]) return;
3118
+ const attrs = graph.getNodeAttributes(source);
3119
+ nextFrontier.set(source, {
3120
+ id: source,
3121
+ label: attrs.label ?? source,
3122
+ sourceFile: attrs.sourceFile ?? "",
3123
+ sourceLocation: attrs.sourceLocation ?? "",
3124
+ depth,
3125
+ via: { relation, confidence, dependsOn: current }
3126
+ });
3127
+ });
3128
+ }
3129
+ const roundNodes = [...nextFrontier.values()].sort((a, b) => a.id.localeCompare(b.id));
3130
+ for (const node of roundNodes) {
3131
+ if (affected.length >= limit) {
3132
+ truncated = true;
3133
+ break;
3134
+ }
3135
+ visited.add(node.id);
3136
+ affected.push(node);
3137
+ }
3138
+ frontier = roundNodes.filter((n) => visited.has(n.id)).map((n) => n.id);
3139
+ }
3140
+ const byConfidence = { EXTRACTED: 0, INFERRED: 0, AMBIGUOUS: 0 };
3141
+ for (const node of affected) byConfidence[node.via.confidence] += 1;
3142
+ return { target, affected, byConfidence, maxDepth, truncated };
3143
+ }
3144
+
3145
+ // src/merge.ts
3146
+ var import_graphology4 = __toESM(require("graphology"), 1);
3147
+ var CONFIDENCE_RANK3 = { EXTRACTED: 3, INFERRED: 2, AMBIGUOUS: 1 };
3148
+ function strongerConfidence2(a, b) {
3149
+ return CONFIDENCE_RANK3[a] >= CONFIDENCE_RANK3[b] ? a : b;
3150
+ }
3151
+ function isShared(id) {
3152
+ return id.startsWith("external:") || id.startsWith("module:");
3153
+ }
3154
+ function namespaced(project, id) {
3155
+ return isShared(id) ? id : `${project}/${id}`;
3156
+ }
3157
+ function mergeGraphs(entries) {
3158
+ const names = entries.map((e) => e.name);
3159
+ const duplicate = names.find((name, i) => names.indexOf(name) !== i);
3160
+ if (duplicate !== void 0) {
3161
+ throw new Error(`Duplicate project name in merge: "${duplicate}" \u2014 every entry needs a unique name.`);
3162
+ }
3163
+ const merged = new import_graphology4.default({ type: "directed", multi: true, allowSelfLoops: true });
3164
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
3165
+ for (const { name, graph } of sorted) {
3166
+ const nodeIds = [...graph.nodes()].sort((a, b) => a.localeCompare(b));
3167
+ for (const id of nodeIds) {
3168
+ const attrs = graph.getNodeAttributes(id);
3169
+ const newId = namespaced(name, id);
3170
+ if (merged.hasNode(newId)) {
3171
+ merged.setNodeAttribute(newId, "project", "(shared)");
3172
+ continue;
3173
+ }
3174
+ const sourceFile = attrs.sourceFile ?? "";
3175
+ merged.addNode(newId, {
3176
+ label: attrs.label ?? id,
3177
+ sourceFile: isShared(id) || sourceFile === "" ? sourceFile : `${name}/${sourceFile}`,
3178
+ sourceLocation: attrs.sourceLocation ?? "",
3179
+ project: name
3180
+ });
3181
+ }
3182
+ const edgeKeys = [...graph.edges()].sort((a, b) => a.localeCompare(b));
3183
+ for (const edgeKey of edgeKeys) {
3184
+ const attrs = graph.getEdgeAttributes(edgeKey);
3185
+ const source = namespaced(name, graph.source(edgeKey));
3186
+ const target = namespaced(name, graph.target(edgeKey));
3187
+ const relation = String(attrs.relation);
3188
+ const confidence = attrs.confidence;
3189
+ const key = `${source}|${relation}|${target}`;
3190
+ if (merged.hasEdge(key)) {
3191
+ const existing = merged.getEdgeAttribute(key, "confidence");
3192
+ merged.setEdgeAttribute(key, "confidence", strongerConfidence2(existing, confidence));
3193
+ continue;
3194
+ }
3195
+ merged.addEdgeWithKey(key, source, target, { relation, confidence });
3196
+ }
3197
+ }
3198
+ return merged;
3199
+ }
3200
+
3201
+ // src/extractors/mysql.ts
3202
+ var schemaId = (schema) => `db:${schema}`;
3203
+ var tableId = (schema, table) => `db:${schema}::${table}`;
3204
+ var columnId = (schema, table, column) => `db:${schema}::${table}.${column}`;
3205
+ async function defaultQueryFn(dsn) {
3206
+ const { connection } = validateDsn(dsn);
3207
+ const mysql = await import("mysql2/promise");
3208
+ const conn = await mysql.createConnection({
3209
+ host: connection.host,
3210
+ port: connection.port,
3211
+ user: connection.user,
3212
+ password: connection.password,
3213
+ database: connection.database
3214
+ });
3215
+ return {
3216
+ query: async (sql, params) => {
3217
+ const [rows] = await conn.execute(sql, [...params]);
3218
+ return rows;
3219
+ },
3220
+ close: () => conn.end()
3221
+ };
3222
+ }
3223
+ async function extractMysql(dsn, queryFn) {
3224
+ const { safeDisplay, connection } = validateDsn(dsn);
3225
+ const schema = connection.database;
3226
+ let query = queryFn;
3227
+ let close;
3228
+ if (!query) {
3229
+ const live = await defaultQueryFn(dsn);
3230
+ query = live.query;
3231
+ close = live.close;
3232
+ }
3233
+ try {
3234
+ const nodes = [];
3235
+ const edges = [];
3236
+ nodes.push({ id: schemaId(schema), label: schema, sourceFile: safeDisplay, sourceLocation: schema });
3237
+ const tableRows = await query(
3238
+ "SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
3239
+ [schema]
3240
+ );
3241
+ const tables = tableRows.map((row) => ({
3242
+ name: String(row.TABLE_NAME),
3243
+ isView: String(row.TABLE_TYPE).toUpperCase() === "VIEW"
3244
+ }));
3245
+ const tableNames = new Set(tables.map((t) => t.name));
3246
+ for (const table of tables) {
3247
+ nodes.push({
3248
+ id: tableId(schema, table.name),
3249
+ label: `${table.isView ? "view" : "table"} ${table.name}`,
3250
+ sourceFile: safeDisplay,
3251
+ sourceLocation: table.name
3252
+ });
3253
+ edges.push({
3254
+ source: schemaId(schema),
3255
+ target: tableId(schema, table.name),
3256
+ relation: "contains",
3257
+ confidence: "EXTRACTED"
3258
+ });
3259
+ }
3260
+ const columnRows = await query(
3261
+ "SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME, ORDINAL_POSITION",
3262
+ [schema]
3263
+ );
3264
+ for (const row of columnRows) {
3265
+ const table = String(row.TABLE_NAME);
3266
+ const column = String(row.COLUMN_NAME);
3267
+ if (!tableNames.has(table)) continue;
3268
+ nodes.push({
3269
+ id: columnId(schema, table, column),
3270
+ label: `${table}.${column}: ${String(row.COLUMN_TYPE)}`,
3271
+ sourceFile: safeDisplay,
3272
+ sourceLocation: `${table}.${column}`
3273
+ });
3274
+ edges.push({
3275
+ source: tableId(schema, table),
3276
+ target: columnId(schema, table, column),
3277
+ relation: "contains",
3278
+ confidence: "EXTRACTED"
3279
+ });
3280
+ }
3281
+ const fkRows = await query(
3282
+ "SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL ORDER BY TABLE_NAME, COLUMN_NAME",
3283
+ [schema]
3284
+ );
3285
+ for (const row of fkRows) {
3286
+ const table = String(row.TABLE_NAME);
3287
+ const column = String(row.COLUMN_NAME);
3288
+ const referenced = String(row.REFERENCED_TABLE_NAME);
3289
+ if (!tableNames.has(table) || !tableNames.has(referenced)) continue;
3290
+ edges.push({
3291
+ source: columnId(schema, table, column),
3292
+ target: tableId(schema, referenced),
3293
+ relation: "references",
3294
+ confidence: "EXTRACTED"
3295
+ });
3296
+ }
3297
+ const viewNames = tables.filter((t) => t.isView).map((t) => t.name);
3298
+ if (viewNames.length > 0) {
3299
+ await addViewEdges(query, schema, viewNames, tableNames, edges);
3300
+ }
3301
+ return { nodes, edges };
3302
+ } finally {
3303
+ await close?.();
3304
+ }
3305
+ }
3306
+ async function addViewEdges(query, schema, viewNames, tableNames, edges) {
3307
+ try {
3308
+ const usageRows = await query(
3309
+ "SELECT VIEW_NAME, TABLE_NAME FROM information_schema.VIEW_TABLE_USAGE WHERE VIEW_SCHEMA = ? AND TABLE_SCHEMA = ? ORDER BY VIEW_NAME, TABLE_NAME",
3310
+ [schema, schema]
3311
+ );
3312
+ for (const row of usageRows) {
3313
+ const view = String(row.VIEW_NAME);
3314
+ const table = String(row.TABLE_NAME);
3315
+ if (!tableNames.has(view) || !tableNames.has(table) || view === table) continue;
3316
+ edges.push({
3317
+ source: tableId(schema, view),
3318
+ target: tableId(schema, table),
3319
+ relation: "references",
3320
+ confidence: "EXTRACTED"
3321
+ });
3322
+ }
3323
+ return;
3324
+ } catch {
3325
+ }
3326
+ const definitionRows = await query(
3327
+ "SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.VIEWS WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
3328
+ [schema]
3329
+ );
3330
+ for (const row of definitionRows) {
3331
+ const view = String(row.TABLE_NAME);
3332
+ if (!viewNames.includes(view)) continue;
3333
+ const definition = String(row.VIEW_DEFINITION ?? "").toLowerCase();
3334
+ for (const table of [...tableNames].sort((a, b) => a.localeCompare(b))) {
3335
+ if (table === view) continue;
3336
+ if (new RegExp(`\\b${table.toLowerCase()}\\b`).test(definition)) {
3337
+ edges.push({
3338
+ source: tableId(schema, view),
3339
+ target: tableId(schema, table),
3340
+ relation: "references",
3341
+ confidence: "INFERRED"
3342
+ });
3343
+ }
3344
+ }
3345
+ }
3346
+ }
3347
+
3348
+ // src/memory.ts
3349
+ var import_node_crypto4 = require("crypto");
3350
+ var fs15 = __toESM(require("fs/promises"), 1);
3351
+ var path10 = __toESM(require("path"), 1);
3352
+ var OUTCOMES = /* @__PURE__ */ new Set(["useful", "dead_end", "corrected"]);
3353
+ var TYPES = /* @__PURE__ */ new Set(["query", "path", "explain", "affected"]);
3354
+ function validateResult(value) {
3355
+ if (!value.question) throw new Error("save-result requires --question");
3356
+ if (!value.answer) throw new Error("save-result requires --answer");
3357
+ if (!OUTCOMES.has(value.outcome)) {
3358
+ throw new Error(`--outcome must be one of: useful, dead_end, corrected (got "${value.outcome}")`);
3359
+ }
3360
+ if (!TYPES.has(value.type)) {
3361
+ throw new Error(`--type must be one of: query, path, explain, affected (got "${value.type}")`);
3362
+ }
3363
+ if (value.outcome === "corrected" && !value.correction) {
3364
+ throw new Error("--outcome corrected requires --correction with what the right answer was");
3365
+ }
3366
+ }
3367
+ async function saveResult(entry, memoryDir, now = /* @__PURE__ */ new Date()) {
3368
+ validateResult(entry);
3369
+ await fs15.mkdir(memoryDir, { recursive: true });
3370
+ const saved = { ...entry, savedAt: now.toISOString() };
3371
+ const hash = (0, import_node_crypto4.createHash)("sha256").update(JSON.stringify(saved)).digest("hex").slice(0, 8);
3372
+ const stamp = saved.savedAt.replace(/[:.]/g, "-");
3373
+ const filePath = path10.join(memoryDir, `result-${stamp}-${hash}.json`);
3374
+ await fs15.writeFile(filePath, JSON.stringify(saved, null, 2), "utf-8");
3375
+ return filePath;
3376
+ }
3377
+ async function loadResults(memoryDir) {
3378
+ let files;
3379
+ try {
3380
+ files = (await fs15.readdir(memoryDir)).filter((f) => f.startsWith("result-") && f.endsWith(".json"));
3381
+ } catch {
3382
+ return [];
3383
+ }
3384
+ const results = [];
3385
+ for (const file of files.sort((a, b) => a.localeCompare(b))) {
3386
+ try {
3387
+ const parsed = JSON.parse(await fs15.readFile(path10.join(memoryDir, file), "utf-8"));
3388
+ if (parsed.question && parsed.savedAt) results.push(parsed);
3389
+ } catch {
3390
+ }
3391
+ }
3392
+ return results;
3393
+ }
3394
+ function renderLessons(results, options = {}) {
3395
+ const halfLife = options.halfLifeDays ?? 30;
3396
+ const now = options.now ?? /* @__PURE__ */ new Date();
3397
+ const minCorroboration = options.minCorroboration ?? 2;
3398
+ const weightOf = (savedAt) => {
3399
+ const ageDays = Math.max(0, (now.getTime() - new Date(savedAt).getTime()) / 864e5);
3400
+ return Math.pow(0.5, ageDays / halfLife);
3401
+ };
3402
+ const signals = /* @__PURE__ */ new Map();
3403
+ const corrections = [];
3404
+ for (const result of results) {
3405
+ const weight = weightOf(result.savedAt);
3406
+ for (const node of result.nodes ?? []) {
3407
+ const signal = signals.get(node) ?? { useful: 0, deadEnd: 0, usefulCount: 0, deadEndCount: 0 };
3408
+ if (result.outcome === "useful") {
3409
+ signal.useful += weight;
3410
+ signal.usefulCount++;
3411
+ } else if (result.outcome === "dead_end") {
3412
+ signal.deadEnd += weight;
3413
+ signal.deadEndCount++;
3414
+ }
3415
+ signals.set(node, signal);
3416
+ }
3417
+ if (result.outcome === "corrected" && result.correction) {
3418
+ corrections.push({ question: result.question, correction: result.correction, savedAt: result.savedAt });
3419
+ }
3420
+ }
3421
+ const byScore = (kind) => [...signals.entries()].filter(([, s]) => kind === "useful" ? s.usefulCount >= minCorroboration : s.deadEndCount > 0).sort((a, b) => b[1][kind] - a[1][kind] || a[0].localeCompare(b[0])).slice(0, 10);
3422
+ const lines = [
3423
+ "# Graph lessons",
3424
+ "",
3425
+ `_Aggregated from ${results.length} saved result(s); signal half-life ${halfLife} day(s)._`,
3426
+ "",
3427
+ "## Nodes that keep answering",
3428
+ ""
3429
+ ];
3430
+ const useful = byScore("useful");
3431
+ if (useful.length === 0) {
3432
+ lines.push(`(none yet with >= ${minCorroboration} corroborating results \u2014 keep saving results)`);
3433
+ } else {
3434
+ for (const [node, s] of useful) {
3435
+ lines.push(`- **${node}** \u2014 score ${s.useful.toFixed(2)} across ${s.usefulCount} useful result(s)`);
3436
+ }
3437
+ }
3438
+ lines.push("", "## Dead ends", "");
3439
+ const deadEnds = byScore("deadEnd");
3440
+ if (deadEnds.length === 0) {
3441
+ lines.push("(none recorded)");
3442
+ } else {
3443
+ for (const [node, s] of deadEnds) {
3444
+ lines.push(`- **${node}** \u2014 score ${s.deadEnd.toFixed(2)} across ${s.deadEndCount} dead-end result(s)`);
3445
+ }
3446
+ }
3447
+ lines.push("", "## Corrections", "");
3448
+ if (corrections.length === 0) {
3449
+ lines.push("(none recorded)");
3450
+ } else {
3451
+ corrections.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
3452
+ for (const c of corrections.slice(0, 20)) {
3453
+ lines.push(`- (${c.savedAt.slice(0, 10)}) Q: ${c.question}`);
3454
+ lines.push(` Right answer: ${c.correction}`);
3455
+ }
3456
+ }
3457
+ lines.push("");
3458
+ return lines.join("\n");
3459
+ }
3460
+
3461
+ // src/context.ts
3462
+ var DEFAULT_CONTEXT_BUDGET = 4e3;
3463
+ var DEFAULT_MAX_SEEDS2 = 8;
3464
+ var DEFAULT_MAX_SNIPPET_LINES = 60;
3465
+ var NEIGHBOR_DECAY = 0.4;
3466
+ var tokensOf = (text) => Math.ceil(text.length / 4);
3467
+ function lineNumberOf(sourceLocation) {
3468
+ const match = /^L(\d+)$/.exec(sourceLocation);
3469
+ return match ? Number(match[1]) : null;
3470
+ }
3471
+ function isReadableFile(sourceFile) {
3472
+ return sourceFile !== "" && !sourceFile.startsWith("<") && !sourceFile.includes("://");
3473
+ }
3474
+ function rankForTask(graph, task, maxSeeds = DEFAULT_MAX_SEEDS2) {
3475
+ const seeds = scoreNodes(graph, task).slice(0, maxSeeds);
3476
+ const ranked = /* @__PURE__ */ new Map();
3477
+ for (const seed of seeds) {
3478
+ ranked.set(seed.id, { id: seed.id, score: seed.score, reason: "matches the task" });
3479
+ }
3480
+ for (const seed of seeds) {
3481
+ graph.forEachEdge(seed.id, (_edge, attrs, source, target) => {
3482
+ const neighbor = source === seed.id ? target : source;
3483
+ if (ranked.has(neighbor)) return;
3484
+ const relation = String(attrs.relation);
3485
+ const direction = source === seed.id ? `${relation} ->` : `<- ${relation}`;
3486
+ ranked.set(neighbor, {
3487
+ id: neighbor,
3488
+ score: seed.score * NEIGHBOR_DECAY,
3489
+ reason: `${direction} ${seed.label}`
3490
+ });
3491
+ });
3492
+ }
3493
+ return [...ranked.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
3494
+ }
3495
+ function snippetRange(startLine, fileLineCount, entityStartsInFile, maxLines) {
3496
+ const nextStart = entityStartsInFile.find((l) => l > startLine);
3497
+ const hardEnd = nextStart !== void 0 ? nextStart - 1 : fileLineCount;
3498
+ return { start: startLine, end: Math.min(hardEnd, startLine + maxLines - 1) };
3499
+ }
3500
+ async function buildContextPack(graph, task, readFile14, options = {}) {
3501
+ const tokenBudget = options.tokenBudget ?? DEFAULT_CONTEXT_BUDGET;
3502
+ const maxSnippetLines = options.maxSnippetLines ?? DEFAULT_MAX_SNIPPET_LINES;
3503
+ const ranked = rankForTask(graph, task, options.maxSeeds);
3504
+ const entityStarts = /* @__PURE__ */ new Map();
3505
+ graph.forEachNode((_id, attrs) => {
3506
+ const file = attrs.sourceFile;
3507
+ const line = lineNumberOf(attrs.sourceLocation ?? "");
3508
+ if (file && line !== null) {
3509
+ const starts = entityStarts.get(file) ?? [];
3510
+ starts.push(line);
3511
+ entityStarts.set(file, starts);
3512
+ }
3513
+ });
3514
+ for (const starts of entityStarts.values()) starts.sort((a, b) => a - b);
3515
+ const fileCache = /* @__PURE__ */ new Map();
3516
+ const readLines = async (file) => {
3517
+ const cached = fileCache.get(file);
3518
+ if (cached !== void 0) return cached;
3519
+ let lines;
3520
+ try {
3521
+ lines = (await readFile14(file)).split("\n");
3522
+ } catch {
3523
+ lines = null;
3524
+ }
3525
+ fileCache.set(file, lines);
3526
+ return lines;
3527
+ };
3528
+ const snippets = [];
3529
+ const overflow = [];
3530
+ const coveredRanges = /* @__PURE__ */ new Map();
3531
+ let tokens = 0;
3532
+ for (const node of ranked) {
3533
+ const attrs = graph.getNodeAttributes(node.id);
3534
+ const file = attrs.sourceFile ?? "";
3535
+ const startLine = lineNumberOf(attrs.sourceLocation ?? "");
3536
+ if (!isReadableFile(file) || startLine === null) continue;
3537
+ const lines = await readLines(file);
3538
+ if (lines === null) continue;
3539
+ const { start, end } = snippetRange(startLine, lines.length, entityStarts.get(file) ?? [], maxSnippetLines);
3540
+ const covered = (coveredRanges.get(file) ?? []).some((r) => start >= r.start && end <= r.end);
3541
+ if (covered) continue;
3542
+ const code = lines.slice(start - 1, end).join("\n");
3543
+ const cost = tokensOf(code) + 15;
3544
+ if (tokens + cost > tokenBudget) {
3545
+ overflow.push({ nodeId: node.id, file: `${file}:L${startLine}` });
3546
+ continue;
3547
+ }
3548
+ tokens += cost;
3549
+ snippets.push({
3550
+ nodeId: node.id,
3551
+ label: attrs.label ?? node.id,
3552
+ file,
3553
+ startLine: start,
3554
+ endLine: end,
3555
+ code,
3556
+ reason: node.reason,
3557
+ score: node.score
3558
+ });
3559
+ const ranges = coveredRanges.get(file) ?? [];
3560
+ ranges.push({ start, end });
3561
+ coveredRanges.set(file, ranges);
3562
+ }
3563
+ return { task, snippets, tokens, tokenBudget, overflow };
3564
+ }
3565
+ function renderContextPack(pack) {
3566
+ const lines = [
3567
+ `# Context pack: ${pack.task}`,
3568
+ "",
3569
+ `_~${pack.tokens} of ${pack.tokenBudget} token budget, ${pack.snippets.length} snippet(s)._`,
3570
+ ""
3571
+ ];
3572
+ if (pack.snippets.length === 0) {
3573
+ lines.push("No matching code found \u2014 try different keywords.");
3574
+ return lines.join("\n");
3575
+ }
3576
+ for (const snippet of pack.snippets) {
3577
+ lines.push(`## ${snippet.label} \u2014 \`${snippet.file}:L${snippet.startLine}-L${snippet.endLine}\``);
3578
+ lines.push(`_${snippet.reason}_`);
3579
+ lines.push("```");
3580
+ lines.push(snippet.code);
3581
+ lines.push("```");
3582
+ lines.push("");
3583
+ }
3584
+ if (pack.overflow.length > 0) {
3585
+ lines.push(`## Didn't fit the budget (${pack.overflow.length})`);
3586
+ for (const item of pack.overflow.slice(0, 15)) {
3587
+ lines.push(`- ${item.nodeId} (${item.file})`);
3588
+ }
3589
+ lines.push("");
3590
+ }
3591
+ return lines.join("\n");
3592
+ }
3593
+
3594
+ // src/testmap.ts
3595
+ var TEST_FILE_PATTERNS = [
3596
+ /(^|\/)tests?\//,
3597
+ // tests/ or test/ directory
3598
+ /(^|\/)__tests__\//,
3599
+ /(^|\/)spec\//,
3600
+ /\.test\.[cm]?[jt]sx?$/,
3601
+ /\.spec\.[cm]?[jt]sx?$/,
3602
+ /(^|\/)test_[^/]+\.py$/,
3603
+ /_test\.py$/,
3604
+ /_test\.go$/,
3605
+ /Tests?\.(java|cs)$/,
3606
+ /_spec\.rb$/,
3607
+ /_test\.rb$/
3608
+ ];
3609
+ function isTestFile(path12) {
3610
+ return TEST_FILE_PATTERNS.some((p) => p.test(path12));
3611
+ }
3612
+ var TEST_REACH_DEPTH = 4;
3613
+ var TEST_REACH_LIMIT = 2e3;
3614
+ function selectionFromImpact(graph, targetIds, targetLabel) {
3615
+ const best = /* @__PURE__ */ new Map();
3616
+ let affectedNodeCount = 0;
3617
+ for (const id of targetIds) {
3618
+ const impact = affectedBy(graph, id, { maxDepth: TEST_REACH_DEPTH, limit: TEST_REACH_LIMIT });
3619
+ if (!impact) continue;
3620
+ affectedNodeCount += impact.affected.length;
3621
+ for (const node of impact.affected) {
3622
+ if (!isTestFile(node.sourceFile)) continue;
3623
+ const existing = best.get(node.sourceFile);
3624
+ if (!existing || node.depth < existing.depth) {
3625
+ best.set(node.sourceFile, { depth: node.depth, via: node.via.dependsOn });
3626
+ }
3627
+ }
3628
+ }
3629
+ const testFiles = [...best.entries()].map(([file, info]) => ({ file, depth: info.depth, via: info.via })).sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
3630
+ return { target: targetLabel, testFiles, affectedNodeCount };
3631
+ }
3632
+ function testsForNode(graph, nodeQuery) {
3633
+ const match = resolveNode(graph, nodeQuery);
3634
+ if (!match) return null;
3635
+ return selectionFromImpact(graph, [match.id], match.id);
3636
+ }
3637
+ function testsForChangedFiles(graph, changedFiles) {
3638
+ const fileIds = [];
3639
+ const selfSelected = /* @__PURE__ */ new Map();
3640
+ for (const file of changedFiles) {
3641
+ if (isTestFile(file)) {
3642
+ selfSelected.set(file, { depth: 0, via: "(changed directly)" });
3643
+ continue;
3644
+ }
3645
+ if (graph.hasNode(file)) fileIds.push(file);
3646
+ }
3647
+ const selection = selectionFromImpact(graph, fileIds, changedFiles.join(", "));
3648
+ for (const [file, info] of selfSelected) {
3649
+ if (!selection.testFiles.some((t) => t.file === file)) {
3650
+ selection.testFiles.push({ file, depth: info.depth, via: info.via });
3651
+ }
3652
+ }
3653
+ selection.testFiles.sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
3654
+ return selection;
3655
+ }
3656
+
3657
+ // src/diff.ts
3658
+ var import_node_child_process = require("child_process");
3659
+ var fs16 = __toESM(require("fs/promises"), 1);
3660
+ var os = __toESM(require("os"), 1);
3661
+ var path11 = __toESM(require("path"), 1);
3662
+ var import_node_util = require("util");
3663
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
3664
+ async function graphForDirectory(root) {
3665
+ const manifest = collectFiles(root);
3666
+ const extractions = [];
3667
+ for (const relFile of manifest.files.code.sort((a, b) => a.localeCompare(b))) {
3668
+ extractions.push(await extract(path11.join(root, relFile), relFile));
3669
+ }
3670
+ resolveCrossFileReferences(extractions, { selfNames: await readSelfNames(root) });
3671
+ return buildGraph(extractions);
3672
+ }
3673
+ async function checkoutRev(repoRoot, rev) {
3674
+ const dest = await fs16.mkdtemp(path11.join(os.tmpdir(), `graphify-review-${rev.replace(/[^a-zA-Z0-9]/g, "_")}-`));
3675
+ const { stdout } = await execFileAsync(
3676
+ "git",
3677
+ ["-C", repoRoot, "archive", "--format=tar", rev],
3678
+ { encoding: "buffer", maxBuffer: 512 * 1024 * 1024 }
3679
+ );
3680
+ await new Promise((resolve5, reject) => {
3681
+ const tar = (0, import_node_child_process.execFile)("tar", ["-x", "-C", dest], (error) => error ? reject(error) : resolve5());
3682
+ tar.stdin?.end(stdout);
3683
+ });
3684
+ return dest;
3685
+ }
3686
+ function edgeSignature(graph, id) {
3687
+ const signature = /* @__PURE__ */ new Set();
3688
+ graph.forEachOutEdge(id, (_e, attrs, _s, target) => signature.add(`-> ${String(attrs.relation)} ${target}`));
3689
+ graph.forEachInEdge(id, (_e, attrs, source) => signature.add(`<- ${String(attrs.relation)} ${source}`));
3690
+ return signature;
3691
+ }
3692
+ function describeSymbol(graph, id) {
3693
+ const attrs = graph.getNodeAttributes(id);
3694
+ const impact = affectedBy(graph, id, { maxDepth: 3 });
3695
+ const tests = testsForNode(graph, id);
3696
+ return {
3697
+ id,
3698
+ label: attrs.label ?? id,
3699
+ sourceFile: attrs.sourceFile ?? "",
3700
+ blastRadius: impact?.affected.length ?? 0,
3701
+ tests: tests?.testFiles.map((t) => t.file) ?? []
3702
+ };
3703
+ }
3704
+ function entityIds(graph) {
3705
+ const ids = /* @__PURE__ */ new Set();
3706
+ graph.forEachNode((id) => {
3707
+ if (id.includes("::")) ids.add(id);
3708
+ });
3709
+ return ids;
3710
+ }
3711
+ function diffGraphs(baseGraph, headGraph, base, head) {
3712
+ const baseIds = entityIds(baseGraph);
3713
+ const headIds = entityIds(headGraph);
3714
+ const added = [];
3715
+ const removed = [];
3716
+ const rewired = [];
3717
+ for (const id of [...headIds].sort((a, b) => a.localeCompare(b))) {
3718
+ if (!baseIds.has(id)) {
3719
+ added.push(describeSymbol(headGraph, id));
3720
+ continue;
3721
+ }
3722
+ const before = edgeSignature(baseGraph, id);
3723
+ const after = edgeSignature(headGraph, id);
3724
+ const gained = [...after].filter((e) => !before.has(e)).sort((a, b) => a.localeCompare(b));
3725
+ const lost = [...before].filter((e) => !after.has(e)).sort((a, b) => a.localeCompare(b));
3726
+ if (gained.length > 0 || lost.length > 0) {
3727
+ rewired.push({ ...describeSymbol(headGraph, id), gainedEdges: gained, lostEdges: lost });
3728
+ }
3729
+ }
3730
+ for (const id of [...baseIds].sort((a, b) => a.localeCompare(b))) {
3731
+ if (!headIds.has(id)) removed.push(describeSymbol(baseGraph, id));
3732
+ }
3733
+ return { base, head, added, removed, rewired };
3734
+ }
3735
+ async function reviewRevisions(repoRoot, base, head = "HEAD") {
3736
+ const [baseDir, headDir] = await Promise.all([checkoutRev(repoRoot, base), checkoutRev(repoRoot, head)]);
3737
+ try {
3738
+ const [baseGraph, headGraph] = [await graphForDirectory(baseDir), await graphForDirectory(headDir)];
3739
+ return diffGraphs(baseGraph, headGraph, base, head);
3740
+ } finally {
3741
+ await Promise.all([
3742
+ fs16.rm(baseDir, { recursive: true, force: true }),
3743
+ fs16.rm(headDir, { recursive: true, force: true })
3744
+ ]);
3745
+ }
3746
+ }
3747
+ function renderReview(diff) {
3748
+ const lines = [
3749
+ `# Structural review: ${diff.base}..${diff.head}`,
3750
+ "",
3751
+ `${diff.added.length} symbol(s) added, ${diff.removed.length} removed, ${diff.rewired.length} rewired.`,
3752
+ ""
3753
+ ];
3754
+ const section = (title, symbols) => {
3755
+ lines.push(`## ${title} (${symbols.length})`, "");
3756
+ if (symbols.length === 0) {
3757
+ lines.push("(none)", "");
3758
+ return;
3759
+ }
3760
+ for (const s of symbols) {
3761
+ const tests = s.tests.length > 0 ? ` | tests: ${s.tests.join(", ")}` : " | tests: none found";
3762
+ lines.push(`- **${s.label}** (${s.sourceFile}) \u2014 blast radius ${s.blastRadius}${tests}`);
3763
+ const r = s;
3764
+ if (r.gainedEdges) {
3765
+ for (const e of r.gainedEdges.slice(0, 5)) lines.push(` - gained: ${e}`);
3766
+ for (const e of r.lostEdges.slice(0, 5)) lines.push(` - lost: ${e}`);
3767
+ }
3768
+ }
3769
+ lines.push("");
3770
+ };
3771
+ section("Removed \u2014 check every caller", diff.removed);
3772
+ section("Rewired \u2014 dependencies changed", diff.rewired);
3773
+ section("Added", diff.added);
3774
+ const allTests = /* @__PURE__ */ new Set();
3775
+ for (const s of [...diff.removed, ...diff.rewired, ...diff.added]) {
3776
+ for (const t of s.tests) allTests.add(t);
3777
+ }
3778
+ lines.push(`## Suggested test run (${allTests.size} file(s))`, "");
3779
+ for (const t of [...allTests].sort((a, b) => a.localeCompare(b))) lines.push(`- ${t}`);
3780
+ lines.push("");
3781
+ return lines.join("\n");
3782
+ }
3783
+
3784
+ // src/check.ts
3785
+ var DEPENDENCY_RELATIONS2 = /* @__PURE__ */ new Set([
3786
+ "calls",
3787
+ "imports",
3788
+ "imports_from",
3789
+ "inherits",
3790
+ "implements",
3791
+ "mixes_in",
3792
+ "embeds",
3793
+ "re_exports"
3794
+ ]);
3795
+ function globToRegExp(glob) {
3796
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
3797
+ const pattern = escaped.replace(
3798
+ /\*\*|\*|\?/g,
3799
+ (token) => token === "**" ? ".*" : token === "*" ? "[^/]*" : "[^/]"
3800
+ );
3801
+ return new RegExp(`^${pattern}$`);
3802
+ }
3803
+ function validateRules(value) {
3804
+ const config = value;
3805
+ if (!config || !Array.isArray(config.rules)) {
3806
+ throw new Error('Rules file must be JSON of shape { "rules": [{ "name", "from", "disallow": [...] }] }');
3807
+ }
3808
+ for (const rule of config.rules) {
3809
+ if (!rule.name || typeof rule.from !== "string" || !Array.isArray(rule.disallow)) {
3810
+ throw new Error(`Malformed rule "${rule.name ?? "(unnamed)"}" \u2014 need name, from, and disallow[].`);
3811
+ }
3812
+ }
3813
+ return config;
3814
+ }
3815
+ function checkRules(graph, config) {
3816
+ const compiled = config.rules.map((rule) => ({
3817
+ rule,
3818
+ from: globToRegExp(rule.from),
3819
+ disallow: rule.disallow.map(globToRegExp)
3820
+ }));
3821
+ const violations = [];
3822
+ graph.forEachEdge((_edge, attrs, source, target) => {
3823
+ const relation = String(attrs.relation);
3824
+ if (!DEPENDENCY_RELATIONS2.has(relation)) return;
3825
+ const fromFile = graph.getNodeAttribute(source, "sourceFile") ?? "";
3826
+ const toFile = graph.getNodeAttribute(target, "sourceFile") ?? "";
3827
+ if (!fromFile || !toFile || fromFile === toFile) return;
3828
+ for (const { rule, from, disallow } of compiled) {
3829
+ if (!from.test(fromFile)) continue;
3830
+ if (disallow.some((d) => d.test(toFile))) {
3831
+ violations.push({
3832
+ rule: rule.name,
3833
+ reason: rule.reason,
3834
+ fromFile,
3835
+ fromNode: source,
3836
+ toFile,
3837
+ toNode: target,
3838
+ relation
3839
+ });
3840
+ }
3841
+ }
3842
+ });
3843
+ violations.sort(
3844
+ (a, b) => a.rule.localeCompare(b.rule) || a.fromFile.localeCompare(b.fromFile) || a.toFile.localeCompare(b.toFile) || a.fromNode.localeCompare(b.fromNode)
3845
+ );
3846
+ return violations;
3847
+ }
2738
3848
  // Annotate the CommonJS export names for ESM import in node:
2739
3849
  0 && (module.exports = {
2740
3850
  EXTRACTOR_REGISTRY,
3851
+ ExtractionCache,
2741
3852
  ExtractionResultSchema,
2742
3853
  ExtractionValidationError,
3854
+ affectedBy,
2743
3855
  analyze,
3856
+ buildContextPack,
2744
3857
  buildGraph,
3858
+ checkRules,
2745
3859
  cluster,
2746
3860
  collectFiles,
3861
+ diffGraphs,
2747
3862
  explainNode,
2748
3863
  exportGraph,
2749
3864
  extract,
3865
+ extractMysql,
3866
+ globToRegExp,
3867
+ graphForDirectory,
3868
+ isTestFile,
2750
3869
  loadGraph,
3870
+ loadResults,
3871
+ mergeGraphs,
2751
3872
  queryGraph,
3873
+ rankForTask,
3874
+ renderContextPack,
3875
+ renderLessons,
2752
3876
  renderReport,
3877
+ renderReview,
3878
+ resolveCrossFileReferences,
2753
3879
  resolveNode,
3880
+ reviewRevisions,
2754
3881
  runPipeline,
3882
+ saveResult,
2755
3883
  scoreNodes,
2756
3884
  shortestPath,
2757
- validateExtraction
3885
+ testsForChangedFiles,
3886
+ testsForNode,
3887
+ validateExtraction,
3888
+ validateRules
2758
3889
  });
2759
3890
  //# sourceMappingURL=index.cjs.map