@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.
- package/README.md +106 -35
- package/dist/chunk-6JLEILYF.js +207 -0
- package/dist/chunk-6JLEILYF.js.map +1 -0
- package/dist/{chunk-DG5FECXV.js → chunk-EW23BLJC.js} +557 -27
- package/dist/chunk-EW23BLJC.js.map +1 -0
- package/dist/chunk-YT7B6DOD.js +554 -0
- package/dist/chunk-YT7B6DOD.js.map +1 -0
- package/dist/chunk-ZPB37LLQ.js +155 -0
- package/dist/chunk-ZPB37LLQ.js.map +1 -0
- package/dist/cli/index.cjs +1939 -143
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +683 -40
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +1131 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +333 -5
- package/dist/index.d.ts +333 -5
- package/dist/index.js +50 -5
- package/dist/mcp/server.cjs +444 -56
- package/dist/mcp/server.cjs.map +1 -1
- package/dist/mcp/server.js +65 -4
- package/dist/mcp/server.js.map +1 -1
- package/dist/mysql-EJ6XOWR4.js +8 -0
- package/dist/mysql-EJ6XOWR4.js.map +1 -0
- package/package.json +2 -1
- package/src/skill/SKILL.md +45 -10
- package/dist/chunk-5ANIDX3G.js +0 -364
- package/dist/chunk-5ANIDX3G.js.map +0 -1
- package/dist/chunk-DG5FECXV.js.map +0 -1
package/dist/mcp/server.cjs
CHANGED
|
@@ -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-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
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("
|
|
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()) {
|