@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.
@@ -35,57 +35,12 @@ __export(server_exports, {
35
35
  startServer: () => startServer
36
36
  });
37
37
  module.exports = __toCommonJS(server_exports);
38
+ var fs3 = __toESM(require("fs/promises"), 1);
38
39
  var path2 = __toESM(require("path"), 1);
39
40
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
40
41
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
41
42
  var import_zod = require("zod");
42
43
 
43
- // src/graphStore.ts
44
- var fs2 = __toESM(require("fs/promises"), 1);
45
- var path = __toESM(require("path"), 1);
46
- var import_graphology = __toESM(require("graphology"), 1);
47
-
48
- // src/security.ts
49
- var import_node_crypto = require("crypto");
50
- var dns = __toESM(require("dns"), 1);
51
- var http = __toESM(require("http"), 1);
52
- var https = __toESM(require("https"), 1);
53
- var fs = __toESM(require("fs"), 1);
54
- var net = __toESM(require("net"), 1);
55
- var nodePath = __toESM(require("path"), 1);
56
- var MAX_FETCH_BYTES = 50 * 1024 * 1024;
57
- var MAX_TEXT_BYTES = 10 * 1024 * 1024;
58
- function validateGraphPath(path3, base) {
59
- const baseDir = base ?? nodePath.join(process.cwd(), "graphify-out");
60
- let resolvedBase;
61
- try {
62
- resolvedBase = fs.realpathSync(baseDir);
63
- } catch {
64
- throw new Error(`Base directory does not exist: ${baseDir}`);
65
- }
66
- const candidate = nodePath.isAbsolute(path3) ? path3 : nodePath.join(resolvedBase, path3);
67
- const resolvedCandidate = nodePath.resolve(candidate);
68
- let realCandidate = resolvedCandidate;
69
- try {
70
- realCandidate = fs.realpathSync(resolvedCandidate);
71
- } catch {
72
- }
73
- const relative2 = nodePath.relative(resolvedBase, realCandidate);
74
- const escapes = relative2 === ".." || relative2.startsWith(`..${nodePath.sep}`) || nodePath.isAbsolute(relative2);
75
- if (escapes) {
76
- throw new Error(`Path escapes graphify-out/: ${path3}`);
77
- }
78
- return realCandidate;
79
- }
80
-
81
- // src/graphStore.ts
82
- async function loadGraph(outDir = path.join(process.cwd(), "graphify-out")) {
83
- const jsonPath = validateGraphPath("graph.json", outDir);
84
- const raw = await fs2.readFile(jsonPath, "utf-8");
85
- const data = JSON.parse(raw);
86
- return import_graphology.default.from(data);
87
- }
88
-
89
44
  // src/query.ts
90
45
  var STOPWORDS = /* @__PURE__ */ new Set([
91
46
  "a",
@@ -118,8 +73,48 @@ var STOPWORDS = /* @__PURE__ */ new Set([
118
73
  "does",
119
74
  "that's"
120
75
  ]);
76
+ function splitCamelCase(text) {
77
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
78
+ }
121
79
  function tokenize(text) {
122
- return text.toLowerCase().split(/[^a-z0-9_.]+/).filter((token) => token.length > 1 && !STOPWORDS.has(token));
80
+ return splitCamelCase(text).toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 1 && !STOPWORDS.has(token));
81
+ }
82
+ function subtokenize(text) {
83
+ return splitCamelCase(text).toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 1);
84
+ }
85
+ var FUZZY_MIN_TOKEN_LEN = 3;
86
+ var FUZZY_THRESHOLD = 0.7;
87
+ var SUBTOKEN_MATCH_SCORE = 1;
88
+ var SUBSTRING_MATCH_SCORE = 0.6;
89
+ var FUZZY_MATCH_WEIGHT = 0.5;
90
+ function osaDistance(a, b) {
91
+ let prev2 = [];
92
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
93
+ let curr = new Array(b.length + 1).fill(0);
94
+ for (let i = 1; i <= a.length; i++) {
95
+ curr[0] = i;
96
+ for (let j = 1; j <= b.length; j++) {
97
+ const substitution = a[i - 1] === b[j - 1] ? 0 : 1;
98
+ let best = Math.min(
99
+ prev[j] + 1,
100
+ curr[j - 1] + 1,
101
+ prev[j - 1] + substitution
102
+ );
103
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
104
+ best = Math.min(best, prev2[j - 2] + 1);
105
+ }
106
+ curr[j] = best;
107
+ }
108
+ [prev2, prev, curr] = [prev, curr, new Array(b.length + 1).fill(0)];
109
+ }
110
+ return prev[b.length];
111
+ }
112
+ function fuzzySimilarity(a, b) {
113
+ if (a === b) return 1;
114
+ const maxLen = Math.max(a.length, b.length);
115
+ if (maxLen === 0) return 1;
116
+ if (Math.abs(a.length - b.length) / maxLen > 1 - FUZZY_THRESHOLD) return 0;
117
+ return 1 - osaDistance(a, b) / maxLen;
123
118
  }
124
119
  function scoreNodes(graph, query) {
125
120
  const tokens = tokenize(query);
@@ -127,9 +122,25 @@ function scoreNodes(graph, query) {
127
122
  graph.forEachNode((id, attributes) => {
128
123
  const label = attributes.label ?? id;
129
124
  const haystack = `${label} ${id}`.toLowerCase();
125
+ const subtokens = new Set(subtokenize(`${label} ${id}`));
130
126
  let score = 0;
131
127
  for (const token of tokens) {
132
- if (haystack.includes(token)) score += 1;
128
+ if (subtokens.has(token)) {
129
+ score += SUBTOKEN_MATCH_SCORE;
130
+ continue;
131
+ }
132
+ if (haystack.includes(token)) {
133
+ score += SUBSTRING_MATCH_SCORE;
134
+ continue;
135
+ }
136
+ if (token.length >= FUZZY_MIN_TOKEN_LEN) {
137
+ let best = 0;
138
+ for (const subtoken of subtokens) {
139
+ const similarity = fuzzySimilarity(token, subtoken);
140
+ if (similarity > best) best = similarity;
141
+ }
142
+ if (best >= FUZZY_THRESHOLD) score += best * FUZZY_MATCH_WEIGHT;
143
+ }
133
144
  }
134
145
  if (score > 0) matches.push({ id, label, score });
135
146
  });
@@ -143,22 +154,36 @@ function resolveNode(graph, query) {
143
154
  var DEFAULT_MAX_DEPTH = 2;
144
155
  var DEFAULT_MAX_SEEDS = 5;
145
156
  var DEFAULT_BUDGET = 40;
157
+ var DEFAULT_TOKEN_BUDGET = 2e3;
158
+ var NODES_PER_TOKEN = 1 / 10;
159
+ function nodeTokenCost(graph, id) {
160
+ const attrs = graph.getNodeAttributes(id);
161
+ const label = attrs.label ?? id;
162
+ const sourceFile = attrs.sourceFile ?? "";
163
+ return Math.ceil((id.length * 2 + label.length + sourceFile.length + 24) / 4);
164
+ }
146
165
  function queryGraph(graph, question, options = {}) {
147
166
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
148
167
  const maxSeeds = options.maxSeeds ?? DEFAULT_MAX_SEEDS;
149
- const budget = options.budget ?? DEFAULT_BUDGET;
168
+ const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
169
+ const budget = options.budget ?? Math.max(DEFAULT_BUDGET, Math.ceil(tokenBudget * NODES_PER_TOKEN));
150
170
  const allMatches = scoreNodes(graph, question);
151
171
  const seeds = allMatches.length > 0 ? allMatches.slice(0, maxSeeds) : [];
152
172
  const visited = /* @__PURE__ */ new Map();
153
173
  const frontier = seeds.map((s) => ({ id: s.id, depth: 0 }));
154
- for (const seed of seeds) visited.set(seed.id, 0);
155
- while (frontier.length > 0 && visited.size < budget) {
174
+ let spentTokens = 0;
175
+ for (const seed of seeds) {
176
+ visited.set(seed.id, 0);
177
+ spentTokens += nodeTokenCost(graph, seed.id);
178
+ }
179
+ while (frontier.length > 0 && visited.size < budget && spentTokens < tokenBudget) {
156
180
  const current = options.dfs ? frontier.pop() : frontier.shift();
157
181
  if (current.depth >= maxDepth) continue;
158
182
  const neighbors = [...graph.neighbors(current.id)].sort((a, b) => a.localeCompare(b));
159
183
  for (const neighbor of neighbors) {
160
- if (visited.has(neighbor) || visited.size >= budget) continue;
184
+ if (visited.has(neighbor) || visited.size >= budget || spentTokens >= tokenBudget) continue;
161
185
  visited.set(neighbor, current.depth + 1);
186
+ spentTokens += nodeTokenCost(graph, neighbor);
162
187
  frontier.push({ id: neighbor, depth: current.depth + 1 });
163
188
  }
164
189
  }
@@ -186,7 +211,30 @@ function queryGraph(graph, question, options = {}) {
186
211
  edges.sort(
187
212
  (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) || a.relation.localeCompare(b.relation)
188
213
  );
189
- return { seeds, visited: visitedNodes, edges };
214
+ return enforceTokenBudget({ seeds, visited: visitedNodes, edges }, tokenBudget);
215
+ }
216
+ function enforceTokenBudget(result, tokenBudget) {
217
+ const cost = (value) => Math.ceil(JSON.stringify(value).length / 4);
218
+ const visited = [...result.visited];
219
+ let edges = [...result.edges];
220
+ let total = cost(result.seeds) + visited.reduce((s, n) => s + cost(n), 0) + edges.reduce((s, e) => s + cost(e), 0);
221
+ const seedIds = new Set(result.seeds.map((s) => s.id));
222
+ while (total > tokenBudget && visited.length > 0) {
223
+ const last = visited[visited.length - 1];
224
+ if (seedIds.has(last.id)) break;
225
+ visited.pop();
226
+ total -= cost(last);
227
+ const remaining = [];
228
+ for (const edge of edges) {
229
+ if (edge.source === last.id || edge.target === last.id) {
230
+ total -= cost(edge);
231
+ } else {
232
+ remaining.push(edge);
233
+ }
234
+ }
235
+ edges = remaining;
236
+ }
237
+ return { seeds: result.seeds, visited, edges };
190
238
  }
191
239
  function shortestPath(graph, fromQuery, toQuery) {
192
240
  const from = resolveNode(graph, fromQuery);
@@ -257,6 +305,292 @@ function explainNode(graph, query) {
257
305
  };
258
306
  }
259
307
 
308
+ // src/context.ts
309
+ var DEFAULT_CONTEXT_BUDGET = 4e3;
310
+ var DEFAULT_MAX_SEEDS2 = 8;
311
+ var DEFAULT_MAX_SNIPPET_LINES = 60;
312
+ var NEIGHBOR_DECAY = 0.4;
313
+ var tokensOf = (text) => Math.ceil(text.length / 4);
314
+ function lineNumberOf(sourceLocation) {
315
+ const match = /^L(\d+)$/.exec(sourceLocation);
316
+ return match ? Number(match[1]) : null;
317
+ }
318
+ function isReadableFile(sourceFile) {
319
+ return sourceFile !== "" && !sourceFile.startsWith("<") && !sourceFile.includes("://");
320
+ }
321
+ function rankForTask(graph, task, maxSeeds = DEFAULT_MAX_SEEDS2) {
322
+ const seeds = scoreNodes(graph, task).slice(0, maxSeeds);
323
+ const ranked = /* @__PURE__ */ new Map();
324
+ for (const seed of seeds) {
325
+ ranked.set(seed.id, { id: seed.id, score: seed.score, reason: "matches the task" });
326
+ }
327
+ for (const seed of seeds) {
328
+ graph.forEachEdge(seed.id, (_edge, attrs, source, target) => {
329
+ const neighbor = source === seed.id ? target : source;
330
+ if (ranked.has(neighbor)) return;
331
+ const relation = String(attrs.relation);
332
+ const direction = source === seed.id ? `${relation} ->` : `<- ${relation}`;
333
+ ranked.set(neighbor, {
334
+ id: neighbor,
335
+ score: seed.score * NEIGHBOR_DECAY,
336
+ reason: `${direction} ${seed.label}`
337
+ });
338
+ });
339
+ }
340
+ return [...ranked.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
341
+ }
342
+ function snippetRange(startLine, fileLineCount, entityStartsInFile, maxLines) {
343
+ const nextStart = entityStartsInFile.find((l) => l > startLine);
344
+ const hardEnd = nextStart !== void 0 ? nextStart - 1 : fileLineCount;
345
+ return { start: startLine, end: Math.min(hardEnd, startLine + maxLines - 1) };
346
+ }
347
+ async function buildContextPack(graph, task, readFile3, options = {}) {
348
+ const tokenBudget = options.tokenBudget ?? DEFAULT_CONTEXT_BUDGET;
349
+ const maxSnippetLines = options.maxSnippetLines ?? DEFAULT_MAX_SNIPPET_LINES;
350
+ const ranked = rankForTask(graph, task, options.maxSeeds);
351
+ const entityStarts = /* @__PURE__ */ new Map();
352
+ graph.forEachNode((_id, attrs) => {
353
+ const file = attrs.sourceFile;
354
+ const line = lineNumberOf(attrs.sourceLocation ?? "");
355
+ if (file && line !== null) {
356
+ const starts = entityStarts.get(file) ?? [];
357
+ starts.push(line);
358
+ entityStarts.set(file, starts);
359
+ }
360
+ });
361
+ for (const starts of entityStarts.values()) starts.sort((a, b) => a - b);
362
+ const fileCache = /* @__PURE__ */ new Map();
363
+ const readLines = async (file) => {
364
+ const cached = fileCache.get(file);
365
+ if (cached !== void 0) return cached;
366
+ let lines;
367
+ try {
368
+ lines = (await readFile3(file)).split("\n");
369
+ } catch {
370
+ lines = null;
371
+ }
372
+ fileCache.set(file, lines);
373
+ return lines;
374
+ };
375
+ const snippets = [];
376
+ const overflow = [];
377
+ const coveredRanges = /* @__PURE__ */ new Map();
378
+ let tokens = 0;
379
+ for (const node of ranked) {
380
+ const attrs = graph.getNodeAttributes(node.id);
381
+ const file = attrs.sourceFile ?? "";
382
+ const startLine = lineNumberOf(attrs.sourceLocation ?? "");
383
+ if (!isReadableFile(file) || startLine === null) continue;
384
+ const lines = await readLines(file);
385
+ if (lines === null) continue;
386
+ const { start, end } = snippetRange(startLine, lines.length, entityStarts.get(file) ?? [], maxSnippetLines);
387
+ const covered = (coveredRanges.get(file) ?? []).some((r) => start >= r.start && end <= r.end);
388
+ if (covered) continue;
389
+ const code = lines.slice(start - 1, end).join("\n");
390
+ const cost = tokensOf(code) + 15;
391
+ if (tokens + cost > tokenBudget) {
392
+ overflow.push({ nodeId: node.id, file: `${file}:L${startLine}` });
393
+ continue;
394
+ }
395
+ tokens += cost;
396
+ snippets.push({
397
+ nodeId: node.id,
398
+ label: attrs.label ?? node.id,
399
+ file,
400
+ startLine: start,
401
+ endLine: end,
402
+ code,
403
+ reason: node.reason,
404
+ score: node.score
405
+ });
406
+ const ranges = coveredRanges.get(file) ?? [];
407
+ ranges.push({ start, end });
408
+ coveredRanges.set(file, ranges);
409
+ }
410
+ return { task, snippets, tokens, tokenBudget, overflow };
411
+ }
412
+ function renderContextPack(pack) {
413
+ const lines = [
414
+ `# Context pack: ${pack.task}`,
415
+ "",
416
+ `_~${pack.tokens} of ${pack.tokenBudget} token budget, ${pack.snippets.length} snippet(s)._`,
417
+ ""
418
+ ];
419
+ if (pack.snippets.length === 0) {
420
+ lines.push("No matching code found \u2014 try different keywords.");
421
+ return lines.join("\n");
422
+ }
423
+ for (const snippet of pack.snippets) {
424
+ lines.push(`## ${snippet.label} \u2014 \`${snippet.file}:L${snippet.startLine}-L${snippet.endLine}\``);
425
+ lines.push(`_${snippet.reason}_`);
426
+ lines.push("```");
427
+ lines.push(snippet.code);
428
+ lines.push("```");
429
+ lines.push("");
430
+ }
431
+ if (pack.overflow.length > 0) {
432
+ lines.push(`## Didn't fit the budget (${pack.overflow.length})`);
433
+ for (const item of pack.overflow.slice(0, 15)) {
434
+ lines.push(`- ${item.nodeId} (${item.file})`);
435
+ }
436
+ lines.push("");
437
+ }
438
+ return lines.join("\n");
439
+ }
440
+
441
+ // src/graphStore.ts
442
+ var fs2 = __toESM(require("fs/promises"), 1);
443
+ var path = __toESM(require("path"), 1);
444
+ var import_graphology = __toESM(require("graphology"), 1);
445
+
446
+ // src/security.ts
447
+ var import_node_crypto = require("crypto");
448
+ var dns = __toESM(require("dns"), 1);
449
+ var http = __toESM(require("http"), 1);
450
+ var https = __toESM(require("https"), 1);
451
+ var fs = __toESM(require("fs"), 1);
452
+ var net = __toESM(require("net"), 1);
453
+ var nodePath = __toESM(require("path"), 1);
454
+ var MAX_FETCH_BYTES = 50 * 1024 * 1024;
455
+ var MAX_TEXT_BYTES = 10 * 1024 * 1024;
456
+ function validateGraphPath(path3, base) {
457
+ const baseDir = base ?? nodePath.join(process.cwd(), "graphify-out");
458
+ let resolvedBase;
459
+ try {
460
+ resolvedBase = fs.realpathSync(baseDir);
461
+ } catch {
462
+ throw new Error(`Base directory does not exist: ${baseDir}`);
463
+ }
464
+ const candidate = nodePath.isAbsolute(path3) ? path3 : nodePath.join(resolvedBase, path3);
465
+ const resolvedCandidate = nodePath.resolve(candidate);
466
+ let realCandidate = resolvedCandidate;
467
+ try {
468
+ realCandidate = fs.realpathSync(resolvedCandidate);
469
+ } catch {
470
+ }
471
+ const relative2 = nodePath.relative(resolvedBase, realCandidate);
472
+ const escapes = relative2 === ".." || relative2.startsWith(`..${nodePath.sep}`) || nodePath.isAbsolute(relative2);
473
+ if (escapes) {
474
+ throw new Error(`Path escapes graphify-out/: ${path3}`);
475
+ }
476
+ return realCandidate;
477
+ }
478
+
479
+ // src/graphStore.ts
480
+ async function loadGraph(outDir = path.join(process.cwd(), "graphify-out")) {
481
+ const jsonPath = validateGraphPath("graph.json", outDir);
482
+ const raw = await fs2.readFile(jsonPath, "utf-8");
483
+ const data = JSON.parse(raw);
484
+ return import_graphology.default.from(data);
485
+ }
486
+
487
+ // src/impact.ts
488
+ var DEPENDENCY_RELATIONS = /* @__PURE__ */ new Set([
489
+ "calls",
490
+ "imports",
491
+ "imports_from",
492
+ "inherits",
493
+ "implements",
494
+ "mixes_in",
495
+ "embeds",
496
+ "references",
497
+ "re_exports",
498
+ "method",
499
+ "contains"
500
+ ]);
501
+ var DEFAULT_IMPACT_DEPTH = 3;
502
+ var DEFAULT_IMPACT_LIMIT = 200;
503
+ var CONFIDENCE_RANK = { EXTRACTED: 3, INFERRED: 2, AMBIGUOUS: 1 };
504
+ function affectedBy(graph, nodeQuery, options = {}) {
505
+ const target = resolveNode(graph, nodeQuery);
506
+ if (!target) return null;
507
+ const maxDepth = options.maxDepth ?? DEFAULT_IMPACT_DEPTH;
508
+ const limit = options.limit ?? DEFAULT_IMPACT_LIMIT;
509
+ const affected = [];
510
+ const visited = /* @__PURE__ */ new Set([target.id]);
511
+ let frontier = [target.id];
512
+ let truncated = false;
513
+ for (let depth = 1; depth <= maxDepth && frontier.length > 0 && !truncated; depth++) {
514
+ const nextFrontier = /* @__PURE__ */ new Map();
515
+ for (const current of [...frontier].sort((a, b) => a.localeCompare(b))) {
516
+ graph.forEachInEdge(current, (_edge, edgeAttrs, source) => {
517
+ if (visited.has(source)) return;
518
+ const relation = edgeAttrs.relation;
519
+ if (!DEPENDENCY_RELATIONS.has(relation)) return;
520
+ const confidence = edgeAttrs.confidence;
521
+ const existing = nextFrontier.get(source);
522
+ if (existing && CONFIDENCE_RANK[existing.via.confidence] >= CONFIDENCE_RANK[confidence]) return;
523
+ const attrs = graph.getNodeAttributes(source);
524
+ nextFrontier.set(source, {
525
+ id: source,
526
+ label: attrs.label ?? source,
527
+ sourceFile: attrs.sourceFile ?? "",
528
+ sourceLocation: attrs.sourceLocation ?? "",
529
+ depth,
530
+ via: { relation, confidence, dependsOn: current }
531
+ });
532
+ });
533
+ }
534
+ const roundNodes = [...nextFrontier.values()].sort((a, b) => a.id.localeCompare(b.id));
535
+ for (const node of roundNodes) {
536
+ if (affected.length >= limit) {
537
+ truncated = true;
538
+ break;
539
+ }
540
+ visited.add(node.id);
541
+ affected.push(node);
542
+ }
543
+ frontier = roundNodes.filter((n) => visited.has(n.id)).map((n) => n.id);
544
+ }
545
+ const byConfidence = { EXTRACTED: 0, INFERRED: 0, AMBIGUOUS: 0 };
546
+ for (const node of affected) byConfidence[node.via.confidence] += 1;
547
+ return { target, affected, byConfidence, maxDepth, truncated };
548
+ }
549
+
550
+ // src/testmap.ts
551
+ var TEST_FILE_PATTERNS = [
552
+ /(^|\/)tests?\//,
553
+ // tests/ or test/ directory
554
+ /(^|\/)__tests__\//,
555
+ /(^|\/)spec\//,
556
+ /\.test\.[cm]?[jt]sx?$/,
557
+ /\.spec\.[cm]?[jt]sx?$/,
558
+ /(^|\/)test_[^/]+\.py$/,
559
+ /_test\.py$/,
560
+ /_test\.go$/,
561
+ /Tests?\.(java|cs)$/,
562
+ /_spec\.rb$/,
563
+ /_test\.rb$/
564
+ ];
565
+ function isTestFile(path3) {
566
+ return TEST_FILE_PATTERNS.some((p) => p.test(path3));
567
+ }
568
+ var TEST_REACH_DEPTH = 4;
569
+ var TEST_REACH_LIMIT = 2e3;
570
+ function selectionFromImpact(graph, targetIds, targetLabel) {
571
+ const best = /* @__PURE__ */ new Map();
572
+ let affectedNodeCount = 0;
573
+ for (const id of targetIds) {
574
+ const impact = affectedBy(graph, id, { maxDepth: TEST_REACH_DEPTH, limit: TEST_REACH_LIMIT });
575
+ if (!impact) continue;
576
+ affectedNodeCount += impact.affected.length;
577
+ for (const node of impact.affected) {
578
+ if (!isTestFile(node.sourceFile)) continue;
579
+ const existing = best.get(node.sourceFile);
580
+ if (!existing || node.depth < existing.depth) {
581
+ best.set(node.sourceFile, { depth: node.depth, via: node.via.dependsOn });
582
+ }
583
+ }
584
+ }
585
+ 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));
586
+ return { target: targetLabel, testFiles, affectedNodeCount };
587
+ }
588
+ function testsForNode(graph, nodeQuery) {
589
+ const match = resolveNode(graph, nodeQuery);
590
+ if (!match) return null;
591
+ return selectionFromImpact(graph, [match.id], match.id);
592
+ }
593
+
260
594
  // src/mcp/server.ts
261
595
  function textResult(text) {
262
596
  return { content: [{ type: "text", text }] };
@@ -270,12 +604,13 @@ function createGraphifyMcpServer(outDir) {
270
604
  inputSchema: {
271
605
  question: import_zod.z.string().describe("Natural-language question or keywords to search the graph for."),
272
606
  dfs: import_zod.z.boolean().optional().describe("Use depth-first traversal instead of the default breadth-first."),
273
- budget: import_zod.z.number().int().positive().optional().describe("Approximate cap on how many nodes to include.")
607
+ budget: import_zod.z.number().int().positive().optional().describe("Hard cap on how many nodes to include."),
608
+ tokenBudget: import_zod.z.number().int().positive().optional().describe("Approximate cap, in tokens, on the serialized size of the result (default 2000).")
274
609
  }
275
610
  },
276
- async ({ question, dfs, budget }) => {
611
+ async ({ question, dfs, budget, tokenBudget }) => {
277
612
  const graph = await loadGraph(outDir);
278
- const result = queryGraph(graph, question, { dfs, budget });
613
+ const result = queryGraph(graph, question, { dfs, budget, tokenBudget });
279
614
  return textResult(JSON.stringify(result, null, 2));
280
615
  }
281
616
  );
@@ -314,6 +649,59 @@ function createGraphifyMcpServer(outDir) {
314
649
  return textResult(JSON.stringify(result, null, 2));
315
650
  }
316
651
  );
652
+ server.registerTool(
653
+ "affected",
654
+ {
655
+ 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.',
656
+ inputSchema: {
657
+ node: import_zod.z.string().describe("Name/label (or id) of the node being changed."),
658
+ depth: import_zod.z.number().int().positive().optional().describe("How many reverse hops to follow (default 3)."),
659
+ limit: import_zod.z.number().int().positive().optional().describe("Cap on affected nodes reported (default 200).")
660
+ }
661
+ },
662
+ async ({ node, depth, limit }) => {
663
+ const graph = await loadGraph(outDir);
664
+ const result = affectedBy(graph, node, { maxDepth: depth, limit });
665
+ if (!result) {
666
+ return textResult(`No node matched "${node}".`);
667
+ }
668
+ return textResult(JSON.stringify(result, null, 2));
669
+ }
670
+ );
671
+ server.registerTool(
672
+ "context",
673
+ {
674
+ 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.",
675
+ inputSchema: {
676
+ task: import_zod.z.string().describe("What you are about to do, in natural language."),
677
+ budget: import_zod.z.number().int().positive().optional().describe("Approximate token cap (default 4000).")
678
+ }
679
+ },
680
+ async ({ task, budget }) => {
681
+ const graph = await loadGraph(outDir);
682
+ const pack = await buildContextPack(graph, task, (p) => fs3.readFile(p, "utf-8"), {
683
+ tokenBudget: budget
684
+ });
685
+ return textResult(renderContextPack(pack));
686
+ }
687
+ );
688
+ server.registerTool(
689
+ "tests",
690
+ {
691
+ 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.",
692
+ inputSchema: {
693
+ node: import_zod.z.string().describe("Name/label (or id) of the changed node.")
694
+ }
695
+ },
696
+ async ({ node }) => {
697
+ const graph = await loadGraph(outDir);
698
+ const result = testsForNode(graph, node);
699
+ if (!result) {
700
+ return textResult(`No node matched "${node}".`);
701
+ }
702
+ return textResult(JSON.stringify(result, null, 2));
703
+ }
704
+ );
317
705
  return server;
318
706
  }
319
707
  async function startServer(graphPath, transport = new import_stdio.StdioServerTransport()) {