@dreamtree-org/graphify 1.0.0 → 1.1.1

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.
@@ -127,7 +127,36 @@ function validateUrl(url) {
127
127
  }
128
128
  return parsed.toString();
129
129
  }
130
- function validateGraphPath(path11, base) {
130
+ function validateDsn(dsn) {
131
+ let parsed;
132
+ try {
133
+ parsed = new URL(dsn);
134
+ } catch {
135
+ throw new Error("Invalid DSN \u2014 expected mysql://user:pass@host:port/database");
136
+ }
137
+ if (parsed.protocol !== "mysql:") {
138
+ throw new Error(`DSN scheme not supported: "${parsed.protocol}" (only mysql: is supported)`);
139
+ }
140
+ const database = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
141
+ if (!database || database.includes("/")) {
142
+ throw new Error("DSN must name exactly one database, e.g. mysql://localhost:3306/mydb");
143
+ }
144
+ if (!parsed.hostname) {
145
+ throw new Error("DSN must include a host, e.g. mysql://localhost:3306/mydb");
146
+ }
147
+ const port = parsed.port ? Number(parsed.port) : DEFAULT_MYSQL_PORT;
148
+ return {
149
+ safeDisplay: `mysql://${parsed.hostname}:${port}/${database}`,
150
+ connection: {
151
+ host: parsed.hostname,
152
+ port,
153
+ user: decodeURIComponent(parsed.username) || "root",
154
+ password: decodeURIComponent(parsed.password),
155
+ database
156
+ }
157
+ };
158
+ }
159
+ function validateGraphPath(path17, base) {
131
160
  const baseDir = base ?? nodePath.join(process.cwd(), "graphify-out");
132
161
  let resolvedBase;
133
162
  try {
@@ -135,7 +164,7 @@ function validateGraphPath(path11, base) {
135
164
  } catch {
136
165
  throw new Error(`Base directory does not exist: ${baseDir}`);
137
166
  }
138
- const candidate = nodePath.isAbsolute(path11) ? path11 : nodePath.join(resolvedBase, path11);
167
+ const candidate = nodePath.isAbsolute(path17) ? path17 : nodePath.join(resolvedBase, path17);
139
168
  const resolvedCandidate = nodePath.resolve(candidate);
140
169
  let realCandidate = resolvedCandidate;
141
170
  try {
@@ -145,7 +174,7 @@ function validateGraphPath(path11, base) {
145
174
  const relative4 = nodePath.relative(resolvedBase, realCandidate);
146
175
  const escapes = relative4 === ".." || relative4.startsWith(`..${nodePath.sep}`) || nodePath.isAbsolute(relative4);
147
176
  if (escapes) {
148
- throw new Error(`Path escapes graphify-out/: ${path11}`);
177
+ throw new Error(`Path escapes graphify-out/: ${path17}`);
149
178
  }
150
179
  return realCandidate;
151
180
  }
@@ -157,7 +186,7 @@ function sanitizeLabel(text) {
157
186
  function escapeHtml(text) {
158
187
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
159
188
  }
160
- var import_node_crypto2, dns, http, https, fs, net, nodePath, ALLOWED_SCHEMES, MAX_FETCH_BYTES, MAX_TEXT_BYTES, MAX_LABEL_LEN, BLOCKED_HOSTNAMES, BLOCKED_IPV4_CIDRS;
189
+ var import_node_crypto2, dns, http, https, fs, net, nodePath, ALLOWED_SCHEMES, MAX_FETCH_BYTES, MAX_TEXT_BYTES, MAX_LABEL_LEN, BLOCKED_HOSTNAMES, BLOCKED_IPV4_CIDRS, DEFAULT_MYSQL_PORT;
161
190
  var init_security = __esm({
162
191
  "src/security.ts"() {
163
192
  "use strict";
@@ -213,6 +242,7 @@ var init_security = __esm({
213
242
  "255.255.255.255/32"
214
243
  // broadcast
215
244
  ];
245
+ DEFAULT_MYSQL_PORT = 3306;
216
246
  }
217
247
  });
218
248
 
@@ -236,8 +266,43 @@ var init_graphStore = __esm({
236
266
  });
237
267
 
238
268
  // src/query.ts
269
+ function splitCamelCase(text) {
270
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
271
+ }
239
272
  function tokenize(text) {
240
- return text.toLowerCase().split(/[^a-z0-9_.]+/).filter((token) => token.length > 1 && !STOPWORDS.has(token));
273
+ return splitCamelCase(text).toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 1 && !STOPWORDS.has(token));
274
+ }
275
+ function subtokenize(text) {
276
+ return splitCamelCase(text).toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 1);
277
+ }
278
+ function osaDistance(a, b) {
279
+ let prev2 = [];
280
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
281
+ let curr = new Array(b.length + 1).fill(0);
282
+ for (let i = 1; i <= a.length; i++) {
283
+ curr[0] = i;
284
+ for (let j = 1; j <= b.length; j++) {
285
+ const substitution = a[i - 1] === b[j - 1] ? 0 : 1;
286
+ let best = Math.min(
287
+ prev[j] + 1,
288
+ curr[j - 1] + 1,
289
+ prev[j - 1] + substitution
290
+ );
291
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
292
+ best = Math.min(best, prev2[j - 2] + 1);
293
+ }
294
+ curr[j] = best;
295
+ }
296
+ [prev2, prev, curr] = [prev, curr, new Array(b.length + 1).fill(0)];
297
+ }
298
+ return prev[b.length];
299
+ }
300
+ function fuzzySimilarity(a, b) {
301
+ if (a === b) return 1;
302
+ const maxLen = Math.max(a.length, b.length);
303
+ if (maxLen === 0) return 1;
304
+ if (Math.abs(a.length - b.length) / maxLen > 1 - FUZZY_THRESHOLD) return 0;
305
+ return 1 - osaDistance(a, b) / maxLen;
241
306
  }
242
307
  function scoreNodes(graph, query) {
243
308
  const tokens = tokenize(query);
@@ -245,9 +310,25 @@ function scoreNodes(graph, query) {
245
310
  graph.forEachNode((id, attributes) => {
246
311
  const label = attributes.label ?? id;
247
312
  const haystack = `${label} ${id}`.toLowerCase();
313
+ const subtokens = new Set(subtokenize(`${label} ${id}`));
248
314
  let score = 0;
249
315
  for (const token of tokens) {
250
- if (haystack.includes(token)) score += 1;
316
+ if (subtokens.has(token)) {
317
+ score += SUBTOKEN_MATCH_SCORE;
318
+ continue;
319
+ }
320
+ if (haystack.includes(token)) {
321
+ score += SUBSTRING_MATCH_SCORE;
322
+ continue;
323
+ }
324
+ if (token.length >= FUZZY_MIN_TOKEN_LEN) {
325
+ let best = 0;
326
+ for (const subtoken of subtokens) {
327
+ const similarity = fuzzySimilarity(token, subtoken);
328
+ if (similarity > best) best = similarity;
329
+ }
330
+ if (best >= FUZZY_THRESHOLD) score += best * FUZZY_MATCH_WEIGHT;
331
+ }
251
332
  }
252
333
  if (score > 0) matches.push({ id, label, score });
253
334
  });
@@ -258,22 +339,34 @@ function resolveNode(graph, query) {
258
339
  const matches = scoreNodes(graph, query);
259
340
  return matches[0] ?? null;
260
341
  }
342
+ function nodeTokenCost(graph, id) {
343
+ const attrs = graph.getNodeAttributes(id);
344
+ const label = attrs.label ?? id;
345
+ const sourceFile = attrs.sourceFile ?? "";
346
+ return Math.ceil((id.length * 2 + label.length + sourceFile.length + 24) / 4);
347
+ }
261
348
  function queryGraph(graph, question, options = {}) {
262
349
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
263
350
  const maxSeeds = options.maxSeeds ?? DEFAULT_MAX_SEEDS;
264
- const budget = options.budget ?? DEFAULT_BUDGET;
351
+ const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
352
+ const budget = options.budget ?? Math.max(DEFAULT_BUDGET, Math.ceil(tokenBudget * NODES_PER_TOKEN));
265
353
  const allMatches = scoreNodes(graph, question);
266
354
  const seeds = allMatches.length > 0 ? allMatches.slice(0, maxSeeds) : [];
267
355
  const visited = /* @__PURE__ */ new Map();
268
356
  const frontier = seeds.map((s) => ({ id: s.id, depth: 0 }));
269
- for (const seed of seeds) visited.set(seed.id, 0);
270
- while (frontier.length > 0 && visited.size < budget) {
357
+ let spentTokens = 0;
358
+ for (const seed of seeds) {
359
+ visited.set(seed.id, 0);
360
+ spentTokens += nodeTokenCost(graph, seed.id);
361
+ }
362
+ while (frontier.length > 0 && visited.size < budget && spentTokens < tokenBudget) {
271
363
  const current = options.dfs ? frontier.pop() : frontier.shift();
272
364
  if (current.depth >= maxDepth) continue;
273
365
  const neighbors = [...graph.neighbors(current.id)].sort((a, b) => a.localeCompare(b));
274
366
  for (const neighbor of neighbors) {
275
- if (visited.has(neighbor) || visited.size >= budget) continue;
367
+ if (visited.has(neighbor) || visited.size >= budget || spentTokens >= tokenBudget) continue;
276
368
  visited.set(neighbor, current.depth + 1);
369
+ spentTokens += nodeTokenCost(graph, neighbor);
277
370
  frontier.push({ id: neighbor, depth: current.depth + 1 });
278
371
  }
279
372
  }
@@ -301,7 +394,30 @@ function queryGraph(graph, question, options = {}) {
301
394
  edges.sort(
302
395
  (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) || a.relation.localeCompare(b.relation)
303
396
  );
304
- return { seeds, visited: visitedNodes, edges };
397
+ return enforceTokenBudget({ seeds, visited: visitedNodes, edges }, tokenBudget);
398
+ }
399
+ function enforceTokenBudget(result, tokenBudget) {
400
+ const cost = (value) => Math.ceil(JSON.stringify(value).length / 4);
401
+ const visited = [...result.visited];
402
+ let edges = [...result.edges];
403
+ let total = cost(result.seeds) + visited.reduce((s, n) => s + cost(n), 0) + edges.reduce((s, e) => s + cost(e), 0);
404
+ const seedIds = new Set(result.seeds.map((s) => s.id));
405
+ while (total > tokenBudget && visited.length > 0) {
406
+ const last = visited[visited.length - 1];
407
+ if (seedIds.has(last.id)) break;
408
+ visited.pop();
409
+ total -= cost(last);
410
+ const remaining = [];
411
+ for (const edge of edges) {
412
+ if (edge.source === last.id || edge.target === last.id) {
413
+ total -= cost(edge);
414
+ } else {
415
+ remaining.push(edge);
416
+ }
417
+ }
418
+ edges = remaining;
419
+ }
420
+ return { seeds: result.seeds, visited, edges };
305
421
  }
306
422
  function shortestPath(graph, fromQuery, toQuery) {
307
423
  const from = resolveNode(graph, fromQuery);
@@ -327,25 +443,25 @@ function shortestPath(graph, fromQuery, toQuery) {
327
443
  if (!visited.has(to.id)) {
328
444
  return { from, to, found: false, path: [], edges: [] };
329
445
  }
330
- const path11 = [to.id];
446
+ const path17 = [to.id];
331
447
  let cursor = to.id;
332
448
  while (cursor !== from.id) {
333
449
  const prev = predecessor.get(cursor);
334
450
  if (!prev) break;
335
- path11.unshift(prev);
451
+ path17.unshift(prev);
336
452
  cursor = prev;
337
453
  }
338
454
  const edges = [];
339
- for (let i = 0; i < path11.length - 1; i++) {
340
- const a = path11[i];
341
- const b = path11[i + 1];
455
+ for (let i = 0; i < path17.length - 1; i++) {
456
+ const a = path17[i];
457
+ const b = path17[i + 1];
342
458
  const key = graph.edges(a, b)[0] ?? graph.edges(b, a)[0];
343
459
  if (key) {
344
460
  const attrs = graph.getEdgeAttributes(key);
345
461
  edges.push({ source: a, target: b, relation: String(attrs.relation), confidence: String(attrs.confidence) });
346
462
  }
347
463
  }
348
- return { from, to, found: true, path: path11, edges };
464
+ return { from, to, found: true, path: path17, edges };
349
465
  }
350
466
  function explainNode(graph, query) {
351
467
  const match = resolveNode(graph, query);
@@ -371,7 +487,7 @@ function explainNode(graph, query) {
371
487
  incoming
372
488
  };
373
489
  }
374
- var STOPWORDS, DEFAULT_MAX_DEPTH, DEFAULT_MAX_SEEDS, DEFAULT_BUDGET;
490
+ var STOPWORDS, FUZZY_MIN_TOKEN_LEN, FUZZY_THRESHOLD, SUBTOKEN_MATCH_SCORE, SUBSTRING_MATCH_SCORE, FUZZY_MATCH_WEIGHT, DEFAULT_MAX_DEPTH, DEFAULT_MAX_SEEDS, DEFAULT_BUDGET, DEFAULT_TOKEN_BUDGET, NODES_PER_TOKEN;
375
491
  var init_query = __esm({
376
492
  "src/query.ts"() {
377
493
  "use strict";
@@ -407,9 +523,300 @@ var init_query = __esm({
407
523
  "does",
408
524
  "that's"
409
525
  ]);
526
+ FUZZY_MIN_TOKEN_LEN = 3;
527
+ FUZZY_THRESHOLD = 0.7;
528
+ SUBTOKEN_MATCH_SCORE = 1;
529
+ SUBSTRING_MATCH_SCORE = 0.6;
530
+ FUZZY_MATCH_WEIGHT = 0.5;
410
531
  DEFAULT_MAX_DEPTH = 2;
411
532
  DEFAULT_MAX_SEEDS = 5;
412
533
  DEFAULT_BUDGET = 40;
534
+ DEFAULT_TOKEN_BUDGET = 2e3;
535
+ NODES_PER_TOKEN = 1 / 10;
536
+ }
537
+ });
538
+
539
+ // src/impact.ts
540
+ function affectedBy(graph, nodeQuery, options = {}) {
541
+ const target = resolveNode(graph, nodeQuery);
542
+ if (!target) return null;
543
+ const maxDepth = options.maxDepth ?? DEFAULT_IMPACT_DEPTH;
544
+ const limit = options.limit ?? DEFAULT_IMPACT_LIMIT;
545
+ const affected = [];
546
+ const visited = /* @__PURE__ */ new Set([target.id]);
547
+ let frontier = [target.id];
548
+ let truncated = false;
549
+ for (let depth = 1; depth <= maxDepth && frontier.length > 0 && !truncated; depth++) {
550
+ const nextFrontier = /* @__PURE__ */ new Map();
551
+ for (const current of [...frontier].sort((a, b) => a.localeCompare(b))) {
552
+ graph.forEachInEdge(current, (_edge, edgeAttrs, source) => {
553
+ if (visited.has(source)) return;
554
+ const relation = edgeAttrs.relation;
555
+ if (!DEPENDENCY_RELATIONS.has(relation)) return;
556
+ const confidence = edgeAttrs.confidence;
557
+ const existing = nextFrontier.get(source);
558
+ if (existing && CONFIDENCE_RANK2[existing.via.confidence] >= CONFIDENCE_RANK2[confidence]) return;
559
+ const attrs = graph.getNodeAttributes(source);
560
+ nextFrontier.set(source, {
561
+ id: source,
562
+ label: attrs.label ?? source,
563
+ sourceFile: attrs.sourceFile ?? "",
564
+ sourceLocation: attrs.sourceLocation ?? "",
565
+ depth,
566
+ via: { relation, confidence, dependsOn: current }
567
+ });
568
+ });
569
+ }
570
+ const roundNodes = [...nextFrontier.values()].sort((a, b) => a.id.localeCompare(b.id));
571
+ for (const node of roundNodes) {
572
+ if (affected.length >= limit) {
573
+ truncated = true;
574
+ break;
575
+ }
576
+ visited.add(node.id);
577
+ affected.push(node);
578
+ }
579
+ frontier = roundNodes.filter((n) => visited.has(n.id)).map((n) => n.id);
580
+ }
581
+ const byConfidence = { EXTRACTED: 0, INFERRED: 0, AMBIGUOUS: 0 };
582
+ for (const node of affected) byConfidence[node.via.confidence] += 1;
583
+ return { target, affected, byConfidence, maxDepth, truncated };
584
+ }
585
+ var DEPENDENCY_RELATIONS, DEFAULT_IMPACT_DEPTH, DEFAULT_IMPACT_LIMIT, CONFIDENCE_RANK2;
586
+ var init_impact = __esm({
587
+ "src/impact.ts"() {
588
+ "use strict";
589
+ init_cjs_shims();
590
+ init_query();
591
+ DEPENDENCY_RELATIONS = /* @__PURE__ */ new Set([
592
+ "calls",
593
+ "imports",
594
+ "imports_from",
595
+ "inherits",
596
+ "implements",
597
+ "mixes_in",
598
+ "embeds",
599
+ "references",
600
+ "re_exports",
601
+ "method",
602
+ "contains"
603
+ ]);
604
+ DEFAULT_IMPACT_DEPTH = 3;
605
+ DEFAULT_IMPACT_LIMIT = 200;
606
+ CONFIDENCE_RANK2 = { EXTRACTED: 3, INFERRED: 2, AMBIGUOUS: 1 };
607
+ }
608
+ });
609
+
610
+ // src/context.ts
611
+ function lineNumberOf(sourceLocation) {
612
+ const match = /^L(\d+)$/.exec(sourceLocation);
613
+ return match ? Number(match[1]) : null;
614
+ }
615
+ function isReadableFile(sourceFile) {
616
+ return sourceFile !== "" && !sourceFile.startsWith("<") && !sourceFile.includes("://");
617
+ }
618
+ function rankForTask(graph, task, maxSeeds = DEFAULT_MAX_SEEDS2) {
619
+ const seeds = scoreNodes(graph, task).slice(0, maxSeeds);
620
+ const ranked = /* @__PURE__ */ new Map();
621
+ for (const seed of seeds) {
622
+ ranked.set(seed.id, { id: seed.id, score: seed.score, reason: "matches the task" });
623
+ }
624
+ for (const seed of seeds) {
625
+ graph.forEachEdge(seed.id, (_edge, attrs, source, target) => {
626
+ const neighbor = source === seed.id ? target : source;
627
+ if (ranked.has(neighbor)) return;
628
+ const relation = String(attrs.relation);
629
+ const direction = source === seed.id ? `${relation} ->` : `<- ${relation}`;
630
+ ranked.set(neighbor, {
631
+ id: neighbor,
632
+ score: seed.score * NEIGHBOR_DECAY,
633
+ reason: `${direction} ${seed.label}`
634
+ });
635
+ });
636
+ }
637
+ return [...ranked.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
638
+ }
639
+ function snippetRange(startLine, fileLineCount, entityStartsInFile, maxLines) {
640
+ const nextStart = entityStartsInFile.find((l) => l > startLine);
641
+ const hardEnd = nextStart !== void 0 ? nextStart - 1 : fileLineCount;
642
+ return { start: startLine, end: Math.min(hardEnd, startLine + maxLines - 1) };
643
+ }
644
+ async function buildContextPack(graph, task, readFile20, options = {}) {
645
+ const tokenBudget = options.tokenBudget ?? DEFAULT_CONTEXT_BUDGET;
646
+ const maxSnippetLines = options.maxSnippetLines ?? DEFAULT_MAX_SNIPPET_LINES;
647
+ const ranked = rankForTask(graph, task, options.maxSeeds);
648
+ const entityStarts = /* @__PURE__ */ new Map();
649
+ graph.forEachNode((_id, attrs) => {
650
+ const file = attrs.sourceFile;
651
+ const line = lineNumberOf(attrs.sourceLocation ?? "");
652
+ if (file && line !== null) {
653
+ const starts = entityStarts.get(file) ?? [];
654
+ starts.push(line);
655
+ entityStarts.set(file, starts);
656
+ }
657
+ });
658
+ for (const starts of entityStarts.values()) starts.sort((a, b) => a - b);
659
+ const fileCache = /* @__PURE__ */ new Map();
660
+ const readLines = async (file) => {
661
+ const cached = fileCache.get(file);
662
+ if (cached !== void 0) return cached;
663
+ let lines;
664
+ try {
665
+ lines = (await readFile20(file)).split("\n");
666
+ } catch {
667
+ lines = null;
668
+ }
669
+ fileCache.set(file, lines);
670
+ return lines;
671
+ };
672
+ const snippets = [];
673
+ const overflow = [];
674
+ const coveredRanges = /* @__PURE__ */ new Map();
675
+ let tokens = 0;
676
+ for (const node of ranked) {
677
+ const attrs = graph.getNodeAttributes(node.id);
678
+ const file = attrs.sourceFile ?? "";
679
+ const startLine = lineNumberOf(attrs.sourceLocation ?? "");
680
+ if (!isReadableFile(file) || startLine === null) continue;
681
+ const lines = await readLines(file);
682
+ if (lines === null) continue;
683
+ const { start, end } = snippetRange(startLine, lines.length, entityStarts.get(file) ?? [], maxSnippetLines);
684
+ const covered = (coveredRanges.get(file) ?? []).some((r) => start >= r.start && end <= r.end);
685
+ if (covered) continue;
686
+ const code = lines.slice(start - 1, end).join("\n");
687
+ const cost = tokensOf2(code) + 15;
688
+ if (tokens + cost > tokenBudget) {
689
+ overflow.push({ nodeId: node.id, file: `${file}:L${startLine}` });
690
+ continue;
691
+ }
692
+ tokens += cost;
693
+ snippets.push({
694
+ nodeId: node.id,
695
+ label: attrs.label ?? node.id,
696
+ file,
697
+ startLine: start,
698
+ endLine: end,
699
+ code,
700
+ reason: node.reason,
701
+ score: node.score
702
+ });
703
+ const ranges = coveredRanges.get(file) ?? [];
704
+ ranges.push({ start, end });
705
+ coveredRanges.set(file, ranges);
706
+ }
707
+ return { task, snippets, tokens, tokenBudget, overflow };
708
+ }
709
+ function renderContextPack(pack) {
710
+ const lines = [
711
+ `# Context pack: ${pack.task}`,
712
+ "",
713
+ `_~${pack.tokens} of ${pack.tokenBudget} token budget, ${pack.snippets.length} snippet(s)._`,
714
+ ""
715
+ ];
716
+ if (pack.snippets.length === 0) {
717
+ lines.push("No matching code found \u2014 try different keywords.");
718
+ return lines.join("\n");
719
+ }
720
+ for (const snippet of pack.snippets) {
721
+ lines.push(`## ${snippet.label} \u2014 \`${snippet.file}:L${snippet.startLine}-L${snippet.endLine}\``);
722
+ lines.push(`_${snippet.reason}_`);
723
+ lines.push("```");
724
+ lines.push(snippet.code);
725
+ lines.push("```");
726
+ lines.push("");
727
+ }
728
+ if (pack.overflow.length > 0) {
729
+ lines.push(`## Didn't fit the budget (${pack.overflow.length})`);
730
+ for (const item of pack.overflow.slice(0, 15)) {
731
+ lines.push(`- ${item.nodeId} (${item.file})`);
732
+ }
733
+ lines.push("");
734
+ }
735
+ return lines.join("\n");
736
+ }
737
+ var DEFAULT_CONTEXT_BUDGET, DEFAULT_MAX_SEEDS2, DEFAULT_MAX_SNIPPET_LINES, NEIGHBOR_DECAY, tokensOf2;
738
+ var init_context = __esm({
739
+ "src/context.ts"() {
740
+ "use strict";
741
+ init_cjs_shims();
742
+ init_query();
743
+ DEFAULT_CONTEXT_BUDGET = 4e3;
744
+ DEFAULT_MAX_SEEDS2 = 8;
745
+ DEFAULT_MAX_SNIPPET_LINES = 60;
746
+ NEIGHBOR_DECAY = 0.4;
747
+ tokensOf2 = (text) => Math.ceil(text.length / 4);
748
+ }
749
+ });
750
+
751
+ // src/testmap.ts
752
+ function isTestFile(path17) {
753
+ return TEST_FILE_PATTERNS.some((p) => p.test(path17));
754
+ }
755
+ function selectionFromImpact(graph, targetIds, targetLabel) {
756
+ const best = /* @__PURE__ */ new Map();
757
+ let affectedNodeCount = 0;
758
+ for (const id of targetIds) {
759
+ const impact = affectedBy(graph, id, { maxDepth: TEST_REACH_DEPTH, limit: TEST_REACH_LIMIT });
760
+ if (!impact) continue;
761
+ affectedNodeCount += impact.affected.length;
762
+ for (const node of impact.affected) {
763
+ if (!isTestFile(node.sourceFile)) continue;
764
+ const existing = best.get(node.sourceFile);
765
+ if (!existing || node.depth < existing.depth) {
766
+ best.set(node.sourceFile, { depth: node.depth, via: node.via.dependsOn });
767
+ }
768
+ }
769
+ }
770
+ 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));
771
+ return { target: targetLabel, testFiles, affectedNodeCount };
772
+ }
773
+ function testsForNode(graph, nodeQuery) {
774
+ const match = resolveNode(graph, nodeQuery);
775
+ if (!match) return null;
776
+ return selectionFromImpact(graph, [match.id], match.id);
777
+ }
778
+ function testsForChangedFiles(graph, changedFiles) {
779
+ const fileIds = [];
780
+ const selfSelected = /* @__PURE__ */ new Map();
781
+ for (const file of changedFiles) {
782
+ if (isTestFile(file)) {
783
+ selfSelected.set(file, { depth: 0, via: "(changed directly)" });
784
+ continue;
785
+ }
786
+ if (graph.hasNode(file)) fileIds.push(file);
787
+ }
788
+ const selection = selectionFromImpact(graph, fileIds, changedFiles.join(", "));
789
+ for (const [file, info] of selfSelected) {
790
+ if (!selection.testFiles.some((t) => t.file === file)) {
791
+ selection.testFiles.push({ file, depth: info.depth, via: info.via });
792
+ }
793
+ }
794
+ selection.testFiles.sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
795
+ return selection;
796
+ }
797
+ var TEST_FILE_PATTERNS, TEST_REACH_DEPTH, TEST_REACH_LIMIT;
798
+ var init_testmap = __esm({
799
+ "src/testmap.ts"() {
800
+ "use strict";
801
+ init_cjs_shims();
802
+ init_impact();
803
+ init_query();
804
+ TEST_FILE_PATTERNS = [
805
+ /(^|\/)tests?\//,
806
+ // tests/ or test/ directory
807
+ /(^|\/)__tests__\//,
808
+ /(^|\/)spec\//,
809
+ /\.test\.[cm]?[jt]sx?$/,
810
+ /\.spec\.[cm]?[jt]sx?$/,
811
+ /(^|\/)test_[^/]+\.py$/,
812
+ /_test\.py$/,
813
+ /_test\.go$/,
814
+ /Tests?\.(java|cs)$/,
815
+ /_spec\.rb$/,
816
+ /_test\.rb$/
817
+ ];
818
+ TEST_REACH_DEPTH = 4;
819
+ TEST_REACH_LIMIT = 2e3;
413
820
  }
414
821
  });
415
822
 
@@ -432,12 +839,13 @@ function createGraphifyMcpServer(outDir) {
432
839
  inputSchema: {
433
840
  question: import_zod2.z.string().describe("Natural-language question or keywords to search the graph for."),
434
841
  dfs: import_zod2.z.boolean().optional().describe("Use depth-first traversal instead of the default breadth-first."),
435
- budget: import_zod2.z.number().int().positive().optional().describe("Approximate cap on how many nodes to include.")
842
+ budget: import_zod2.z.number().int().positive().optional().describe("Hard cap on how many nodes to include."),
843
+ tokenBudget: import_zod2.z.number().int().positive().optional().describe("Approximate cap, in tokens, on the serialized size of the result (default 2000).")
436
844
  }
437
845
  },
438
- async ({ question, dfs, budget }) => {
846
+ async ({ question, dfs, budget, tokenBudget }) => {
439
847
  const graph = await loadGraph(outDir);
440
- const result = queryGraph(graph, question, { dfs, budget });
848
+ const result = queryGraph(graph, question, { dfs, budget, tokenBudget });
441
849
  return textResult(JSON.stringify(result, null, 2));
442
850
  }
443
851
  );
@@ -476,41 +884,257 @@ function createGraphifyMcpServer(outDir) {
476
884
  return textResult(JSON.stringify(result, null, 2));
477
885
  }
478
886
  );
887
+ server.registerTool(
888
+ "affected",
889
+ {
890
+ description: 'Reverse impact analysis \u2014 "what breaks if I change X". Walks incoming dependency edges (calls/imports/inherits/...) transitively and returns everything that depends on the node, grouped by depth, with per-confidence blast-radius counts.',
891
+ inputSchema: {
892
+ node: import_zod2.z.string().describe("Name/label (or id) of the node being changed."),
893
+ depth: import_zod2.z.number().int().positive().optional().describe("How many reverse hops to follow (default 3)."),
894
+ limit: import_zod2.z.number().int().positive().optional().describe("Cap on affected nodes reported (default 200).")
895
+ }
896
+ },
897
+ async ({ node, depth, limit }) => {
898
+ const graph = await loadGraph(outDir);
899
+ const result = affectedBy(graph, node, { maxDepth: depth, limit });
900
+ if (!result) {
901
+ return textResult(`No node matched "${node}".`);
902
+ }
903
+ return textResult(JSON.stringify(result, null, 2));
904
+ }
905
+ );
906
+ server.registerTool(
907
+ "context",
908
+ {
909
+ description: "Token-budgeted working-set pack: the actual code snippets relevant to a task, graph-ranked and packed to a budget. Call this FIRST when starting a task \u2014 it replaces query-then-read-whole-files.",
910
+ inputSchema: {
911
+ task: import_zod2.z.string().describe("What you are about to do, in natural language."),
912
+ budget: import_zod2.z.number().int().positive().optional().describe("Approximate token cap (default 4000).")
913
+ }
914
+ },
915
+ async ({ task, budget }) => {
916
+ const graph = await loadGraph(outDir);
917
+ const pack = await buildContextPack(graph, task, (p) => fs22.readFile(p, "utf-8"), {
918
+ tokenBudget: budget
919
+ });
920
+ return textResult(renderContextPack(pack));
921
+ }
922
+ );
923
+ server.registerTool(
924
+ "tests",
925
+ {
926
+ description: "Structural test selection: the minimal test files worth running to verify a change to the given node, via reverse dependency reachability \u2014 no coverage instrumentation.",
927
+ inputSchema: {
928
+ node: import_zod2.z.string().describe("Name/label (or id) of the changed node.")
929
+ }
930
+ },
931
+ async ({ node }) => {
932
+ const graph = await loadGraph(outDir);
933
+ const result = testsForNode(graph, node);
934
+ if (!result) {
935
+ return textResult(`No node matched "${node}".`);
936
+ }
937
+ return textResult(JSON.stringify(result, null, 2));
938
+ }
939
+ );
479
940
  return server;
480
941
  }
481
942
  async function startServer(graphPath, transport = new import_stdio.StdioServerTransport()) {
482
- const outDir = path9.dirname(path9.resolve(graphPath));
483
- const fileName = path9.basename(graphPath);
943
+ const outDir = path15.dirname(path15.resolve(graphPath));
944
+ const fileName = path15.basename(graphPath);
484
945
  validateGraphPath(fileName, outDir);
485
946
  const server = createGraphifyMcpServer(outDir);
486
947
  await server.connect(transport);
487
948
  }
488
949
  async function main(argv = process.argv) {
489
- const graphPath = argv[2] ?? path9.join(process.cwd(), "graphify-out", "graph.json");
950
+ const graphPath = argv[2] ?? path15.join(process.cwd(), "graphify-out", "graph.json");
490
951
  await startServer(graphPath);
491
952
  }
492
- var path9, import_mcp, import_stdio, import_zod2;
953
+ var fs22, path15, import_mcp, import_stdio, import_zod2;
493
954
  var init_server = __esm({
494
955
  "src/mcp/server.ts"() {
495
956
  "use strict";
496
957
  init_cjs_shims();
497
- path9 = __toESM(require("path"), 1);
958
+ fs22 = __toESM(require("fs/promises"), 1);
959
+ path15 = __toESM(require("path"), 1);
498
960
  import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
499
961
  import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
500
962
  import_zod2 = require("zod");
963
+ init_context();
501
964
  init_graphStore();
965
+ init_impact();
502
966
  init_query();
967
+ init_testmap();
968
+ init_security();
969
+ }
970
+ });
971
+
972
+ // src/extractors/mysql.ts
973
+ var mysql_exports = {};
974
+ __export(mysql_exports, {
975
+ extractMysql: () => extractMysql
976
+ });
977
+ async function defaultQueryFn(dsn) {
978
+ const { connection } = validateDsn(dsn);
979
+ const mysql = await import("mysql2/promise");
980
+ const conn = await mysql.createConnection({
981
+ host: connection.host,
982
+ port: connection.port,
983
+ user: connection.user,
984
+ password: connection.password,
985
+ database: connection.database
986
+ });
987
+ return {
988
+ query: async (sql, params) => {
989
+ const [rows] = await conn.execute(sql, [...params]);
990
+ return rows;
991
+ },
992
+ close: () => conn.end()
993
+ };
994
+ }
995
+ async function extractMysql(dsn, queryFn) {
996
+ const { safeDisplay, connection } = validateDsn(dsn);
997
+ const schema = connection.database;
998
+ let query = queryFn;
999
+ let close;
1000
+ if (!query) {
1001
+ const live = await defaultQueryFn(dsn);
1002
+ query = live.query;
1003
+ close = live.close;
1004
+ }
1005
+ try {
1006
+ const nodes = [];
1007
+ const edges = [];
1008
+ nodes.push({ id: schemaId(schema), label: schema, sourceFile: safeDisplay, sourceLocation: schema });
1009
+ const tableRows = await query(
1010
+ "SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
1011
+ [schema]
1012
+ );
1013
+ const tables = tableRows.map((row) => ({
1014
+ name: String(row.TABLE_NAME),
1015
+ isView: String(row.TABLE_TYPE).toUpperCase() === "VIEW"
1016
+ }));
1017
+ const tableNames = new Set(tables.map((t) => t.name));
1018
+ for (const table of tables) {
1019
+ nodes.push({
1020
+ id: tableId(schema, table.name),
1021
+ label: `${table.isView ? "view" : "table"} ${table.name}`,
1022
+ sourceFile: safeDisplay,
1023
+ sourceLocation: table.name
1024
+ });
1025
+ edges.push({
1026
+ source: schemaId(schema),
1027
+ target: tableId(schema, table.name),
1028
+ relation: "contains",
1029
+ confidence: "EXTRACTED"
1030
+ });
1031
+ }
1032
+ const columnRows = await query(
1033
+ "SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME, ORDINAL_POSITION",
1034
+ [schema]
1035
+ );
1036
+ for (const row of columnRows) {
1037
+ const table = String(row.TABLE_NAME);
1038
+ const column = String(row.COLUMN_NAME);
1039
+ if (!tableNames.has(table)) continue;
1040
+ nodes.push({
1041
+ id: columnId(schema, table, column),
1042
+ label: `${table}.${column}: ${String(row.COLUMN_TYPE)}`,
1043
+ sourceFile: safeDisplay,
1044
+ sourceLocation: `${table}.${column}`
1045
+ });
1046
+ edges.push({
1047
+ source: tableId(schema, table),
1048
+ target: columnId(schema, table, column),
1049
+ relation: "contains",
1050
+ confidence: "EXTRACTED"
1051
+ });
1052
+ }
1053
+ const fkRows = await query(
1054
+ "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",
1055
+ [schema]
1056
+ );
1057
+ for (const row of fkRows) {
1058
+ const table = String(row.TABLE_NAME);
1059
+ const column = String(row.COLUMN_NAME);
1060
+ const referenced = String(row.REFERENCED_TABLE_NAME);
1061
+ if (!tableNames.has(table) || !tableNames.has(referenced)) continue;
1062
+ edges.push({
1063
+ source: columnId(schema, table, column),
1064
+ target: tableId(schema, referenced),
1065
+ relation: "references",
1066
+ confidence: "EXTRACTED"
1067
+ });
1068
+ }
1069
+ const viewNames = tables.filter((t) => t.isView).map((t) => t.name);
1070
+ if (viewNames.length > 0) {
1071
+ await addViewEdges(query, schema, viewNames, tableNames, edges);
1072
+ }
1073
+ return { nodes, edges };
1074
+ } finally {
1075
+ await close?.();
1076
+ }
1077
+ }
1078
+ async function addViewEdges(query, schema, viewNames, tableNames, edges) {
1079
+ try {
1080
+ const usageRows = await query(
1081
+ "SELECT VIEW_NAME, TABLE_NAME FROM information_schema.VIEW_TABLE_USAGE WHERE VIEW_SCHEMA = ? AND TABLE_SCHEMA = ? ORDER BY VIEW_NAME, TABLE_NAME",
1082
+ [schema, schema]
1083
+ );
1084
+ for (const row of usageRows) {
1085
+ const view = String(row.VIEW_NAME);
1086
+ const table = String(row.TABLE_NAME);
1087
+ if (!tableNames.has(view) || !tableNames.has(table) || view === table) continue;
1088
+ edges.push({
1089
+ source: tableId(schema, view),
1090
+ target: tableId(schema, table),
1091
+ relation: "references",
1092
+ confidence: "EXTRACTED"
1093
+ });
1094
+ }
1095
+ return;
1096
+ } catch {
1097
+ }
1098
+ const definitionRows = await query(
1099
+ "SELECT TABLE_NAME, VIEW_DEFINITION FROM information_schema.VIEWS WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
1100
+ [schema]
1101
+ );
1102
+ for (const row of definitionRows) {
1103
+ const view = String(row.TABLE_NAME);
1104
+ if (!viewNames.includes(view)) continue;
1105
+ const definition = String(row.VIEW_DEFINITION ?? "").toLowerCase();
1106
+ for (const table of [...tableNames].sort((a, b) => a.localeCompare(b))) {
1107
+ if (table === view) continue;
1108
+ if (new RegExp(`\\b${table.toLowerCase()}\\b`).test(definition)) {
1109
+ edges.push({
1110
+ source: tableId(schema, view),
1111
+ target: tableId(schema, table),
1112
+ relation: "references",
1113
+ confidence: "INFERRED"
1114
+ });
1115
+ }
1116
+ }
1117
+ }
1118
+ }
1119
+ var schemaId, tableId, columnId;
1120
+ var init_mysql = __esm({
1121
+ "src/extractors/mysql.ts"() {
1122
+ "use strict";
1123
+ init_cjs_shims();
503
1124
  init_security();
1125
+ schemaId = (schema) => `db:${schema}`;
1126
+ tableId = (schema, table) => `db:${schema}::${table}`;
1127
+ columnId = (schema, table, column) => `db:${schema}::${table}.${column}`;
504
1128
  }
505
1129
  });
506
1130
 
507
1131
  // src/cli/index.ts
508
1132
  init_cjs_shims();
509
- var import_node_child_process = require("child_process");
510
- var fs13 = __toESM(require("fs/promises"), 1);
511
- var os = __toESM(require("os"), 1);
512
- var path10 = __toESM(require("path"), 1);
513
- var import_node_util = require("util");
1133
+ var import_node_child_process3 = require("child_process");
1134
+ var fs23 = __toESM(require("fs/promises"), 1);
1135
+ var os3 = __toESM(require("os"), 1);
1136
+ var path16 = __toESM(require("path"), 1);
1137
+ var import_node_util3 = require("util");
514
1138
  var import_chokidar = require("chokidar");
515
1139
  var import_commander = require("commander");
516
1140
 
@@ -841,7 +1465,8 @@ init_graphStore();
841
1465
 
842
1466
  // src/pipeline.ts
843
1467
  init_cjs_shims();
844
- var path7 = __toESM(require("path"), 1);
1468
+ var fs13 = __toESM(require("fs/promises"), 1);
1469
+ var path8 = __toESM(require("path"), 1);
845
1470
 
846
1471
  // src/build.ts
847
1472
  init_cjs_shims();
@@ -947,10 +1572,39 @@ function buildGraph(extractions) {
947
1572
  return graph;
948
1573
  }
949
1574
 
950
- // src/detect.ts
1575
+ // src/extractionCache.ts
951
1576
  init_cjs_shims();
952
- var fs4 = __toESM(require("fs"), 1);
1577
+ var import_node_crypto3 = require("crypto");
1578
+ var fs4 = __toESM(require("fs/promises"), 1);
953
1579
  var path3 = __toESM(require("path"), 1);
1580
+ var ExtractionCache = class {
1581
+ dir;
1582
+ constructor(outDir) {
1583
+ this.dir = path3.join(outDir, "cache");
1584
+ }
1585
+ key(relFile, content) {
1586
+ return (0, import_node_crypto3.createHash)("sha256").update(relFile).update("\0").update(content).digest("hex");
1587
+ }
1588
+ async get(key) {
1589
+ try {
1590
+ const raw = await fs4.readFile(path3.join(this.dir, `${key}.json`), "utf-8");
1591
+ const parsed = JSON.parse(raw);
1592
+ if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null;
1593
+ return parsed;
1594
+ } catch {
1595
+ return null;
1596
+ }
1597
+ }
1598
+ async put(key, extraction) {
1599
+ await fs4.mkdir(this.dir, { recursive: true });
1600
+ await fs4.writeFile(path3.join(this.dir, `${key}.json`), JSON.stringify(extraction), "utf-8");
1601
+ }
1602
+ };
1603
+
1604
+ // src/detect.ts
1605
+ init_cjs_shims();
1606
+ var fs5 = __toESM(require("fs"), 1);
1607
+ var path4 = __toESM(require("path"), 1);
954
1608
  var SKIP_DIRS = /* @__PURE__ */ new Set([
955
1609
  "node_modules",
956
1610
  ".git",
@@ -1031,13 +1685,13 @@ function countWords(content) {
1031
1685
  return trimmed.split(/\s+/).length;
1032
1686
  }
1033
1687
  function readTextSafely(filePath) {
1034
- const buf = fs4.readFileSync(filePath);
1688
+ const buf = fs5.readFileSync(filePath);
1035
1689
  return buf.toString("utf-8");
1036
1690
  }
1037
1691
  function collectFiles(root) {
1038
- const scanRoot = path3.resolve(root);
1039
- const stat = fs4.statSync(scanRoot);
1040
- if (!stat.isDirectory()) {
1692
+ const scanRoot = path4.resolve(root);
1693
+ const stat2 = fs5.statSync(scanRoot);
1694
+ if (!stat2.isDirectory()) {
1041
1695
  throw new Error(`collectFiles: not a directory: ${scanRoot}`);
1042
1696
  }
1043
1697
  const files = {
@@ -1055,15 +1709,15 @@ function collectFiles(root) {
1055
1709
  const dir = stack.pop();
1056
1710
  let entries;
1057
1711
  try {
1058
- entries = fs4.readdirSync(dir, { withFileTypes: true });
1712
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
1059
1713
  } catch {
1060
1714
  continue;
1061
1715
  }
1062
1716
  entries.sort((a, b) => a.name.localeCompare(b.name));
1063
1717
  for (const entry of entries) {
1064
1718
  if (entry.isSymbolicLink()) continue;
1065
- const fullPath = path3.join(dir, entry.name);
1066
- const relPath = path3.relative(scanRoot, fullPath);
1719
+ const fullPath = path4.join(dir, entry.name);
1720
+ const relPath = path4.relative(scanRoot, fullPath);
1067
1721
  if (entry.isDirectory()) {
1068
1722
  if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
1069
1723
  stack.push(fullPath);
@@ -1074,7 +1728,7 @@ function collectFiles(root) {
1074
1728
  skippedSensitive.push(relPath);
1075
1729
  continue;
1076
1730
  }
1077
- const ext = path3.extname(entry.name);
1731
+ const ext = path4.extname(entry.name);
1078
1732
  const category = categorize(ext);
1079
1733
  if (category === null) continue;
1080
1734
  files[category].push(relPath);
@@ -1103,15 +1757,15 @@ function collectFiles(root) {
1103
1757
 
1104
1758
  // src/extract.ts
1105
1759
  init_cjs_shims();
1106
- var path6 = __toESM(require("path"), 1);
1760
+ var path7 = __toESM(require("path"), 1);
1107
1761
 
1108
1762
  // src/extractors/csharp.ts
1109
1763
  init_cjs_shims();
1110
- var fs5 = __toESM(require("fs/promises"), 1);
1764
+ var fs6 = __toESM(require("fs/promises"), 1);
1111
1765
 
1112
1766
  // src/extractors/common.ts
1113
1767
  init_cjs_shims();
1114
- var path4 = __toESM(require("path"), 1);
1768
+ var path5 = __toESM(require("path"), 1);
1115
1769
  function namedChildren(node) {
1116
1770
  return node.namedChildren.filter((c) => c !== null);
1117
1771
  }
@@ -1119,7 +1773,7 @@ function descendantsOfType(node, types) {
1119
1773
  return node.descendantsOfType(types).filter((c) => c !== null);
1120
1774
  }
1121
1775
  function toPosix(p) {
1122
- return p.split(path4.sep).join("/");
1776
+ return p.split(path5.sep).join("/");
1123
1777
  }
1124
1778
  function lineOf(node) {
1125
1779
  return `L${node.startPosition.row + 1}`;
@@ -1132,9 +1786,9 @@ function externalId(name) {
1132
1786
  }
1133
1787
  function resolveModuleRef(sourceFile, specifier) {
1134
1788
  if (specifier.startsWith(".") || specifier.startsWith("/")) {
1135
- const dir = path4.posix.dirname(toPosix(sourceFile));
1136
- const joined = specifier.startsWith("/") ? specifier : path4.posix.join(dir, specifier);
1137
- const normalized = path4.posix.normalize(joined);
1789
+ const dir = path5.posix.dirname(toPosix(sourceFile));
1790
+ const joined = specifier.startsWith("/") ? specifier : path5.posix.join(dir, specifier);
1791
+ const normalized = path5.posix.normalize(joined);
1138
1792
  return { id: normalized, label: specifier, external: false };
1139
1793
  }
1140
1794
  return { id: `module:${specifier}`, label: specifier, external: true };
@@ -1190,16 +1844,16 @@ async function createParser(grammarName) {
1190
1844
  }
1191
1845
 
1192
1846
  // src/extractors/csharp.ts
1193
- async function extractCSharp(filePath) {
1847
+ async function extractCSharp(filePath, displayPath) {
1194
1848
  const parser = await createParser("c_sharp");
1195
- const raw = await fs5.readFile(filePath);
1849
+ const raw = await fs6.readFile(filePath);
1196
1850
  const content = raw.toString("utf-8");
1197
1851
  const tree = parser.parse(content);
1198
1852
  if (!tree) {
1199
1853
  throw new Error(`extractCSharp: parser produced no tree for ${filePath}`);
1200
1854
  }
1201
1855
  const root = tree.rootNode;
1202
- const sourceFile = toPosix(filePath);
1856
+ const sourceFile = toPosix(displayPath ?? filePath);
1203
1857
  const builder = new ExtractionBuilder();
1204
1858
  const fileId = sourceFile;
1205
1859
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -1389,7 +2043,7 @@ async function extractCSharp(filePath) {
1389
2043
 
1390
2044
  // src/extractors/go.ts
1391
2045
  init_cjs_shims();
1392
- var fs6 = __toESM(require("fs/promises"), 1);
2046
+ var fs7 = __toESM(require("fs/promises"), 1);
1393
2047
  function stripQuotes(text) {
1394
2048
  if (text.length >= 2) {
1395
2049
  const first = text[0];
@@ -1404,16 +2058,16 @@ function defaultPackageQualifier(importPath) {
1404
2058
  const segments = importPath.split("/");
1405
2059
  return segments[segments.length - 1] ?? importPath;
1406
2060
  }
1407
- async function extractGo(filePath) {
2061
+ async function extractGo(filePath, displayPath) {
1408
2062
  const parser = await createParser("go");
1409
- const raw = await fs6.readFile(filePath);
2063
+ const raw = await fs7.readFile(filePath);
1410
2064
  const content = raw.toString("utf-8");
1411
2065
  const tree = parser.parse(content);
1412
2066
  if (!tree) {
1413
2067
  throw new Error(`extractGo: parser produced no tree for ${filePath}`);
1414
2068
  }
1415
2069
  const root = tree.rootNode;
1416
- const sourceFile = toPosix(filePath);
2070
+ const sourceFile = toPosix(displayPath ?? filePath);
1417
2071
  const builder = new ExtractionBuilder();
1418
2072
  const fileId = sourceFile;
1419
2073
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -1611,17 +2265,17 @@ async function extractGo(filePath) {
1611
2265
 
1612
2266
  // src/extractors/java.ts
1613
2267
  init_cjs_shims();
1614
- var fs7 = __toESM(require("fs/promises"), 1);
1615
- async function extractJava(filePath) {
2268
+ var fs8 = __toESM(require("fs/promises"), 1);
2269
+ async function extractJava(filePath, displayPath) {
1616
2270
  const parser = await createParser("java");
1617
- const raw = await fs7.readFile(filePath);
2271
+ const raw = await fs8.readFile(filePath);
1618
2272
  const content = raw.toString("utf-8");
1619
2273
  const tree = parser.parse(content);
1620
2274
  if (!tree) {
1621
2275
  throw new Error(`extractJava: parser produced no tree for ${filePath}`);
1622
2276
  }
1623
2277
  const root = tree.rootNode;
1624
- const sourceFile = toPosix(filePath);
2278
+ const sourceFile = toPosix(displayPath ?? filePath);
1625
2279
  const builder = new ExtractionBuilder();
1626
2280
  const fileId = sourceFile;
1627
2281
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -1813,17 +2467,17 @@ async function extractJava(filePath) {
1813
2467
 
1814
2468
  // src/extractors/python.ts
1815
2469
  init_cjs_shims();
1816
- var fs8 = __toESM(require("fs/promises"), 1);
1817
- async function extractPython(filePath) {
2470
+ var fs9 = __toESM(require("fs/promises"), 1);
2471
+ async function extractPython(filePath, displayPath) {
1818
2472
  const parser = await createParser("python");
1819
- const raw = await fs8.readFile(filePath);
2473
+ const raw = await fs9.readFile(filePath);
1820
2474
  const content = raw.toString("utf-8");
1821
2475
  const tree = parser.parse(content);
1822
2476
  if (!tree) {
1823
2477
  throw new Error(`extractPython: parser produced no tree for ${filePath}`);
1824
2478
  }
1825
2479
  const root = tree.rootNode;
1826
- const sourceFile = toPosix(filePath);
2480
+ const sourceFile = toPosix(displayPath ?? filePath);
1827
2481
  const builder = new ExtractionBuilder();
1828
2482
  const fileId = sourceFile;
1829
2483
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -2049,7 +2703,7 @@ async function extractPython(filePath) {
2049
2703
 
2050
2704
  // src/extractors/ruby.ts
2051
2705
  init_cjs_shims();
2052
- var fs9 = __toESM(require("fs/promises"), 1);
2706
+ var fs10 = __toESM(require("fs/promises"), 1);
2053
2707
  function conventionalConstantName(specifier) {
2054
2708
  const lastSegment = specifier.split("/").filter(Boolean).pop() ?? specifier;
2055
2709
  return lastSegment.split("_").filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
@@ -2059,16 +2713,16 @@ function stringLiteralContent(node) {
2059
2713
  const content = namedChildren(node).find((c) => c.type === "string_content");
2060
2714
  return content ? content.text : "";
2061
2715
  }
2062
- async function extractRuby(filePath) {
2716
+ async function extractRuby(filePath, displayPath) {
2063
2717
  const parser = await createParser("ruby");
2064
- const raw = await fs9.readFile(filePath);
2718
+ const raw = await fs10.readFile(filePath);
2065
2719
  const content = raw.toString("utf-8");
2066
2720
  const tree = parser.parse(content);
2067
2721
  if (!tree) {
2068
2722
  throw new Error(`extractRuby: parser produced no tree for ${filePath}`);
2069
2723
  }
2070
2724
  const root = tree.rootNode;
2071
- const sourceFile = toPosix(filePath);
2725
+ const sourceFile = toPosix(displayPath ?? filePath);
2072
2726
  const builder = new ExtractionBuilder();
2073
2727
  const fileId = sourceFile;
2074
2728
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -2246,7 +2900,7 @@ async function extractRuby(filePath) {
2246
2900
 
2247
2901
  // src/extractors/rust.ts
2248
2902
  init_cjs_shims();
2249
- var fs10 = __toESM(require("fs/promises"), 1);
2903
+ var fs11 = __toESM(require("fs/promises"), 1);
2250
2904
  function convertUsePath(segments) {
2251
2905
  const first = segments[0];
2252
2906
  if (first === "crate" || first === "self") {
@@ -2262,16 +2916,16 @@ function convertUsePath(segments) {
2262
2916
  }
2263
2917
  return segments.join("::");
2264
2918
  }
2265
- async function extractRust(filePath) {
2919
+ async function extractRust(filePath, displayPath) {
2266
2920
  const parser = await createParser("rust");
2267
- const raw = await fs10.readFile(filePath);
2921
+ const raw = await fs11.readFile(filePath);
2268
2922
  const content = raw.toString("utf-8");
2269
2923
  const tree = parser.parse(content);
2270
2924
  if (!tree) {
2271
2925
  throw new Error(`extractRust: parser produced no tree for ${filePath}`);
2272
2926
  }
2273
2927
  const root = tree.rootNode;
2274
- const sourceFile = toPosix(filePath);
2928
+ const sourceFile = toPosix(displayPath ?? filePath);
2275
2929
  const builder = new ExtractionBuilder();
2276
2930
  const fileId = sourceFile;
2277
2931
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -2484,10 +3138,10 @@ async function extractRust(filePath) {
2484
3138
  }
2485
3139
  }
2486
3140
  } else if (fn.type === "scoped_identifier") {
2487
- const path11 = fn.childForFieldName("path")?.text;
3141
+ const path17 = fn.childForFieldName("path")?.text;
2488
3142
  const name = fn.childForFieldName("name")?.text;
2489
- if (path11 && name) {
2490
- const viaImport = importIndex.get(path11);
3143
+ if (path17 && name) {
3144
+ const viaImport = importIndex.get(path17);
2491
3145
  if (viaImport) {
2492
3146
  targetId = viaImport.id;
2493
3147
  confidence = "INFERRED";
@@ -2503,8 +3157,8 @@ async function extractRust(filePath) {
2503
3157
 
2504
3158
  // src/extractors/typescript.ts
2505
3159
  init_cjs_shims();
2506
- var fs11 = __toESM(require("fs/promises"), 1);
2507
- var path5 = __toESM(require("path"), 1);
3160
+ var fs12 = __toESM(require("fs/promises"), 1);
3161
+ var path6 = __toESM(require("path"), 1);
2508
3162
  function grammarForExtension(ext) {
2509
3163
  const lower = ext.toLowerCase();
2510
3164
  if (lower === ".tsx") return "tsx";
@@ -2529,18 +3183,18 @@ function firstStringArgument(call) {
2529
3183
  if (!first || first.type !== "string") return null;
2530
3184
  return stripQuotes2(first.text);
2531
3185
  }
2532
- async function extractTypeScript(filePath) {
2533
- const ext = path5.extname(filePath);
3186
+ async function extractTypeScript(filePath, displayPath) {
3187
+ const ext = path6.extname(filePath);
2534
3188
  const grammar = grammarForExtension(ext);
2535
3189
  const parser = await createParser(grammar);
2536
- const raw = await fs11.readFile(filePath);
3190
+ const raw = await fs12.readFile(filePath);
2537
3191
  const content = raw.toString("utf-8");
2538
3192
  const tree = parser.parse(content);
2539
3193
  if (!tree) {
2540
3194
  throw new Error(`extractTypeScript: parser produced no tree for ${filePath}`);
2541
3195
  }
2542
3196
  const root = tree.rootNode;
2543
- const sourceFile = toPosix(filePath);
3197
+ const sourceFile = toPosix(displayPath ?? filePath);
2544
3198
  const builder = new ExtractionBuilder();
2545
3199
  const fileId = sourceFile;
2546
3200
  builder.addNode({ id: fileId, label: sourceFile, sourceFile, sourceLocation: "L1" });
@@ -2857,13 +3511,13 @@ var EXTRACTOR_REGISTRY = {
2857
3511
  ".cs": extractCSharp,
2858
3512
  ".rb": extractRuby
2859
3513
  };
2860
- async function extract(filePath) {
2861
- const ext = path6.extname(filePath);
3514
+ async function extract(filePath, displayPath) {
3515
+ const ext = path7.extname(filePath);
2862
3516
  const extractor = EXTRACTOR_REGISTRY[ext];
2863
3517
  if (!extractor) {
2864
3518
  return { nodes: [], edges: [] };
2865
3519
  }
2866
- return extractor(filePath);
3520
+ return extractor(filePath, displayPath);
2867
3521
  }
2868
3522
 
2869
3523
  // src/report.ts
@@ -2945,37 +3599,161 @@ function renderReport(graph, analysis, now = /* @__PURE__ */ new Date()) {
2945
3599
  return lines.join("\n");
2946
3600
  }
2947
3601
 
2948
- // src/pipeline.ts
2949
- async function runPipeline(root, options = {}) {
2950
- const progress = options.onProgress ?? (() => {
2951
- });
2952
- const resolvedRoot = path7.resolve(root);
2953
- progress(`Scanning ${resolvedRoot} ...`);
2954
- const manifest = collectFiles(resolvedRoot);
2955
- progress(
2956
- `Found ${manifest.totalFiles} file(s) (${manifest.files.code.length} code, ${manifest.skippedSensitive.length} skipped as sensitive).`
2957
- );
2958
- progress("Extracting...");
2959
- const extractions = [];
2960
- for (const relFile of manifest.files.code.sort((a, b) => a.localeCompare(b))) {
2961
- const filePath = path7.relative(process.cwd(), path7.join(resolvedRoot, relFile)) || relFile;
2962
- try {
2963
- const extraction = await extract(filePath);
2964
- extractions.push(extraction);
2965
- } catch (error) {
2966
- progress(` extraction failed for ${relFile}: ${error.message}`);
2967
- throw error;
2968
- }
2969
- }
2970
- progress("Building graph...");
2971
- const graph = buildGraph(extractions);
2972
- progress(`Clustering (${options.algorithm ?? "louvain"})...`);
2973
- cluster(graph, { algorithm: options.algorithm, maxIterations: options.maxIterations });
3602
+ // src/resolve.ts
3603
+ init_cjs_shims();
3604
+ var CODE_EXTENSIONS2 = [
3605
+ ".ts",
3606
+ ".tsx",
3607
+ ".mts",
3608
+ ".cts",
3609
+ ".js",
3610
+ ".jsx",
3611
+ ".mjs",
3612
+ ".cjs",
3613
+ ".py",
3614
+ ".go",
3615
+ ".rs",
3616
+ ".java",
3617
+ ".cs",
3618
+ ".rb"
3619
+ ];
3620
+ function isOpaque(id) {
3621
+ return id.startsWith("module:") || id.startsWith("external:") || id.startsWith("db:");
3622
+ }
3623
+ function stripKnownExtension(p) {
3624
+ for (const ext of CODE_EXTENSIONS2) {
3625
+ if (p.endsWith(ext)) return p.slice(0, -ext.length);
3626
+ }
3627
+ return null;
3628
+ }
3629
+ function candidatePaths(guessed) {
3630
+ const stem = stripKnownExtension(guessed) ?? guessed;
3631
+ const candidates = [];
3632
+ for (const ext of CODE_EXTENSIONS2) candidates.push(`${stem}${ext}`);
3633
+ for (const ext of CODE_EXTENSIONS2) candidates.push(`${guessed}/index${ext}`);
3634
+ if (stem !== guessed) {
3635
+ for (const ext of CODE_EXTENSIONS2) candidates.push(`${stem}/index${ext}`);
3636
+ }
3637
+ return candidates.filter((c) => c !== guessed);
3638
+ }
3639
+ function uniqueMatch(guessed, candidates, realIds) {
3640
+ const matches = /* @__PURE__ */ new Set();
3641
+ for (const candidate of candidates) {
3642
+ if (realIds.has(candidate)) matches.add(candidate);
3643
+ }
3644
+ if (matches.size !== 1) return null;
3645
+ return [...matches][0];
3646
+ }
3647
+ function resolveCrossFileReferences(extractions) {
3648
+ const realIds = /* @__PURE__ */ new Set();
3649
+ const realFiles = /* @__PURE__ */ new Set();
3650
+ for (const extraction of extractions) {
3651
+ for (const node of extraction.nodes) realIds.add(node.id);
3652
+ for (const node of extraction.nodes) {
3653
+ const sep3 = node.id.indexOf("::");
3654
+ if (sep3 > 0) realFiles.add(node.id.slice(0, sep3));
3655
+ }
3656
+ for (const edge of extraction.edges) {
3657
+ if (edge.relation === "contains") realFiles.add(edge.source);
3658
+ }
3659
+ }
3660
+ const remap = /* @__PURE__ */ new Map();
3661
+ const resolveId = (id) => {
3662
+ if (isOpaque(id)) return null;
3663
+ const cached = remap.get(id);
3664
+ if (cached !== void 0) return cached;
3665
+ const sep3 = id.indexOf("::");
3666
+ let resolved;
3667
+ if (sep3 > 0) {
3668
+ if (realIds.has(id)) return null;
3669
+ const guessedPath = id.slice(0, sep3);
3670
+ const name = id.slice(sep3);
3671
+ const candidates = candidatePaths(guessedPath).map((p) => `${p}${name}`);
3672
+ resolved = uniqueMatch(id, candidates, realIds);
3673
+ } else {
3674
+ if (realFiles.has(id)) return null;
3675
+ resolved = uniqueMatch(id, candidatePaths(id), realFiles);
3676
+ }
3677
+ if (resolved !== null) remap.set(id, resolved);
3678
+ return resolved;
3679
+ };
3680
+ let resolvedEndpoints = 0;
3681
+ for (const extraction of extractions) {
3682
+ for (const edge of extraction.edges) {
3683
+ const source = resolveId(edge.source);
3684
+ if (source !== null) {
3685
+ edge.source = source;
3686
+ resolvedEndpoints++;
3687
+ }
3688
+ const target = resolveId(edge.target);
3689
+ if (target !== null) {
3690
+ edge.target = target;
3691
+ resolvedEndpoints++;
3692
+ }
3693
+ }
3694
+ }
3695
+ let droppedPlaceholders = 0;
3696
+ for (const extraction of extractions) {
3697
+ const before = extraction.nodes.length;
3698
+ extraction.nodes = extraction.nodes.filter((node) => !remap.has(node.id));
3699
+ droppedPlaceholders += before - extraction.nodes.length;
3700
+ }
3701
+ return { resolvedEndpoints, droppedPlaceholders };
3702
+ }
3703
+
3704
+ // src/pipeline.ts
3705
+ async function runPipeline(root, options = {}) {
3706
+ const progress = options.onProgress ?? (() => {
3707
+ });
3708
+ const resolvedRoot = path8.resolve(root);
3709
+ progress(`Scanning ${resolvedRoot} ...`);
3710
+ const manifest = collectFiles(resolvedRoot);
3711
+ progress(
3712
+ `Found ${manifest.totalFiles} file(s) (${manifest.files.code.length} code, ${manifest.skippedSensitive.length} skipped as sensitive).`
3713
+ );
3714
+ const outDir = options.outDir ?? path8.join(resolvedRoot, "graphify-out");
3715
+ const cache = new ExtractionCache(outDir);
3716
+ progress("Extracting...");
3717
+ const extractions = [];
3718
+ let cacheHits = 0;
3719
+ for (const relFile of manifest.files.code.sort((a, b) => a.localeCompare(b))) {
3720
+ const filePath = path8.relative(process.cwd(), path8.join(resolvedRoot, relFile)) || relFile;
3721
+ try {
3722
+ const key = cache.key(relFile, await fs13.readFile(path8.join(resolvedRoot, relFile)));
3723
+ let extraction = options.update ? await cache.get(key) : null;
3724
+ if (extraction) {
3725
+ cacheHits++;
3726
+ } else {
3727
+ extraction = await extract(filePath, relFile);
3728
+ await cache.put(key, extraction);
3729
+ }
3730
+ extractions.push(extraction);
3731
+ } catch (error) {
3732
+ progress(` extraction failed for ${relFile}: ${error.message}`);
3733
+ throw error;
3734
+ }
3735
+ }
3736
+ if (options.update) {
3737
+ progress(` cache: ${cacheHits} hit(s), ${extractions.length - cacheHits} re-extracted.`);
3738
+ }
3739
+ if (options.extraExtractions && options.extraExtractions.length > 0) {
3740
+ extractions.push(...options.extraExtractions);
3741
+ }
3742
+ progress("Resolving cross-file references...");
3743
+ const resolveStats = resolveCrossFileReferences(extractions);
3744
+ if (resolveStats.resolvedEndpoints > 0) {
3745
+ progress(
3746
+ ` re-pointed ${resolveStats.resolvedEndpoints} guessed reference(s) at real nodes (${resolveStats.droppedPlaceholders} placeholder node(s) dropped).`
3747
+ );
3748
+ }
3749
+ progress("Building graph...");
3750
+ const graph = buildGraph(extractions);
3751
+ progress(`Clustering (${options.algorithm ?? "louvain"})...`);
3752
+ cluster(graph, { algorithm: options.algorithm, maxIterations: options.maxIterations });
2974
3753
  progress("Analyzing...");
2975
3754
  const analysis = analyze(graph);
2976
3755
  progress("Rendering report...");
2977
3756
  const report = renderReport(graph, analysis);
2978
- const outDir = options.outDir ?? path7.join(resolvedRoot, "graphify-out");
2979
3757
  progress(`Exporting to ${outDir} ...`);
2980
3758
  await exportGraph(
2981
3759
  graph,
@@ -2996,6 +3774,228 @@ async function runPipeline(root, options = {}) {
2996
3774
  // src/cli/index.ts
2997
3775
  init_security();
2998
3776
 
3777
+ // src/cli/commands/affected.ts
3778
+ init_cjs_shims();
3779
+ init_graphStore();
3780
+ init_impact();
3781
+ async function runAffected(nodeName, options = {}) {
3782
+ const graph = await loadGraph();
3783
+ const result = affectedBy(graph, nodeName, { maxDepth: options.depth, limit: options.limit });
3784
+ if (!result) {
3785
+ console.log(`No node matched "${nodeName}".`);
3786
+ return;
3787
+ }
3788
+ console.log(`Impact of changing ${result.target.label} (${result.target.id}):`);
3789
+ if (result.affected.length === 0) {
3790
+ console.log(" Nothing in the graph depends on it \u2014 no incoming dependency edges.");
3791
+ return;
3792
+ }
3793
+ let currentDepth = 0;
3794
+ for (const node of result.affected) {
3795
+ if (node.depth !== currentDepth) {
3796
+ currentDepth = node.depth;
3797
+ console.log("");
3798
+ console.log(currentDepth === 1 ? "Directly affected:" : `Affected at depth ${currentDepth}:`);
3799
+ }
3800
+ const location = node.sourceLocation ? `:${node.sourceLocation}` : "";
3801
+ console.log(
3802
+ ` ${node.label} \u2014 ${node.sourceFile}${location} (--${node.via.relation}--> ${node.via.dependsOn}, ${node.via.confidence})`
3803
+ );
3804
+ }
3805
+ console.log("");
3806
+ const { EXTRACTED, INFERRED, AMBIGUOUS } = result.byConfidence;
3807
+ console.log(
3808
+ `Blast radius: ${result.affected.length} node(s) within ${result.maxDepth} hop(s) \u2014 ${EXTRACTED} certain (EXTRACTED), ${INFERRED} likely (INFERRED), ${AMBIGUOUS} uncertain (AMBIGUOUS).`
3809
+ );
3810
+ if (result.truncated) {
3811
+ console.log("(List truncated \u2014 raise --limit to see more.)");
3812
+ }
3813
+ }
3814
+
3815
+ // src/cli/commands/benchmark.ts
3816
+ init_cjs_shims();
3817
+ var fs14 = __toESM(require("fs/promises"), 1);
3818
+ init_graphStore();
3819
+ init_query();
3820
+ var tokensOf = (text) => Math.ceil(text.length / 4);
3821
+ async function benchmarkQuestion(graph, question, readFile20) {
3822
+ const result = queryGraph(graph, question);
3823
+ const graphTokens = tokensOf(JSON.stringify(result));
3824
+ const sourceFiles = /* @__PURE__ */ new Set();
3825
+ for (const node of result.visited) {
3826
+ if (node.sourceFile && !node.sourceFile.startsWith("<") && !node.sourceFile.includes("://")) {
3827
+ sourceFiles.add(node.sourceFile);
3828
+ }
3829
+ }
3830
+ let naiveTokens = 0;
3831
+ let filesRead = 0;
3832
+ let filesMissing = 0;
3833
+ for (const file of [...sourceFiles].sort((a, b) => a.localeCompare(b))) {
3834
+ try {
3835
+ naiveTokens += tokensOf(await readFile20(file));
3836
+ filesRead++;
3837
+ } catch {
3838
+ filesMissing++;
3839
+ }
3840
+ }
3841
+ return {
3842
+ question,
3843
+ graphTokens,
3844
+ naiveTokens,
3845
+ reduction: graphTokens > 0 && naiveTokens > 0 ? naiveTokens / graphTokens : 0,
3846
+ filesRead,
3847
+ filesMissing
3848
+ };
3849
+ }
3850
+ function defaultQuestions(graph, count = 5) {
3851
+ const entries = [...graph.nodes()].map((id) => ({
3852
+ id,
3853
+ degree: graph.degree(id),
3854
+ label: graph.getNodeAttribute(id, "label") ?? id
3855
+ })).filter((e) => e.id.includes("::")).sort((a, b) => b.degree - a.degree || a.id.localeCompare(b.id));
3856
+ return entries.slice(0, count).map((e) => `how does ${e.label} work`);
3857
+ }
3858
+ async function runBenchmark(questions) {
3859
+ const graph = await loadGraph();
3860
+ const effective = questions.length > 0 ? questions : defaultQuestions(graph);
3861
+ if (effective.length === 0) {
3862
+ console.log("The graph has no entity nodes to benchmark against \u2014 build it first.");
3863
+ return [];
3864
+ }
3865
+ const rows = [];
3866
+ for (const question of effective) {
3867
+ rows.push(await benchmarkQuestion(graph, question, (p) => fs14.readFile(p, "utf-8")));
3868
+ }
3869
+ console.log("Token cost per question \u2014 graph answer vs reading the files it touches:\n");
3870
+ for (const row of rows) {
3871
+ const reduction = row.reduction > 0 ? `${row.reduction.toFixed(1)}x` : "n/a";
3872
+ console.log(` "${row.question}"`);
3873
+ console.log(
3874
+ ` graph: ~${row.graphTokens} tokens | files: ~${row.naiveTokens} tokens (${row.filesRead} file(s)${row.filesMissing ? `, ${row.filesMissing} unreadable` : ""}) | reduction: ${reduction}`
3875
+ );
3876
+ }
3877
+ const scored = rows.filter((r) => r.reduction > 0);
3878
+ if (scored.length > 0) {
3879
+ const avg = scored.reduce((s, r) => s + r.reduction, 0) / scored.length;
3880
+ console.log(`
3881
+ Average reduction: ${avg.toFixed(1)}x across ${scored.length} question(s).`);
3882
+ } else {
3883
+ console.log("\nNo reduction could be measured \u2014 run from the project root so source files are readable.");
3884
+ }
3885
+ return rows;
3886
+ }
3887
+
3888
+ // src/cli/commands/check.ts
3889
+ init_cjs_shims();
3890
+ var fs15 = __toESM(require("fs/promises"), 1);
3891
+ var path9 = __toESM(require("path"), 1);
3892
+
3893
+ // src/check.ts
3894
+ init_cjs_shims();
3895
+ var DEPENDENCY_RELATIONS2 = /* @__PURE__ */ new Set([
3896
+ "calls",
3897
+ "imports",
3898
+ "imports_from",
3899
+ "inherits",
3900
+ "implements",
3901
+ "mixes_in",
3902
+ "embeds",
3903
+ "re_exports"
3904
+ ]);
3905
+ function globToRegExp(glob) {
3906
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
3907
+ const pattern = escaped.replace(
3908
+ /\*\*|\*|\?/g,
3909
+ (token) => token === "**" ? ".*" : token === "*" ? "[^/]*" : "[^/]"
3910
+ );
3911
+ return new RegExp(`^${pattern}$`);
3912
+ }
3913
+ function validateRules(value) {
3914
+ const config = value;
3915
+ if (!config || !Array.isArray(config.rules)) {
3916
+ throw new Error('Rules file must be JSON of shape { "rules": [{ "name", "from", "disallow": [...] }] }');
3917
+ }
3918
+ for (const rule of config.rules) {
3919
+ if (!rule.name || typeof rule.from !== "string" || !Array.isArray(rule.disallow)) {
3920
+ throw new Error(`Malformed rule "${rule.name ?? "(unnamed)"}" \u2014 need name, from, and disallow[].`);
3921
+ }
3922
+ }
3923
+ return config;
3924
+ }
3925
+ function checkRules(graph, config) {
3926
+ const compiled = config.rules.map((rule) => ({
3927
+ rule,
3928
+ from: globToRegExp(rule.from),
3929
+ disallow: rule.disallow.map(globToRegExp)
3930
+ }));
3931
+ const violations = [];
3932
+ graph.forEachEdge((_edge, attrs, source, target) => {
3933
+ const relation = String(attrs.relation);
3934
+ if (!DEPENDENCY_RELATIONS2.has(relation)) return;
3935
+ const fromFile = graph.getNodeAttribute(source, "sourceFile") ?? "";
3936
+ const toFile = graph.getNodeAttribute(target, "sourceFile") ?? "";
3937
+ if (!fromFile || !toFile || fromFile === toFile) return;
3938
+ for (const { rule, from, disallow } of compiled) {
3939
+ if (!from.test(fromFile)) continue;
3940
+ if (disallow.some((d) => d.test(toFile))) {
3941
+ violations.push({
3942
+ rule: rule.name,
3943
+ reason: rule.reason,
3944
+ fromFile,
3945
+ fromNode: source,
3946
+ toFile,
3947
+ toNode: target,
3948
+ relation
3949
+ });
3950
+ }
3951
+ }
3952
+ });
3953
+ violations.sort(
3954
+ (a, b) => a.rule.localeCompare(b.rule) || a.fromFile.localeCompare(b.fromFile) || a.toFile.localeCompare(b.toFile) || a.fromNode.localeCompare(b.fromNode)
3955
+ );
3956
+ return violations;
3957
+ }
3958
+
3959
+ // src/cli/commands/check.ts
3960
+ init_graphStore();
3961
+ async function runCheck(options = {}) {
3962
+ const rulesPath = path9.resolve(options.rules ?? "graphify.rules.json");
3963
+ let raw;
3964
+ try {
3965
+ raw = await fs15.readFile(rulesPath, "utf-8");
3966
+ } catch {
3967
+ throw new Error(
3968
+ `No rules file at ${rulesPath} \u2014 create graphify.rules.json with { "rules": [{ "name": "...", "from": "src/a/**", "disallow": ["src/b/**"] }] }.`
3969
+ );
3970
+ }
3971
+ const config = validateRules(JSON.parse(raw));
3972
+ const graph = await loadGraph();
3973
+ const violations = checkRules(graph, config);
3974
+ if (violations.length === 0) {
3975
+ console.log(`OK \u2014 ${config.rules.length} rule(s), no violations.`);
3976
+ return;
3977
+ }
3978
+ console.log(`${violations.length} violation(s):`);
3979
+ for (const v of violations) {
3980
+ console.log(` [${v.rule}] ${v.fromNode} --${v.relation}--> ${v.toNode}`);
3981
+ console.log(` ${v.fromFile} must not depend on ${v.toFile}${v.reason ? ` \u2014 ${v.reason}` : ""}`);
3982
+ }
3983
+ process.exitCode = 1;
3984
+ }
3985
+
3986
+ // src/cli/commands/context.ts
3987
+ init_cjs_shims();
3988
+ var fs16 = __toESM(require("fs/promises"), 1);
3989
+ init_context();
3990
+ init_graphStore();
3991
+ async function runContext(task, options = {}) {
3992
+ const graph = await loadGraph();
3993
+ const pack = await buildContextPack(graph, task, (p) => fs16.readFile(p, "utf-8"), {
3994
+ tokenBudget: options.budget
3995
+ });
3996
+ console.log(renderContextPack(pack));
3997
+ }
3998
+
2999
3999
  // src/cli/commands/explain.ts
3000
4000
  init_cjs_shims();
3001
4001
  init_graphStore();
@@ -3033,32 +4033,685 @@ async function runExplain(nodeName) {
3033
4033
  }
3034
4034
  }
3035
4035
 
4036
+ // src/cli/commands/review.ts
4037
+ init_cjs_shims();
4038
+
4039
+ // src/diff.ts
4040
+ init_cjs_shims();
4041
+ var import_node_child_process = require("child_process");
4042
+ var fs17 = __toESM(require("fs/promises"), 1);
4043
+ var os = __toESM(require("os"), 1);
4044
+ var path10 = __toESM(require("path"), 1);
4045
+ var import_node_util = require("util");
4046
+ init_impact();
4047
+ init_testmap();
4048
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
4049
+ async function graphForDirectory(root) {
4050
+ const manifest = collectFiles(root);
4051
+ const extractions = [];
4052
+ for (const relFile of manifest.files.code.sort((a, b) => a.localeCompare(b))) {
4053
+ extractions.push(await extract(path10.join(root, relFile), relFile));
4054
+ }
4055
+ resolveCrossFileReferences(extractions);
4056
+ return buildGraph(extractions);
4057
+ }
4058
+ async function checkoutRev(repoRoot, rev) {
4059
+ const dest = await fs17.mkdtemp(path10.join(os.tmpdir(), `graphify-review-${rev.replace(/[^a-zA-Z0-9]/g, "_")}-`));
4060
+ const { stdout } = await execFileAsync(
4061
+ "git",
4062
+ ["-C", repoRoot, "archive", "--format=tar", rev],
4063
+ { encoding: "buffer", maxBuffer: 512 * 1024 * 1024 }
4064
+ );
4065
+ await new Promise((resolve10, reject) => {
4066
+ const tar = (0, import_node_child_process.execFile)("tar", ["-x", "-C", dest], (error) => error ? reject(error) : resolve10());
4067
+ tar.stdin?.end(stdout);
4068
+ });
4069
+ return dest;
4070
+ }
4071
+ function edgeSignature(graph, id) {
4072
+ const signature = /* @__PURE__ */ new Set();
4073
+ graph.forEachOutEdge(id, (_e, attrs, _s, target) => signature.add(`-> ${String(attrs.relation)} ${target}`));
4074
+ graph.forEachInEdge(id, (_e, attrs, source) => signature.add(`<- ${String(attrs.relation)} ${source}`));
4075
+ return signature;
4076
+ }
4077
+ function describeSymbol(graph, id) {
4078
+ const attrs = graph.getNodeAttributes(id);
4079
+ const impact = affectedBy(graph, id, { maxDepth: 3 });
4080
+ const tests = testsForNode(graph, id);
4081
+ return {
4082
+ id,
4083
+ label: attrs.label ?? id,
4084
+ sourceFile: attrs.sourceFile ?? "",
4085
+ blastRadius: impact?.affected.length ?? 0,
4086
+ tests: tests?.testFiles.map((t) => t.file) ?? []
4087
+ };
4088
+ }
4089
+ function entityIds(graph) {
4090
+ const ids = /* @__PURE__ */ new Set();
4091
+ graph.forEachNode((id) => {
4092
+ if (id.includes("::")) ids.add(id);
4093
+ });
4094
+ return ids;
4095
+ }
4096
+ function diffGraphs(baseGraph, headGraph, base, head) {
4097
+ const baseIds = entityIds(baseGraph);
4098
+ const headIds = entityIds(headGraph);
4099
+ const added = [];
4100
+ const removed = [];
4101
+ const rewired = [];
4102
+ for (const id of [...headIds].sort((a, b) => a.localeCompare(b))) {
4103
+ if (!baseIds.has(id)) {
4104
+ added.push(describeSymbol(headGraph, id));
4105
+ continue;
4106
+ }
4107
+ const before = edgeSignature(baseGraph, id);
4108
+ const after = edgeSignature(headGraph, id);
4109
+ const gained = [...after].filter((e) => !before.has(e)).sort((a, b) => a.localeCompare(b));
4110
+ const lost = [...before].filter((e) => !after.has(e)).sort((a, b) => a.localeCompare(b));
4111
+ if (gained.length > 0 || lost.length > 0) {
4112
+ rewired.push({ ...describeSymbol(headGraph, id), gainedEdges: gained, lostEdges: lost });
4113
+ }
4114
+ }
4115
+ for (const id of [...baseIds].sort((a, b) => a.localeCompare(b))) {
4116
+ if (!headIds.has(id)) removed.push(describeSymbol(baseGraph, id));
4117
+ }
4118
+ return { base, head, added, removed, rewired };
4119
+ }
4120
+ async function reviewRevisions(repoRoot, base, head = "HEAD") {
4121
+ const [baseDir, headDir] = await Promise.all([checkoutRev(repoRoot, base), checkoutRev(repoRoot, head)]);
4122
+ try {
4123
+ const [baseGraph, headGraph] = [await graphForDirectory(baseDir), await graphForDirectory(headDir)];
4124
+ return diffGraphs(baseGraph, headGraph, base, head);
4125
+ } finally {
4126
+ await Promise.all([
4127
+ fs17.rm(baseDir, { recursive: true, force: true }),
4128
+ fs17.rm(headDir, { recursive: true, force: true })
4129
+ ]);
4130
+ }
4131
+ }
4132
+ function renderReview(diff) {
4133
+ const lines = [
4134
+ `# Structural review: ${diff.base}..${diff.head}`,
4135
+ "",
4136
+ `${diff.added.length} symbol(s) added, ${diff.removed.length} removed, ${diff.rewired.length} rewired.`,
4137
+ ""
4138
+ ];
4139
+ const section = (title, symbols) => {
4140
+ lines.push(`## ${title} (${symbols.length})`, "");
4141
+ if (symbols.length === 0) {
4142
+ lines.push("(none)", "");
4143
+ return;
4144
+ }
4145
+ for (const s of symbols) {
4146
+ const tests = s.tests.length > 0 ? ` | tests: ${s.tests.join(", ")}` : " | tests: none found";
4147
+ lines.push(`- **${s.label}** (${s.sourceFile}) \u2014 blast radius ${s.blastRadius}${tests}`);
4148
+ const r = s;
4149
+ if (r.gainedEdges) {
4150
+ for (const e of r.gainedEdges.slice(0, 5)) lines.push(` - gained: ${e}`);
4151
+ for (const e of r.lostEdges.slice(0, 5)) lines.push(` - lost: ${e}`);
4152
+ }
4153
+ }
4154
+ lines.push("");
4155
+ };
4156
+ section("Removed \u2014 check every caller", diff.removed);
4157
+ section("Rewired \u2014 dependencies changed", diff.rewired);
4158
+ section("Added", diff.added);
4159
+ const allTests = /* @__PURE__ */ new Set();
4160
+ for (const s of [...diff.removed, ...diff.rewired, ...diff.added]) {
4161
+ for (const t of s.tests) allTests.add(t);
4162
+ }
4163
+ lines.push(`## Suggested test run (${allTests.size} file(s))`, "");
4164
+ for (const t of [...allTests].sort((a, b) => a.localeCompare(b))) lines.push(`- ${t}`);
4165
+ lines.push("");
4166
+ return lines.join("\n");
4167
+ }
4168
+
4169
+ // src/cli/commands/review.ts
4170
+ async function runReview(base, head) {
4171
+ const diff = await reviewRevisions(process.cwd(), base, head ?? "HEAD");
4172
+ console.log(renderReview(diff));
4173
+ }
4174
+
4175
+ // src/cli/commands/tests.ts
4176
+ init_cjs_shims();
4177
+ var import_node_child_process2 = require("child_process");
4178
+ var import_node_util2 = require("util");
4179
+ init_graphStore();
4180
+ init_testmap();
4181
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
4182
+ async function runTests(nodeQuery, options = {}) {
4183
+ const graph = await loadGraph();
4184
+ let selection;
4185
+ if (options.changed !== void 0 && options.changed !== false) {
4186
+ const args = ["diff", "--name-only"];
4187
+ if (typeof options.changed === "string") args.push(options.changed);
4188
+ const { stdout } = await execFileAsync2("git", args);
4189
+ const changedFiles = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
4190
+ if (changedFiles.length === 0) {
4191
+ console.log("No changed files in the diff.");
4192
+ return;
4193
+ }
4194
+ selection = testsForChangedFiles(graph, changedFiles);
4195
+ } else if (nodeQuery) {
4196
+ selection = testsForNode(graph, nodeQuery);
4197
+ if (!selection) {
4198
+ console.log(`No node matched "${nodeQuery}".`);
4199
+ return;
4200
+ }
4201
+ } else {
4202
+ console.log("Provide a node to select tests for, or --changed for the working-tree diff.");
4203
+ return;
4204
+ }
4205
+ if (selection.testFiles.length === 0) {
4206
+ console.log(
4207
+ `No test files found in the blast radius of ${selection.target} (${selection.affectedNodeCount} affected node(s) checked) \u2014 either it's untested or the tests reach it through a path the graph does not capture.`
4208
+ );
4209
+ return;
4210
+ }
4211
+ console.log(`Test files for ${selection.target} (most direct first):`);
4212
+ for (const test of selection.testFiles) {
4213
+ const via = test.depth === 0 ? test.via : `depth ${test.depth}, via ${test.via}`;
4214
+ console.log(` ${test.file} (${via})`);
4215
+ }
4216
+ }
4217
+
4218
+ // src/cli/commands/global.ts
4219
+ init_cjs_shims();
4220
+ var fs18 = __toESM(require("fs/promises"), 1);
4221
+ var os2 = __toESM(require("os"), 1);
4222
+ var path11 = __toESM(require("path"), 1);
4223
+ init_graphStore();
4224
+
4225
+ // src/merge.ts
4226
+ init_cjs_shims();
4227
+ var import_graphology4 = __toESM(require("graphology"), 1);
4228
+ var CONFIDENCE_RANK3 = { EXTRACTED: 3, INFERRED: 2, AMBIGUOUS: 1 };
4229
+ function strongerConfidence2(a, b) {
4230
+ return CONFIDENCE_RANK3[a] >= CONFIDENCE_RANK3[b] ? a : b;
4231
+ }
4232
+ function isShared(id) {
4233
+ return id.startsWith("external:") || id.startsWith("module:");
4234
+ }
4235
+ function namespaced(project, id) {
4236
+ return isShared(id) ? id : `${project}/${id}`;
4237
+ }
4238
+ function mergeGraphs(entries) {
4239
+ const names = entries.map((e) => e.name);
4240
+ const duplicate = names.find((name, i) => names.indexOf(name) !== i);
4241
+ if (duplicate !== void 0) {
4242
+ throw new Error(`Duplicate project name in merge: "${duplicate}" \u2014 every entry needs a unique name.`);
4243
+ }
4244
+ const merged = new import_graphology4.default({ type: "directed", multi: true, allowSelfLoops: true });
4245
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
4246
+ for (const { name, graph } of sorted) {
4247
+ const nodeIds = [...graph.nodes()].sort((a, b) => a.localeCompare(b));
4248
+ for (const id of nodeIds) {
4249
+ const attrs = graph.getNodeAttributes(id);
4250
+ const newId = namespaced(name, id);
4251
+ if (merged.hasNode(newId)) {
4252
+ merged.setNodeAttribute(newId, "project", "(shared)");
4253
+ continue;
4254
+ }
4255
+ const sourceFile = attrs.sourceFile ?? "";
4256
+ merged.addNode(newId, {
4257
+ label: attrs.label ?? id,
4258
+ sourceFile: isShared(id) || sourceFile === "" ? sourceFile : `${name}/${sourceFile}`,
4259
+ sourceLocation: attrs.sourceLocation ?? "",
4260
+ project: name
4261
+ });
4262
+ }
4263
+ const edgeKeys = [...graph.edges()].sort((a, b) => a.localeCompare(b));
4264
+ for (const edgeKey of edgeKeys) {
4265
+ const attrs = graph.getEdgeAttributes(edgeKey);
4266
+ const source = namespaced(name, graph.source(edgeKey));
4267
+ const target = namespaced(name, graph.target(edgeKey));
4268
+ const relation = String(attrs.relation);
4269
+ const confidence = attrs.confidence;
4270
+ const key = `${source}|${relation}|${target}`;
4271
+ if (merged.hasEdge(key)) {
4272
+ const existing = merged.getEdgeAttribute(key, "confidence");
4273
+ merged.setEdgeAttribute(key, "confidence", strongerConfidence2(existing, confidence));
4274
+ continue;
4275
+ }
4276
+ merged.addEdgeWithKey(key, source, target, { relation, confidence });
4277
+ }
4278
+ }
4279
+ return merged;
4280
+ }
4281
+
4282
+ // src/cli/commands/global.ts
4283
+ function registryDir() {
4284
+ return path11.join(os2.homedir(), ".graphify");
4285
+ }
4286
+ function registryPath() {
4287
+ return path11.join(registryDir(), "global.json");
4288
+ }
4289
+ async function readRegistry() {
4290
+ try {
4291
+ const raw = await fs18.readFile(registryPath(), "utf-8");
4292
+ const parsed = JSON.parse(raw);
4293
+ const projects = parsed.projects;
4294
+ if (!Array.isArray(projects)) return { projects: [] };
4295
+ return { projects: projects.filter((p) => typeof p?.name === "string" && typeof p?.root === "string") };
4296
+ } catch {
4297
+ return { projects: [] };
4298
+ }
4299
+ }
4300
+ async function writeRegistry(registry) {
4301
+ await fs18.mkdir(registryDir(), { recursive: true });
4302
+ await fs18.writeFile(registryPath(), JSON.stringify(registry, null, 2), "utf-8");
4303
+ }
4304
+ async function projectNameFor(root) {
4305
+ try {
4306
+ const raw = await fs18.readFile(path11.join(root, "package.json"), "utf-8");
4307
+ const name = JSON.parse(raw).name;
4308
+ if (typeof name === "string" && name.length > 0) return name;
4309
+ } catch {
4310
+ }
4311
+ return path11.basename(root);
4312
+ }
4313
+ async function runGlobalAdd(target = ".") {
4314
+ const root = path11.resolve(target);
4315
+ const graphJson = path11.join(root, "graphify-out", "graph.json");
4316
+ try {
4317
+ await fs18.access(graphJson);
4318
+ } catch {
4319
+ throw new Error(`${root} has no graphify-out/graph.json \u2014 run \`graphify ${target}\` first, then add it.`);
4320
+ }
4321
+ const name = await projectNameFor(root);
4322
+ const registry = await readRegistry();
4323
+ const existing = registry.projects.find((p) => p.name === name);
4324
+ if (existing) {
4325
+ if (existing.root === root) {
4326
+ console.log(`${name} is already registered (${root}).`);
4327
+ return;
4328
+ }
4329
+ throw new Error(
4330
+ `A different project is already registered as "${name}" (${existing.root}). Remove it first with \`graphify global remove\`.`
4331
+ );
4332
+ }
4333
+ registry.projects.push({ name, root });
4334
+ registry.projects.sort((a, b) => a.name.localeCompare(b.name));
4335
+ await writeRegistry(registry);
4336
+ console.log(`Registered ${name} (${root}). ${registry.projects.length} project(s) in the global graph.`);
4337
+ }
4338
+ async function runGlobalRemove(name) {
4339
+ const registry = await readRegistry();
4340
+ const before = registry.projects.length;
4341
+ registry.projects = registry.projects.filter((p) => p.name !== name);
4342
+ if (registry.projects.length === before) {
4343
+ throw new Error(`No project named "${name}" is registered. See \`graphify global list\`.`);
4344
+ }
4345
+ await writeRegistry(registry);
4346
+ console.log(`Removed ${name}. ${registry.projects.length} project(s) remain.`);
4347
+ }
4348
+ async function runGlobalList() {
4349
+ const registry = await readRegistry();
4350
+ if (registry.projects.length === 0) {
4351
+ console.log("No projects registered. Add one with `graphify global add <path>`.");
4352
+ return;
4353
+ }
4354
+ for (const project of registry.projects) {
4355
+ console.log(`${project.name} ${project.root}`);
4356
+ }
4357
+ }
4358
+ async function loadEntries(sources) {
4359
+ const entries = [];
4360
+ for (const source of sources) {
4361
+ try {
4362
+ entries.push({ name: source.name, graph: await loadGraph(source.outDir) });
4363
+ } catch (error) {
4364
+ console.warn(`Skipping ${source.name}: ${error.message}`);
4365
+ }
4366
+ }
4367
+ return entries;
4368
+ }
4369
+ async function mergeAndExport(entries, outDir) {
4370
+ if (entries.length === 0) {
4371
+ throw new Error("Nothing to merge \u2014 no project graphs could be loaded.");
4372
+ }
4373
+ const merged = mergeGraphs(entries);
4374
+ cluster(merged);
4375
+ const analysis = analyze(merged);
4376
+ const report = renderReport(merged, analysis);
4377
+ await exportGraph(merged, { outDir }, report);
4378
+ console.log(
4379
+ `Merged ${entries.length} project(s) into ${outDir} \u2014 ${merged.order} node(s), ${merged.size} edge(s).`
4380
+ );
4381
+ }
4382
+ async function runGlobalBuild(options = {}) {
4383
+ const registry = await readRegistry();
4384
+ if (registry.projects.length === 0) {
4385
+ throw new Error("No projects registered. Add some with `graphify global add <path>` first.");
4386
+ }
4387
+ const entries = await loadEntries(
4388
+ registry.projects.map((p) => ({ name: p.name, outDir: path11.join(p.root, "graphify-out") }))
4389
+ );
4390
+ await mergeAndExport(entries, path11.resolve(options.out ?? path11.join(registryDir(), "global-out")));
4391
+ }
4392
+ async function runMerge(targets, options = {}) {
4393
+ if (targets.length < 2) {
4394
+ throw new Error("Provide at least two project directories to merge.");
4395
+ }
4396
+ const sources = [];
4397
+ for (const target of targets) {
4398
+ const root = path11.resolve(target);
4399
+ sources.push({ name: await projectNameFor(root), outDir: path11.join(root, "graphify-out") });
4400
+ }
4401
+ const names = sources.map((s) => s.name);
4402
+ const duplicate = names.find((name, i) => names.indexOf(name) !== i);
4403
+ if (duplicate !== void 0) {
4404
+ throw new Error(`Two of the given projects resolve to the same name ("${duplicate}") \u2014 rename one.`);
4405
+ }
4406
+ const entries = await loadEntries(sources);
4407
+ await mergeAndExport(entries, path11.resolve(options.out ?? "graphify-merged"));
4408
+ }
4409
+
4410
+ // src/cli/commands/hook.ts
4411
+ init_cjs_shims();
4412
+ var fs19 = __toESM(require("fs/promises"), 1);
4413
+ var path12 = __toESM(require("path"), 1);
4414
+ var MARKER_BEGIN = "# >>> graphify >>>";
4415
+ var MARKER_END = "# <<< graphify <<<";
4416
+ var HOOK_NAMES = ["post-commit", "post-merge"];
4417
+ var HOOK_BLOCK = `${MARKER_BEGIN}
4418
+ # Rebuild the graphify knowledge graph in the background so the hook never
4419
+ # slows down the commit. Errors are silenced \u2014 a failed rebuild should never
4420
+ # break a git operation. Installed by \`graphify hook install\`.
4421
+ (graphify . --update --no-viz >/dev/null 2>&1 &)
4422
+ ${MARKER_END}`;
4423
+ async function hooksDir(cwd) {
4424
+ const gitDir = path12.join(cwd, ".git");
4425
+ let stats;
4426
+ try {
4427
+ stats = await fs19.stat(gitDir);
4428
+ } catch {
4429
+ throw new Error(`${cwd} is not a git repository (no .git directory) \u2014 run this from the repo root.`);
4430
+ }
4431
+ if (!stats.isDirectory()) {
4432
+ const content = (await fs19.readFile(gitDir, "utf-8")).trim();
4433
+ const match = /^gitdir:\s*(.+)$/.exec(content);
4434
+ if (!match) throw new Error(`${gitDir} exists but is neither a directory nor a gitdir pointer.`);
4435
+ return path12.resolve(cwd, match[1], "hooks");
4436
+ }
4437
+ return path12.join(gitDir, "hooks");
4438
+ }
4439
+ async function runHookInstall(cwd = process.cwd()) {
4440
+ const dir = await hooksDir(cwd);
4441
+ await fs19.mkdir(dir, { recursive: true });
4442
+ const results = [];
4443
+ for (const hook of HOOK_NAMES) {
4444
+ const hookPath = path12.join(dir, hook);
4445
+ let existing = null;
4446
+ try {
4447
+ existing = await fs19.readFile(hookPath, "utf-8");
4448
+ } catch {
4449
+ }
4450
+ if (existing === null) {
4451
+ await fs19.writeFile(hookPath, `#!/bin/sh
4452
+ ${HOOK_BLOCK}
4453
+ `, { mode: 493 });
4454
+ results.push({ hook, path: hookPath, action: "created" });
4455
+ } else if (existing.includes(MARKER_BEGIN)) {
4456
+ results.push({ hook, path: hookPath, action: "already-installed" });
4457
+ } else {
4458
+ const separator = existing.endsWith("\n") ? "" : "\n";
4459
+ await fs19.writeFile(hookPath, `${existing}${separator}${HOOK_BLOCK}
4460
+ `, "utf-8");
4461
+ await fs19.chmod(hookPath, 493);
4462
+ results.push({ hook, path: hookPath, action: "appended" });
4463
+ }
4464
+ }
4465
+ for (const result of results) {
4466
+ console.log(`${result.hook}: ${result.action} (${result.path})`);
4467
+ }
4468
+ console.log("");
4469
+ console.log("The graph will rebuild in the background after each commit and pull.");
4470
+ console.log("Remove with `graphify hook uninstall`.");
4471
+ return results;
4472
+ }
4473
+ async function runHookUninstall(cwd = process.cwd()) {
4474
+ const dir = await hooksDir(cwd);
4475
+ const results = [];
4476
+ for (const hook of HOOK_NAMES) {
4477
+ const hookPath = path12.join(dir, hook);
4478
+ let existing = null;
4479
+ try {
4480
+ existing = await fs19.readFile(hookPath, "utf-8");
4481
+ } catch {
4482
+ }
4483
+ if (existing === null || !existing.includes(MARKER_BEGIN)) {
4484
+ results.push({ hook, path: hookPath, action: "not-installed" });
4485
+ continue;
4486
+ }
4487
+ const blockPattern = new RegExp(`\\n?${MARKER_BEGIN}[\\s\\S]*?${MARKER_END}\\n?`);
4488
+ const remaining = existing.replace(blockPattern, "\n").trim();
4489
+ if (remaining === "" || remaining === "#!/bin/sh") {
4490
+ await fs19.unlink(hookPath);
4491
+ } else {
4492
+ await fs19.writeFile(hookPath, `${remaining}
4493
+ `, "utf-8");
4494
+ }
4495
+ results.push({ hook, path: hookPath, action: "removed" });
4496
+ }
4497
+ for (const result of results) {
4498
+ console.log(`${result.hook}: ${result.action}`);
4499
+ }
4500
+ return results;
4501
+ }
4502
+
4503
+ // src/memory.ts
4504
+ init_cjs_shims();
4505
+ var import_node_crypto4 = require("crypto");
4506
+ var fs20 = __toESM(require("fs/promises"), 1);
4507
+ var path13 = __toESM(require("path"), 1);
4508
+ var OUTCOMES = /* @__PURE__ */ new Set(["useful", "dead_end", "corrected"]);
4509
+ var TYPES = /* @__PURE__ */ new Set(["query", "path", "explain", "affected"]);
4510
+ function validateResult(value) {
4511
+ if (!value.question) throw new Error("save-result requires --question");
4512
+ if (!value.answer) throw new Error("save-result requires --answer");
4513
+ if (!OUTCOMES.has(value.outcome)) {
4514
+ throw new Error(`--outcome must be one of: useful, dead_end, corrected (got "${value.outcome}")`);
4515
+ }
4516
+ if (!TYPES.has(value.type)) {
4517
+ throw new Error(`--type must be one of: query, path, explain, affected (got "${value.type}")`);
4518
+ }
4519
+ if (value.outcome === "corrected" && !value.correction) {
4520
+ throw new Error("--outcome corrected requires --correction with what the right answer was");
4521
+ }
4522
+ }
4523
+ async function saveResult(entry, memoryDir, now = /* @__PURE__ */ new Date()) {
4524
+ validateResult(entry);
4525
+ await fs20.mkdir(memoryDir, { recursive: true });
4526
+ const saved = { ...entry, savedAt: now.toISOString() };
4527
+ const hash = (0, import_node_crypto4.createHash)("sha256").update(JSON.stringify(saved)).digest("hex").slice(0, 8);
4528
+ const stamp = saved.savedAt.replace(/[:.]/g, "-");
4529
+ const filePath = path13.join(memoryDir, `result-${stamp}-${hash}.json`);
4530
+ await fs20.writeFile(filePath, JSON.stringify(saved, null, 2), "utf-8");
4531
+ return filePath;
4532
+ }
4533
+ async function loadResults(memoryDir) {
4534
+ let files;
4535
+ try {
4536
+ files = (await fs20.readdir(memoryDir)).filter((f) => f.startsWith("result-") && f.endsWith(".json"));
4537
+ } catch {
4538
+ return [];
4539
+ }
4540
+ const results = [];
4541
+ for (const file of files.sort((a, b) => a.localeCompare(b))) {
4542
+ try {
4543
+ const parsed = JSON.parse(await fs20.readFile(path13.join(memoryDir, file), "utf-8"));
4544
+ if (parsed.question && parsed.savedAt) results.push(parsed);
4545
+ } catch {
4546
+ }
4547
+ }
4548
+ return results;
4549
+ }
4550
+ function renderLessons(results, options = {}) {
4551
+ const halfLife = options.halfLifeDays ?? 30;
4552
+ const now = options.now ?? /* @__PURE__ */ new Date();
4553
+ const minCorroboration = options.minCorroboration ?? 2;
4554
+ const weightOf = (savedAt) => {
4555
+ const ageDays = Math.max(0, (now.getTime() - new Date(savedAt).getTime()) / 864e5);
4556
+ return Math.pow(0.5, ageDays / halfLife);
4557
+ };
4558
+ const signals = /* @__PURE__ */ new Map();
4559
+ const corrections = [];
4560
+ for (const result of results) {
4561
+ const weight = weightOf(result.savedAt);
4562
+ for (const node of result.nodes ?? []) {
4563
+ const signal = signals.get(node) ?? { useful: 0, deadEnd: 0, usefulCount: 0, deadEndCount: 0 };
4564
+ if (result.outcome === "useful") {
4565
+ signal.useful += weight;
4566
+ signal.usefulCount++;
4567
+ } else if (result.outcome === "dead_end") {
4568
+ signal.deadEnd += weight;
4569
+ signal.deadEndCount++;
4570
+ }
4571
+ signals.set(node, signal);
4572
+ }
4573
+ if (result.outcome === "corrected" && result.correction) {
4574
+ corrections.push({ question: result.question, correction: result.correction, savedAt: result.savedAt });
4575
+ }
4576
+ }
4577
+ 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);
4578
+ const lines = [
4579
+ "# Graph lessons",
4580
+ "",
4581
+ `_Aggregated from ${results.length} saved result(s); signal half-life ${halfLife} day(s)._`,
4582
+ "",
4583
+ "## Nodes that keep answering",
4584
+ ""
4585
+ ];
4586
+ const useful = byScore("useful");
4587
+ if (useful.length === 0) {
4588
+ lines.push(`(none yet with >= ${minCorroboration} corroborating results \u2014 keep saving results)`);
4589
+ } else {
4590
+ for (const [node, s] of useful) {
4591
+ lines.push(`- **${node}** \u2014 score ${s.useful.toFixed(2)} across ${s.usefulCount} useful result(s)`);
4592
+ }
4593
+ }
4594
+ lines.push("", "## Dead ends", "");
4595
+ const deadEnds = byScore("deadEnd");
4596
+ if (deadEnds.length === 0) {
4597
+ lines.push("(none recorded)");
4598
+ } else {
4599
+ for (const [node, s] of deadEnds) {
4600
+ lines.push(`- **${node}** \u2014 score ${s.deadEnd.toFixed(2)} across ${s.deadEndCount} dead-end result(s)`);
4601
+ }
4602
+ }
4603
+ lines.push("", "## Corrections", "");
4604
+ if (corrections.length === 0) {
4605
+ lines.push("(none recorded)");
4606
+ } else {
4607
+ corrections.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
4608
+ for (const c of corrections.slice(0, 20)) {
4609
+ lines.push(`- (${c.savedAt.slice(0, 10)}) Q: ${c.question}`);
4610
+ lines.push(` Right answer: ${c.correction}`);
4611
+ }
4612
+ }
4613
+ lines.push("");
4614
+ return lines.join("\n");
4615
+ }
4616
+
3036
4617
  // src/cli/commands/install.ts
3037
4618
  init_cjs_shims();
3038
4619
  var import_node_module3 = require("module");
3039
- var fs12 = __toESM(require("fs/promises"), 1);
3040
- var path8 = __toESM(require("path"), 1);
4620
+ var fs21 = __toESM(require("fs/promises"), 1);
4621
+ var path14 = __toESM(require("path"), 1);
3041
4622
  var require4 = (0, import_node_module3.createRequire)(importMetaUrl);
3042
4623
  function packageRoot() {
3043
4624
  const packageJsonPath = require4.resolve("@dreamtree-org/graphify/package.json");
3044
- return path8.dirname(packageJsonPath);
4625
+ return path14.dirname(packageJsonPath);
3045
4626
  }
3046
- async function runInstall(cwd = process.cwd()) {
3047
- const sourcePath = path8.join(packageRoot(), "src", "skill", "SKILL.md");
3048
- const content = await fs12.readFile(sourcePath, "utf-8");
4627
+ var MD_MARKER_BEGIN = "<!-- >>> graphify >>> -->";
4628
+ var MD_MARKER_END = "<!-- <<< graphify <<< -->";
4629
+ async function writeOwnedFile(filePath, content) {
4630
+ await fs21.mkdir(path14.dirname(filePath), { recursive: true });
4631
+ await fs21.writeFile(filePath, content, "utf-8");
4632
+ }
4633
+ async function upsertMarkedBlock(filePath, content) {
4634
+ const block = `${MD_MARKER_BEGIN}
4635
+ ${content.trim()}
4636
+ ${MD_MARKER_END}`;
4637
+ let existing = null;
4638
+ try {
4639
+ existing = await fs21.readFile(filePath, "utf-8");
4640
+ } catch {
4641
+ }
4642
+ if (existing === null) {
4643
+ await fs21.mkdir(path14.dirname(filePath), { recursive: true });
4644
+ await fs21.writeFile(filePath, `${block}
4645
+ `, "utf-8");
4646
+ return;
4647
+ }
4648
+ if (existing.includes(MD_MARKER_BEGIN)) {
4649
+ const pattern = new RegExp(`${MD_MARKER_BEGIN}[\\s\\S]*?${MD_MARKER_END}`);
4650
+ await fs21.writeFile(filePath, existing.replace(pattern, block), "utf-8");
4651
+ return;
4652
+ }
4653
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
4654
+ await fs21.writeFile(filePath, `${existing}${separator}${block}
4655
+ `, "utf-8");
4656
+ }
4657
+ var PLATFORMS = {
4658
+ claude: async (cwd, content) => {
4659
+ const target = path14.join(cwd, ".claude", "skills", "graphify", "SKILL.md");
4660
+ await writeOwnedFile(target, content);
4661
+ return { host: "Claude Code", path: target };
4662
+ },
4663
+ cursor: async (cwd, content) => {
4664
+ const target = path14.join(cwd, ".cursor", "rules", "graphify.mdc");
4665
+ const body = content.replace(/^---[\s\S]*?---\n/, "");
4666
+ await writeOwnedFile(
4667
+ target,
4668
+ `---
4669
+ description: Query the graphify knowledge graph for codebase structure questions
4670
+ alwaysApply: false
4671
+ ---
4672
+ ${body}`
4673
+ );
4674
+ return { host: "Cursor", path: target };
4675
+ },
4676
+ windsurf: async (cwd, content) => {
4677
+ const target = path14.join(cwd, ".windsurf", "rules", "graphify.md");
4678
+ await writeOwnedFile(target, content.replace(/^---[\s\S]*?---\n/, ""));
4679
+ return { host: "Windsurf", path: target };
4680
+ },
4681
+ cline: async (cwd, content) => {
4682
+ const target = path14.join(cwd, ".clinerules", "graphify.md");
4683
+ await writeOwnedFile(target, content.replace(/^---[\s\S]*?---\n/, ""));
4684
+ return { host: "Cline", path: target };
4685
+ },
4686
+ agents: async (cwd, content) => {
4687
+ const target = path14.join(cwd, "AGENTS.md");
4688
+ await upsertMarkedBlock(target, content.replace(/^---[\s\S]*?---\n/, ""));
4689
+ return { host: "AGENTS.md agents (Codex, opencode, Amp, ...)", path: target };
4690
+ },
4691
+ gemini: async (cwd, content) => {
4692
+ const target = path14.join(cwd, "GEMINI.md");
4693
+ await upsertMarkedBlock(target, content.replace(/^---[\s\S]*?---\n/, ""));
4694
+ return { host: "Gemini CLI", path: target };
4695
+ }
4696
+ };
4697
+ var SUPPORTED_PLATFORMS = Object.keys(PLATFORMS).sort((a, b) => a.localeCompare(b));
4698
+ async function runInstall(platforms = ["claude"], cwd = process.cwd()) {
4699
+ const sourcePath = path14.join(packageRoot(), "src", "skill", "SKILL.md");
4700
+ const content = await fs21.readFile(sourcePath, "utf-8");
4701
+ const requested = platforms.includes("all") ? SUPPORTED_PLATFORMS : platforms;
3049
4702
  const results = [];
3050
- const claudeCodeDir = path8.join(cwd, ".claude", "skills", "graphify");
3051
- await fs12.mkdir(claudeCodeDir, { recursive: true });
3052
- const claudeCodePath = path8.join(claudeCodeDir, "SKILL.md");
3053
- await fs12.writeFile(claudeCodePath, content, "utf-8");
3054
- results.push({ host: "Claude Code", path: claudeCodePath });
4703
+ for (const platform of requested) {
4704
+ const installer = PLATFORMS[platform];
4705
+ if (!installer) {
4706
+ throw new Error(
4707
+ `Unknown platform "${platform}" \u2014 supported: ${SUPPORTED_PLATFORMS.join(", ")}, or "all".`
4708
+ );
4709
+ }
4710
+ results.push(await installer(cwd, content));
4711
+ }
3055
4712
  for (const result of results) {
3056
- console.log(`Installed graphify skill for ${result.host} -> ${result.path}`);
4713
+ console.log(`Installed graphify for ${result.host} -> ${result.path}`);
3057
4714
  }
3058
- console.log("");
3059
- console.log(
3060
- "Other hosts (Cursor, etc.) are not implemented yet by `graphify install` \u2014 see ARCHITECTURE.md."
3061
- );
3062
4715
  return results;
3063
4716
  }
3064
4717
 
@@ -3096,7 +4749,7 @@ init_graphStore();
3096
4749
  init_query();
3097
4750
  async function runQuery(question, options = {}) {
3098
4751
  const graph = await loadGraph();
3099
- const result = queryGraph(graph, question, { dfs: options.dfs, budget: options.budget });
4752
+ const result = queryGraph(graph, question, { dfs: options.dfs, tokenBudget: options.budget });
3100
4753
  if (result.seeds.length === 0) {
3101
4754
  console.log(
3102
4755
  `No nodes matched "${question}". Try different keywords, or run \`graphify explain <node>\` if you already know the name.`
@@ -3121,15 +4774,15 @@ async function runQuery(question, options = {}) {
3121
4774
  }
3122
4775
 
3123
4776
  // src/cli/index.ts
3124
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
4777
+ var execFileAsync3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
3125
4778
  function looksLikeGitUrl(value) {
3126
4779
  if (!/^https?:\/\//i.test(value)) return false;
3127
4780
  return /github\.com|gitlab\.com|bitbucket\.org/i.test(value) || value.endsWith(".git");
3128
4781
  }
3129
4782
  async function cloneRepo(url) {
3130
4783
  const validated = validateUrl(url);
3131
- const dest = await fs13.mkdtemp(path10.join(os.tmpdir(), "graphify-clone-"));
3132
- await execFileAsync("git", ["clone", "--depth", "1", validated, dest]);
4784
+ const dest = await fs23.mkdtemp(path16.join(os3.tmpdir(), "graphify-clone-"));
4785
+ await execFileAsync3("git", ["clone", "--depth", "1", validated, dest]);
3133
4786
  return dest;
3134
4787
  }
3135
4788
  function exportOptionsFrom(options) {
@@ -3142,7 +4795,7 @@ function clusterOptionsFrom(options) {
3142
4795
  };
3143
4796
  }
3144
4797
  async function runClusterOnly(root, options) {
3145
- const outDir = path10.join(root, "graphify-out");
4798
+ const outDir = path16.join(root, "graphify-out");
3146
4799
  const graph = await loadGraph(outDir);
3147
4800
  cluster(graph, clusterOptionsFrom(options));
3148
4801
  const analysis = analyze(graph);
@@ -3173,12 +4826,12 @@ async function watchAndRebuild(root, runOnce) {
3173
4826
  });
3174
4827
  }
3175
4828
  var program = new import_commander.Command();
3176
- program.name("graphify").description("Turn a folder of code/docs/papers into a queryable knowledge graph.").argument("[path]", "path to scan, or a GitHub URL", ".").option("--mode <mode>", "extraction mode (deep for richer INFERRED edges \u2014 reserved, no-op in v1)").option("--update", "incremental re-extract of changed files only (not implemented in v1 \u2014 runs full pipeline)").option("--watch", "rebuild on file change").option("--cluster-only", "rerun clustering on an existing graph").option("--leiden", "use Leiden instead of Louvain for community detection (v2 \u2014 better community quality)").option("--max-iterations <n>", "Leiden only: cap on outer iterations for very large graphs", Number).option("--no-viz", "skip graph.html").option("--svg", "also export graph.svg (not implemented in v1)").option("--graphml", "also export graph.graphml (not implemented in v1)").option("--neo4j", "generate graphify-out/cypher.txt for Neo4j (not implemented in v1)").option("--mcp", "start MCP stdio server instead of running the pipeline").action(async (targetArg, options) => {
4829
+ program.name("graphify").description("Turn a folder of code/docs/papers into a queryable knowledge graph.").argument("[path]", "path to scan, or a GitHub URL", ".").option("--mode <mode>", "extraction mode (deep for richer INFERRED edges \u2014 reserved, no-op in v1)").option("--update", "incremental rebuild \u2014 reuse cached extractions for unchanged files").option("--watch", "rebuild on file change").option("--cluster-only", "rerun clustering on an existing graph").option("--leiden", "use Leiden instead of Louvain for community detection (v2 \u2014 better community quality)").option("--max-iterations <n>", "Leiden only: cap on outer iterations for very large graphs", Number).option("--no-viz", "skip graph.html").option("--svg", "also export graph.svg (not implemented in v1)").option("--graphml", "also export graph.graphml (not implemented in v1)").option("--neo4j", "generate graphify-out/cypher.txt for Neo4j (not implemented in v1)").option("--mcp", "start MCP stdio server instead of running the pipeline").option("--mysql <dsn>", "also extract a MySQL schema (mysql://user:pass@host:port/db) into the graph").action(async (targetArg, options) => {
3177
4830
  try {
3178
4831
  if (options.mcp) {
3179
- const outDir = path10.resolve(targetArg === "." ? process.cwd() : targetArg, "graphify-out");
4832
+ const outDir = path16.resolve(targetArg === "." ? process.cwd() : targetArg, "graphify-out");
3180
4833
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
3181
- await startServer2(path10.join(outDir, "graph.json"));
4834
+ await startServer2(path16.join(outDir, "graph.json"));
3182
4835
  return;
3183
4836
  }
3184
4837
  let root = targetArg;
@@ -3186,12 +4839,7 @@ program.name("graphify").description("Turn a folder of code/docs/papers into a q
3186
4839
  console.error(`Cloning ${targetArg} ...`);
3187
4840
  root = await cloneRepo(targetArg);
3188
4841
  }
3189
- root = path10.resolve(root);
3190
- if (options.update) {
3191
- console.error(
3192
- "--update (incremental re-extract) is not implemented yet in v1 \u2014 running the full pipeline instead."
3193
- );
3194
- }
4842
+ root = path16.resolve(root);
3195
4843
  if (options.mode) {
3196
4844
  console.error(`--mode ${options.mode} is reserved for a future richer-INFERRED-edges pass \u2014 currently a no-op.`);
3197
4845
  }
@@ -3199,9 +4847,17 @@ program.name("graphify").description("Turn a folder of code/docs/papers into a q
3199
4847
  await runClusterOnly(root, options);
3200
4848
  return;
3201
4849
  }
4850
+ let extraExtractions;
4851
+ if (options.mysql) {
4852
+ const { extractMysql: extractMysql2 } = await Promise.resolve().then(() => (init_mysql(), mysql_exports));
4853
+ console.error(`Extracting MySQL schema from ${validateDsn(options.mysql).safeDisplay} ...`);
4854
+ extraExtractions = [await extractMysql2(options.mysql)];
4855
+ }
3202
4856
  const runOnce = () => runPipeline(root, {
3203
4857
  ...exportOptionsFrom(options),
3204
4858
  ...clusterOptionsFrom(options),
4859
+ extraExtractions,
4860
+ update: options.update,
3205
4861
  onProgress: (m) => console.error(m)
3206
4862
  });
3207
4863
  await runOnce();
@@ -3238,13 +4894,153 @@ program.command("explain <node>").description("plain-language explanation of a n
3238
4894
  process.exitCode = 1;
3239
4895
  }
3240
4896
  });
3241
- program.command("install").description("install the skill files into the local agent(s)").action(async () => {
4897
+ program.command("affected <node>").description("reverse impact analysis \u2014 everything that (transitively) depends on a node").option("--depth <n>", "how many reverse hops to follow (default 3)", Number).option("--limit <n>", "cap on affected nodes reported (default 200)", Number).action(async (node, options) => {
4898
+ try {
4899
+ await runAffected(node, options);
4900
+ } catch (error) {
4901
+ console.error(`graphify affected: ${error.message}`);
4902
+ process.exitCode = 1;
4903
+ }
4904
+ });
4905
+ program.command("context <task>").description("token-budgeted working-set pack \u2014 the actual code for a task, in one call").option("--budget <tokens>", "approximate token cap for the pack (default 4000)", Number).action(async (task, options) => {
4906
+ try {
4907
+ await runContext(task, options);
4908
+ } catch (error) {
4909
+ console.error(`graphify context: ${error.message}`);
4910
+ process.exitCode = 1;
4911
+ }
4912
+ });
4913
+ program.command("tests [node]").description("structural test selection \u2014 the minimal test files worth running for a change").option("--changed [rev]", "select for the working-tree diff (optionally vs a revision) instead of a node").action(async (node, options) => {
3242
4914
  try {
3243
- await runInstall();
4915
+ await runTests(node, options);
4916
+ } catch (error) {
4917
+ console.error(`graphify tests: ${error.message}`);
4918
+ process.exitCode = 1;
4919
+ }
4920
+ });
4921
+ program.command("review <base> [head]").description("structural review between two git revisions: added/removed/rewired symbols with blast radius + tests").action(async (base, head) => {
4922
+ try {
4923
+ await runReview(base, head);
4924
+ } catch (error) {
4925
+ console.error(`graphify review: ${error.message}`);
4926
+ process.exitCode = 1;
4927
+ }
4928
+ });
4929
+ program.command("check").description("check the graph against dependency rules (graphify.rules.json) \u2014 exits 1 on violations").option("--rules <file>", "rules file (default ./graphify.rules.json)").action(async (options) => {
4930
+ try {
4931
+ await runCheck(options);
4932
+ } catch (error) {
4933
+ console.error(`graphify check: ${error.message}`);
4934
+ process.exitCode = 1;
4935
+ }
4936
+ });
4937
+ program.command("benchmark [questions...]").description("measure token savings of graph answers vs reading the files they touch").action(async (questions) => {
4938
+ try {
4939
+ await runBenchmark(questions);
4940
+ } catch (error) {
4941
+ console.error(`graphify benchmark: ${error.message}`);
4942
+ process.exitCode = 1;
4943
+ }
4944
+ });
4945
+ program.command("install").description("install the skill/rules files into local agents (claude, cursor, windsurf, cline, agents, gemini, all)").option("--platform <names...>", "platform(s) to install for", ["claude"]).action(async (options) => {
4946
+ try {
4947
+ await runInstall(options.platform);
3244
4948
  } catch (error) {
3245
4949
  console.error(`graphify install: ${error.message}`);
3246
4950
  process.exitCode = 1;
3247
4951
  }
3248
4952
  });
4953
+ var globalCommand = program.command("global").description("manage the cross-project global graph (registry at ~/.graphify/global.json)");
4954
+ globalCommand.command("add [path]").description("register a project (must already have graphify-out/graph.json)").action(async (target) => {
4955
+ try {
4956
+ await runGlobalAdd(target);
4957
+ } catch (error) {
4958
+ console.error(`graphify global add: ${error.message}`);
4959
+ process.exitCode = 1;
4960
+ }
4961
+ });
4962
+ globalCommand.command("remove <name>").description("unregister a project by name").action(async (name) => {
4963
+ try {
4964
+ await runGlobalRemove(name);
4965
+ } catch (error) {
4966
+ console.error(`graphify global remove: ${error.message}`);
4967
+ process.exitCode = 1;
4968
+ }
4969
+ });
4970
+ globalCommand.command("list").description("list registered projects").action(async () => {
4971
+ try {
4972
+ await runGlobalList();
4973
+ } catch (error) {
4974
+ console.error(`graphify global list: ${error.message}`);
4975
+ process.exitCode = 1;
4976
+ }
4977
+ });
4978
+ globalCommand.command("build").description("merge every registered project into one global graph").option("--out <dir>", "output directory (default ~/.graphify/global-out)").action(async (options) => {
4979
+ try {
4980
+ await runGlobalBuild(options);
4981
+ } catch (error) {
4982
+ console.error(`graphify global build: ${error.message}`);
4983
+ process.exitCode = 1;
4984
+ }
4985
+ });
4986
+ program.command("merge <dirs...>").description("one-shot merge of two or more built project graphs into one").option("--out <dir>", "output directory (default ./graphify-merged)").action(async (dirs, options) => {
4987
+ try {
4988
+ await runMerge(dirs, options);
4989
+ } catch (error) {
4990
+ console.error(`graphify merge: ${error.message}`);
4991
+ process.exitCode = 1;
4992
+ }
4993
+ });
4994
+ program.command("save-result").description("save a Q&A result to graphify-out/memory/ for the graph feedback loop").requiredOption("--question <q>", "the question that was asked").requiredOption("--answer <a>", "the answer that was given").option("--nodes <ids...>", "node ids/labels cited in the answer", []).option("--type <t>", "query|path|explain|affected", "query").option("--outcome <o>", "useful|dead_end|corrected", "useful").option("--correction <text>", "what the right answer was (pairs with --outcome corrected)").action(async (options) => {
4995
+ try {
4996
+ const memoryDir = path16.join(process.cwd(), "graphify-out", "memory");
4997
+ const file = await saveResult(
4998
+ {
4999
+ question: options.question,
5000
+ answer: options.answer,
5001
+ nodes: options.nodes,
5002
+ type: options.type,
5003
+ outcome: options.outcome,
5004
+ correction: options.correction
5005
+ },
5006
+ memoryDir
5007
+ );
5008
+ console.log(`Saved -> ${file}`);
5009
+ } catch (error) {
5010
+ console.error(`graphify save-result: ${error.message}`);
5011
+ process.exitCode = 1;
5012
+ }
5013
+ });
5014
+ program.command("reflect").description("aggregate graphify-out/memory/ into a recency-weighted lessons doc").option("--half-life-days <n>", "a result loses half its weight every N days (default 30)", Number).option("--out <file>", "output path (default graphify-out/reflections/LESSONS.md)").action(async (options) => {
5015
+ try {
5016
+ const memoryDir = path16.join(process.cwd(), "graphify-out", "memory");
5017
+ const results = await loadResults(memoryDir);
5018
+ const lessons = renderLessons(results, { halfLifeDays: options.halfLifeDays });
5019
+ const outPath = path16.resolve(options.out ?? path16.join(process.cwd(), "graphify-out", "reflections", "LESSONS.md"));
5020
+ await fs23.mkdir(path16.dirname(outPath), { recursive: true });
5021
+ await fs23.writeFile(outPath, lessons, "utf-8");
5022
+ console.log(`Reflected ${results.length} result(s) -> ${outPath}`);
5023
+ } catch (error) {
5024
+ console.error(`graphify reflect: ${error.message}`);
5025
+ process.exitCode = 1;
5026
+ }
5027
+ });
5028
+ var hookCommand = program.command("hook").description("manage the git hooks that auto-rebuild the graph on commit/pull");
5029
+ hookCommand.command("install").description("install post-commit and post-merge hooks (preserves existing hook content)").action(async () => {
5030
+ try {
5031
+ await runHookInstall();
5032
+ } catch (error) {
5033
+ console.error(`graphify hook install: ${error.message}`);
5034
+ process.exitCode = 1;
5035
+ }
5036
+ });
5037
+ hookCommand.command("uninstall").description("remove the graphify block from the git hooks").action(async () => {
5038
+ try {
5039
+ await runHookUninstall();
5040
+ } catch (error) {
5041
+ console.error(`graphify hook uninstall: ${error.message}`);
5042
+ process.exitCode = 1;
5043
+ }
5044
+ });
3249
5045
  program.parseAsync(process.argv);
3250
5046
  //# sourceMappingURL=index.cjs.map