kodingo-cli 1.0.38 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +11 -0
- package/dist/commands/scan-repo.js +48 -9
- package/dist/commands/sync-graph.js +104 -0
- package/dist/utils/ast-parser.js +93 -0
- package/dist/utils/context-assembler.js +91 -0
- package/dist/utils/graph-builder.js +384 -0
- package/dist/utils/tree-sitter-parser.js +281 -0
- package/package.json +6 -4
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ const deny_1 = require("./commands/deny");
|
|
|
14
14
|
const scan_git_1 = require("./commands/scan-git");
|
|
15
15
|
const scan_claude_1 = require("./commands/scan-claude");
|
|
16
16
|
const scan_repo_1 = require("./commands/scan-repo");
|
|
17
|
+
const sync_graph_1 = require("./commands/sync-graph");
|
|
17
18
|
const init_1 = require("./commands/init");
|
|
18
19
|
const install_hook_1 = require("./commands/install-hook");
|
|
19
20
|
const update_1 = require("./commands/update");
|
|
@@ -231,6 +232,16 @@ program
|
|
|
231
232
|
payload.repo = String(options.repo);
|
|
232
233
|
await (0, scan_repo_1.scanRepoCommand)(payload);
|
|
233
234
|
});
|
|
235
|
+
program
|
|
236
|
+
.command("sync-graph")
|
|
237
|
+
.description("Build and sync the structural relationship graph for the current repo")
|
|
238
|
+
.option("--repo <path>", "repo path to scan")
|
|
239
|
+
.action(async (options) => {
|
|
240
|
+
const payload = {};
|
|
241
|
+
if (options.repo)
|
|
242
|
+
payload.repo = String(options.repo);
|
|
243
|
+
await (0, sync_graph_1.syncGraphCommand)(payload);
|
|
244
|
+
});
|
|
234
245
|
program
|
|
235
246
|
.command("context")
|
|
236
247
|
.description("Get affirmed memories for a file — designed for AI agents")
|
|
@@ -55,6 +55,8 @@ const path_1 = __importDefault(require("path"));
|
|
|
55
55
|
const persistence_config_1 = require("../utils/persistence-config");
|
|
56
56
|
const os = __importStar(require("os"));
|
|
57
57
|
const ast_parser_1 = require("../utils/ast-parser");
|
|
58
|
+
const graph_builder_1 = require("../utils/graph-builder");
|
|
59
|
+
const context_assembler_1 = require("../utils/context-assembler");
|
|
58
60
|
const API_BASE = "https://kodingo-api.onrender.com";
|
|
59
61
|
const SUPPORTED_EXTENSIONS = [
|
|
60
62
|
".ts", ".tsx", ".js", ".jsx",
|
|
@@ -124,7 +126,8 @@ async function fetchExistingSymbols(token, repo, apiUrl) {
|
|
|
124
126
|
if (!res.ok)
|
|
125
127
|
break;
|
|
126
128
|
const data = await res.json();
|
|
127
|
-
|
|
129
|
+
const page = data.data ?? [];
|
|
130
|
+
for (const m of page) {
|
|
128
131
|
if (m.symbol) {
|
|
129
132
|
// Store both symbol-only and symbol::repo keys for accurate matching
|
|
130
133
|
known.add(m.symbol);
|
|
@@ -133,8 +136,8 @@ async function fetchExistingSymbols(token, repo, apiUrl) {
|
|
|
133
136
|
}
|
|
134
137
|
}
|
|
135
138
|
offset += limit;
|
|
136
|
-
if (
|
|
137
|
-
break;
|
|
139
|
+
if (page.length < limit)
|
|
140
|
+
break; // fewer than a full page means we've reached the end
|
|
138
141
|
}
|
|
139
142
|
}
|
|
140
143
|
catch { }
|
|
@@ -150,12 +153,29 @@ async function sendSignal(token, apiUrl, memoryId, weightClass) {
|
|
|
150
153
|
}
|
|
151
154
|
catch { }
|
|
152
155
|
}
|
|
153
|
-
async function inferAndCapture(token, symbol, repo) {
|
|
156
|
+
async function inferAndCapture(token, symbol, repo, graph, repoRoot) {
|
|
154
157
|
try {
|
|
155
|
-
|
|
158
|
+
// Use contextual inference when the graph knows this symbol — richer, structure-grounded reasoning.
|
|
159
|
+
// Falls back to isolated /infer for symbols the graph didn't resolve (e.g. parse failures).
|
|
160
|
+
const useContextual = graph && symbol.fingerprint && graph.nodes[symbol.fingerprint];
|
|
161
|
+
const endpoint = useContextual ? "/infer/contextual" : "/infer";
|
|
162
|
+
let body = { symbol: symbol.name, code: symbol.code };
|
|
163
|
+
if (useContextual && graph && symbol.fingerprint) {
|
|
164
|
+
const assembled = (0, context_assembler_1.assembleContext)(repoRoot, graph, symbol.fingerprint);
|
|
165
|
+
if (assembled) {
|
|
166
|
+
body = {
|
|
167
|
+
symbol: symbol.name,
|
|
168
|
+
code: symbol.code,
|
|
169
|
+
callers: assembled.callers.map(c => ({ name: c.name, snippet: c.snippet, confidence: c.confidence })),
|
|
170
|
+
callees: assembled.callees.map(c => ({ name: c.name, snippet: c.snippet, confidence: c.confidence })),
|
|
171
|
+
weakestConfidence: assembled.weakestConfidence,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const inferRes = await fetch(`${API_BASE}${endpoint}`, {
|
|
156
176
|
method: "POST",
|
|
157
177
|
headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
|
|
158
|
-
body: JSON.stringify(
|
|
178
|
+
body: JSON.stringify(body),
|
|
159
179
|
});
|
|
160
180
|
if (!inferRes.ok)
|
|
161
181
|
return false;
|
|
@@ -217,8 +237,27 @@ async function scanRepoCommand(options = {}) {
|
|
|
217
237
|
}
|
|
218
238
|
// Fetch all existing memory symbols in one bulk call
|
|
219
239
|
const { known: existingSymbols, idMap: existingIds } = await fetchExistingSymbols(token, repoName, apiUrl);
|
|
220
|
-
//
|
|
221
|
-
|
|
240
|
+
// Build (or reuse) the structural graph — used to order symbols by
|
|
241
|
+
// centrality (most structurally critical first) instead of randomly,
|
|
242
|
+
// and to assemble context for richer, structure-grounded inference.
|
|
243
|
+
let graph = null;
|
|
244
|
+
try {
|
|
245
|
+
graph = await (0, graph_builder_1.buildGraph)(repoRoot);
|
|
246
|
+
(0, graph_builder_1.saveGraph)(repoRoot, graph);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
graph = null;
|
|
250
|
+
}
|
|
251
|
+
const ordered = graph
|
|
252
|
+
? [...allSymbols].sort((a, b) => {
|
|
253
|
+
const centralityA = a.fingerprint && graph.nodes[a.fingerprint] ? graph.nodes[a.fingerprint].centrality : -1;
|
|
254
|
+
const centralityB = b.fingerprint && graph.nodes[b.fingerprint] ? graph.nodes[b.fingerprint].centrality : -1;
|
|
255
|
+
if (centralityA !== centralityB)
|
|
256
|
+
return centralityB - centralityA;
|
|
257
|
+
return Math.random() - 0.5;
|
|
258
|
+
})
|
|
259
|
+
: [...allSymbols].sort(() => Math.random() - 0.5);
|
|
260
|
+
const shuffled = ordered;
|
|
222
261
|
// Filter locally — no per-symbol API calls needed
|
|
223
262
|
const toCapture = [];
|
|
224
263
|
const seen = new Set();
|
|
@@ -253,7 +292,7 @@ async function scanRepoCommand(options = {}) {
|
|
|
253
292
|
let captured = 0;
|
|
254
293
|
for (let i = 0; i < toCapture.length; i += BATCH_SIZE) {
|
|
255
294
|
const batch = toCapture.slice(i, i + BATCH_SIZE);
|
|
256
|
-
const results = await Promise.all(batch.map(sym => inferAndCapture(token, sym, repoName)));
|
|
295
|
+
const results = await Promise.all(batch.map(sym => inferAndCapture(token, sym, repoName, graph, repoRoot)));
|
|
257
296
|
const batchCaptured = results.filter(Boolean).length;
|
|
258
297
|
captured += batchCaptured;
|
|
259
298
|
if (!options.silent) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* kodingo sync-graph
|
|
4
|
+
*
|
|
5
|
+
* Builds the structural relationship graph for the current repo, diffs it
|
|
6
|
+
* against the previously saved local graph, and syncs only the diff to the
|
|
7
|
+
* server. Safe to run repeatedly — nodes are upserted, edges are additive
|
|
8
|
+
* with soft-delete on removal.
|
|
9
|
+
*
|
|
10
|
+
* Designed to be fast on repeat runs since only changed files need re-parsing
|
|
11
|
+
* in a future incremental version; for now it does a full local rebuild but
|
|
12
|
+
* only transmits the diff over the network.
|
|
13
|
+
*/
|
|
14
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
15
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
16
|
+
};
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.syncGraphCommand = syncGraphCommand;
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const persistence_config_1 = require("../utils/persistence-config");
|
|
21
|
+
const repo_scope_1 = require("../utils/repo-scope");
|
|
22
|
+
const graph_builder_1 = require("../utils/graph-builder");
|
|
23
|
+
async function syncGraphCommand(options = {}) {
|
|
24
|
+
const repoRoot = (0, repo_scope_1.resolveRepoScope)(options.repo ? { repo: options.repo } : {}) ?? process.cwd();
|
|
25
|
+
const config = (0, persistence_config_1.readConfig)();
|
|
26
|
+
const wsConfig = (0, persistence_config_1.readWorkspaceConfig)(repoRoot);
|
|
27
|
+
const token = wsConfig?.token ?? "";
|
|
28
|
+
const apiUrl = wsConfig?.apiUrl ?? config.apiUrl ?? "https://kodingo-api.onrender.com";
|
|
29
|
+
if (!token) {
|
|
30
|
+
if (!options.silent)
|
|
31
|
+
console.error("✖ This repo is not connected to Kodingo. Run `kodingo init` first.");
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const repoName = path_1.default.basename(repoRoot);
|
|
35
|
+
if (!options.silent)
|
|
36
|
+
console.log(`\n🧠 Kortex — building structural graph for ${repoName}...\n`);
|
|
37
|
+
const previous = (0, graph_builder_1.loadGraph)(repoRoot);
|
|
38
|
+
const current = await (0, graph_builder_1.buildGraph)(repoRoot);
|
|
39
|
+
const diff = (0, graph_builder_1.diffGraphs)(previous, current);
|
|
40
|
+
if (diff.added.length === 0 && diff.removed.length === 0 && diff.edgesAdded.length === 0 && diff.edgesRemoved.length === 0) {
|
|
41
|
+
if (!options.silent)
|
|
42
|
+
console.log(" ✔ No structural changes detected. Graph is up to date.");
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (!options.silent) {
|
|
46
|
+
console.log(` ${diff.added.length} symbols, ${diff.edgesAdded.length} new relationships detected`);
|
|
47
|
+
}
|
|
48
|
+
// Chunk large syncs to respect the server's per-request caps (2000 nodes / 10000 edges)
|
|
49
|
+
const NODE_CHUNK = 1500;
|
|
50
|
+
const EDGE_CHUNK = 8000;
|
|
51
|
+
try {
|
|
52
|
+
for (let i = 0; i < diff.added.length || i === 0; i += NODE_CHUNK) {
|
|
53
|
+
const nodeChunk = diff.added.slice(i, i + NODE_CHUNK);
|
|
54
|
+
const edgeChunk = i === 0 ? diff.edgesAdded.slice(0, EDGE_CHUNK) : [];
|
|
55
|
+
const res = await fetch(`${apiUrl}/graph/sync`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
repo: repoName,
|
|
60
|
+
added: nodeChunk.map(n => ({
|
|
61
|
+
fingerprint: n.fingerprint,
|
|
62
|
+
name: n.name,
|
|
63
|
+
file: path_1.default.relative(repoRoot, n.file),
|
|
64
|
+
type: n.type,
|
|
65
|
+
language: n.language,
|
|
66
|
+
startLine: n.startLine,
|
|
67
|
+
endLine: n.endLine,
|
|
68
|
+
resolutionTier: n.resolutionTier,
|
|
69
|
+
fanIn: n.fanIn,
|
|
70
|
+
fanOut: n.fanOut,
|
|
71
|
+
centrality: n.centrality,
|
|
72
|
+
})),
|
|
73
|
+
removed: i === 0 ? diff.removed : [],
|
|
74
|
+
edgesAdded: edgeChunk.map(e => ({
|
|
75
|
+
fromFingerprint: e.from,
|
|
76
|
+
toFingerprint: e.to,
|
|
77
|
+
edgeType: e.type,
|
|
78
|
+
confidence: e.confidence,
|
|
79
|
+
file: path_1.default.relative(repoRoot, e.file),
|
|
80
|
+
line: e.line,
|
|
81
|
+
})),
|
|
82
|
+
edgesRemoved: i === 0 ? diff.edgesRemoved.map(e => ({ fromFingerprint: e.from, toFingerprint: e.to })) : [],
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
if (!options.silent)
|
|
87
|
+
console.error(` ✖ Sync failed: ${res.status} ${await res.text()}`);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (diff.added.length === 0)
|
|
91
|
+
break; // ensure single-iteration exit when nothing to chunk
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (!options.silent)
|
|
96
|
+
console.error(` ✖ Sync failed: ${err.message}`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
(0, graph_builder_1.saveGraph)(repoRoot, current);
|
|
100
|
+
if (!options.silent) {
|
|
101
|
+
console.log(`\n✔ Graph synced — ${Object.keys(current.nodes).length} symbols, ${current.edges.length} relationships tracked for ${repoName}`);
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
package/dist/utils/ast-parser.js
CHANGED
|
@@ -63,9 +63,73 @@ function computeFingerprint(body) {
|
|
|
63
63
|
.trim();
|
|
64
64
|
return (0, crypto_1.createHash)("sha256").update(normalised).digest("hex").slice(0, 16);
|
|
65
65
|
}
|
|
66
|
+
// ── Lightweight call extraction for non-TS languages (heuristic, Tier 2) ─────
|
|
67
|
+
/** Regex-based call-site extraction: matches `identifier(` patterns. Not scope-resolved. */
|
|
68
|
+
function extractCallNamesRegex(body) {
|
|
69
|
+
const calls = new Set();
|
|
70
|
+
const pattern = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
|
|
71
|
+
let match;
|
|
72
|
+
while ((match = pattern.exec(body)) !== null) {
|
|
73
|
+
calls.add(match[1]);
|
|
74
|
+
}
|
|
75
|
+
return Array.from(calls);
|
|
76
|
+
}
|
|
66
77
|
// ── TypeScript / JavaScript parser ───────────────────────────────────────────
|
|
67
78
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
68
79
|
const { parse: tsParse } = require("@typescript-eslint/typescript-estree");
|
|
80
|
+
// ── Import extraction ────────────────────────────────────────────────────────
|
|
81
|
+
function extractImports(ast) {
|
|
82
|
+
const imports = [];
|
|
83
|
+
const body = ast.body ?? [];
|
|
84
|
+
for (const node of body) {
|
|
85
|
+
if (node.type !== "ImportDeclaration" || !node.source?.value)
|
|
86
|
+
continue;
|
|
87
|
+
const source = node.source.value;
|
|
88
|
+
for (const spec of node.specifiers ?? []) {
|
|
89
|
+
if (spec.type === "ImportDefaultSpecifier" && spec.local?.name) {
|
|
90
|
+
imports.push({ imported: spec.local.name, source, isDefault: true });
|
|
91
|
+
}
|
|
92
|
+
else if (spec.type === "ImportSpecifier" && spec.local?.name) {
|
|
93
|
+
imports.push({ imported: spec.local.name, source, isDefault: false });
|
|
94
|
+
}
|
|
95
|
+
else if (spec.type === "ImportNamespaceSpecifier" && spec.local?.name) {
|
|
96
|
+
imports.push({ imported: spec.local.name, source, isDefault: false });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return imports;
|
|
101
|
+
}
|
|
102
|
+
// ── Call-site extraction ──────────────────────────────────────────────────────
|
|
103
|
+
/** Walks a symbol's body subtree and collects callee identifier names. Heuristic — not scope-resolved. */
|
|
104
|
+
function extractCallNames(node, acc = new Set()) {
|
|
105
|
+
function walk(n) {
|
|
106
|
+
if (!n || typeof n !== "object")
|
|
107
|
+
return;
|
|
108
|
+
if (n.type === "CallExpression") {
|
|
109
|
+
const callee = n.callee;
|
|
110
|
+
if (callee?.type === "Identifier" && callee.name) {
|
|
111
|
+
acc.add(callee.name);
|
|
112
|
+
}
|
|
113
|
+
else if (callee?.type === "MemberExpression" && callee.property?.name) {
|
|
114
|
+
acc.add(callee.property.name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const key of Object.keys(n)) {
|
|
118
|
+
if (key === "parent")
|
|
119
|
+
continue;
|
|
120
|
+
const child = n[key];
|
|
121
|
+
if (Array.isArray(child)) {
|
|
122
|
+
for (const c of child)
|
|
123
|
+
walk(c);
|
|
124
|
+
}
|
|
125
|
+
else if (child && typeof child === "object" && child.type) {
|
|
126
|
+
walk(child);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
walk(node);
|
|
131
|
+
return Array.from(acc);
|
|
132
|
+
}
|
|
69
133
|
async function parseTypeScript(filePath, source) {
|
|
70
134
|
try {
|
|
71
135
|
const parse = tsParse;
|
|
@@ -79,6 +143,7 @@ async function parseTypeScript(filePath, source) {
|
|
|
79
143
|
});
|
|
80
144
|
const symbols = [];
|
|
81
145
|
const lines = source.split("\n");
|
|
146
|
+
const fileImports = extractImports(ast);
|
|
82
147
|
function getBody(start, end) {
|
|
83
148
|
return source.slice(start, end);
|
|
84
149
|
}
|
|
@@ -95,6 +160,8 @@ async function parseTypeScript(filePath, source) {
|
|
|
95
160
|
endLine: node.loc.end.line,
|
|
96
161
|
body,
|
|
97
162
|
fingerprint: computeFingerprint(body),
|
|
163
|
+
calls: extractCallNames(node.body),
|
|
164
|
+
imports: fileImports,
|
|
98
165
|
});
|
|
99
166
|
}
|
|
100
167
|
if ((node.type === "VariableDeclarator") &&
|
|
@@ -110,6 +177,8 @@ async function parseTypeScript(filePath, source) {
|
|
|
110
177
|
endLine: node.loc.end.line,
|
|
111
178
|
body,
|
|
112
179
|
fingerprint: computeFingerprint(body),
|
|
180
|
+
calls: extractCallNames(node.init.body),
|
|
181
|
+
imports: fileImports,
|
|
113
182
|
});
|
|
114
183
|
}
|
|
115
184
|
if (node.type === "ClassDeclaration" ||
|
|
@@ -125,6 +194,8 @@ async function parseTypeScript(filePath, source) {
|
|
|
125
194
|
endLine: node.loc.end.line,
|
|
126
195
|
body,
|
|
127
196
|
fingerprint: computeFingerprint(body),
|
|
197
|
+
calls: extractCallNames(node.body),
|
|
198
|
+
imports: fileImports,
|
|
128
199
|
});
|
|
129
200
|
// Visit methods inside the class
|
|
130
201
|
if (node.body?.body) {
|
|
@@ -141,6 +212,8 @@ async function parseTypeScript(filePath, source) {
|
|
|
141
212
|
endLine: member.loc.end.line,
|
|
142
213
|
body: mBody,
|
|
143
214
|
fingerprint: computeFingerprint(mBody),
|
|
215
|
+
calls: extractCallNames(member.value?.body),
|
|
216
|
+
imports: fileImports,
|
|
144
217
|
});
|
|
145
218
|
}
|
|
146
219
|
}
|
|
@@ -213,6 +286,8 @@ function parseStructural(filePath, source, patterns) {
|
|
|
213
286
|
endLine,
|
|
214
287
|
body,
|
|
215
288
|
fingerprint: computeFingerprint(body),
|
|
289
|
+
calls: extractCallNamesRegex(body),
|
|
290
|
+
imports: [],
|
|
216
291
|
});
|
|
217
292
|
break;
|
|
218
293
|
}
|
|
@@ -256,6 +331,8 @@ function parsePython(filePath, source) {
|
|
|
256
331
|
endLine,
|
|
257
332
|
body,
|
|
258
333
|
fingerprint: computeFingerprint(body),
|
|
334
|
+
calls: extractCallNamesRegex(body),
|
|
335
|
+
imports: [],
|
|
259
336
|
});
|
|
260
337
|
}
|
|
261
338
|
return symbols;
|
|
@@ -283,6 +360,7 @@ const KOTLIN_PATTERNS = [
|
|
|
283
360
|
/^(?:fun|override\s+fun|suspend\s+fun|private\s+fun|public\s+fun)\s+(\w+)/,
|
|
284
361
|
/^(?:class|data class|object|interface)\s+(\w+)/,
|
|
285
362
|
];
|
|
363
|
+
const TREE_SITTER_EXTENSIONS = new Set([".py", ".go", ".rs", ".java", ".kt", ".rb", ".php", ".c", ".cpp"]);
|
|
286
364
|
async function parseFile(filePath, source) {
|
|
287
365
|
const ext = path.extname(filePath).toLowerCase();
|
|
288
366
|
switch (ext) {
|
|
@@ -293,6 +371,21 @@ async function parseFile(filePath, source) {
|
|
|
293
371
|
case ".mjs":
|
|
294
372
|
case ".cjs":
|
|
295
373
|
return parseTypeScript(filePath, source);
|
|
374
|
+
default:
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
if (TREE_SITTER_EXTENSIONS.has(ext)) {
|
|
378
|
+
try {
|
|
379
|
+
const { parseWithTreeSitter } = require("./tree-sitter-parser");
|
|
380
|
+
const result = await parseWithTreeSitter(filePath, source);
|
|
381
|
+
if (result.length > 0)
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// fall through to legacy heuristic parsers below
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
switch (ext) {
|
|
296
389
|
case ".py":
|
|
297
390
|
return parsePython(filePath, source);
|
|
298
391
|
case ".go":
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* context-assembler.ts
|
|
4
|
+
*
|
|
5
|
+
* Given a target symbol's fingerprint and the repo's structural graph,
|
|
6
|
+
* assembles a bounded 1-hop neighborhood: what it calls, what calls it,
|
|
7
|
+
* and its imports. This is the context sent to /infer/contextual instead
|
|
8
|
+
* of reasoning about a function in total isolation.
|
|
9
|
+
*
|
|
10
|
+
* Token budget is enforced by truncating the lowest-confidence neighbors
|
|
11
|
+
* first — never sends unbounded context.
|
|
12
|
+
*/
|
|
13
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
14
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.assembleContext = assembleContext;
|
|
18
|
+
const fs_1 = __importDefault(require("fs"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const DEFAULT_TOKEN_BUDGET = 3000;
|
|
21
|
+
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
|
22
|
+
const MAX_SNIPPET_CHARS = 500; // per-neighbor cap before budget truncation
|
|
23
|
+
function readSnippet(repoRoot, file, startLine, endLine) {
|
|
24
|
+
try {
|
|
25
|
+
const absPath = path_1.default.isAbsolute(file) ? file : path_1.default.join(repoRoot, file);
|
|
26
|
+
const source = fs_1.default.readFileSync(absPath, "utf-8");
|
|
27
|
+
const lines = source.split("\n");
|
|
28
|
+
const snippet = lines.slice(Math.max(0, startLine - 1), endLine).join("\n");
|
|
29
|
+
return snippet.length > MAX_SNIPPET_CHARS ? snippet.slice(0, MAX_SNIPPET_CHARS) + "\n// ...truncated" : snippet;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function estimateTokens(text) {
|
|
36
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE);
|
|
37
|
+
}
|
|
38
|
+
function assembleContext(repoRoot, graph, targetFingerprint, tokenBudget = DEFAULT_TOKEN_BUDGET) {
|
|
39
|
+
const target = graph.nodes[targetFingerprint];
|
|
40
|
+
if (!target)
|
|
41
|
+
return null;
|
|
42
|
+
const targetBody = readSnippet(repoRoot, target.file, target.startLine, target.endLine);
|
|
43
|
+
if (targetBody === null)
|
|
44
|
+
return null;
|
|
45
|
+
let usedTokens = estimateTokens(targetBody);
|
|
46
|
+
// Callees: edges where this symbol is the source
|
|
47
|
+
const calleeEdges = graph.edges
|
|
48
|
+
.filter(e => e.from === targetFingerprint)
|
|
49
|
+
.sort((a, b) => b.confidence - a.confidence);
|
|
50
|
+
// Callers: edges where this symbol is the target
|
|
51
|
+
const callerEdges = graph.edges
|
|
52
|
+
.filter(e => e.to === targetFingerprint)
|
|
53
|
+
.sort((a, b) => b.confidence - a.confidence);
|
|
54
|
+
function buildNeighbors(edges, direction) {
|
|
55
|
+
const neighbors = [];
|
|
56
|
+
for (const edge of edges) {
|
|
57
|
+
const neighborFingerprint = direction === "from" ? edge.to : edge.from;
|
|
58
|
+
const neighborNode = graph.nodes[neighborFingerprint];
|
|
59
|
+
if (!neighborNode)
|
|
60
|
+
continue;
|
|
61
|
+
const snippet = readSnippet(repoRoot, neighborNode.file, neighborNode.startLine, neighborNode.endLine);
|
|
62
|
+
if (snippet === null)
|
|
63
|
+
continue;
|
|
64
|
+
const projected = usedTokens + estimateTokens(snippet);
|
|
65
|
+
if (projected > tokenBudget)
|
|
66
|
+
break; // budget exhausted — remaining lower-confidence neighbors are dropped
|
|
67
|
+
usedTokens = projected;
|
|
68
|
+
neighbors.push({
|
|
69
|
+
name: neighborNode.name,
|
|
70
|
+
file: neighborNode.file,
|
|
71
|
+
snippet,
|
|
72
|
+
confidence: edge.confidence,
|
|
73
|
+
edgeType: edge.type,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return neighbors;
|
|
77
|
+
}
|
|
78
|
+
const callees = buildNeighbors(calleeEdges, "from");
|
|
79
|
+
const callers = buildNeighbors(callerEdges, "to");
|
|
80
|
+
const allConfidences = [...callees, ...callers].map(n => n.confidence);
|
|
81
|
+
const weakestConfidence = allConfidences.length > 0 ? Math.min(...allConfidences) : 1.0;
|
|
82
|
+
return {
|
|
83
|
+
targetName: target.name,
|
|
84
|
+
targetFile: target.file,
|
|
85
|
+
targetBody,
|
|
86
|
+
callers,
|
|
87
|
+
callees,
|
|
88
|
+
imports: [], // populated by caller if needed — graph doesn't currently store per-node imports
|
|
89
|
+
weakestConfidence,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* graph-builder.ts
|
|
4
|
+
*
|
|
5
|
+
* Builds a structural relationship graph (KortexGraph) for a repository.
|
|
6
|
+
*
|
|
7
|
+
* Nodes are keyed by fingerprint (stable across renames/moves — same mechanism
|
|
8
|
+
* used for memory symbols). Edges represent "calls" relationships between symbols.
|
|
9
|
+
*
|
|
10
|
+
* Resolution has two tiers:
|
|
11
|
+
* - Tier 1 (TypeScript/JavaScript): uses the TypeScript compiler's type checker
|
|
12
|
+
* to resolve call expressions to their actual declaration, giving near-100%
|
|
13
|
+
* accurate edges. Confidence = 1.0.
|
|
14
|
+
* - Tier 2 (all other languages): heuristic name-based matching against the
|
|
15
|
+
* repo-wide symbol table. Confidence is scored by name uniqueness.
|
|
16
|
+
*
|
|
17
|
+
* This module performs zero LLM calls. Pure static analysis.
|
|
18
|
+
*/
|
|
19
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
22
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
23
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(o, k2, desc);
|
|
26
|
+
}) : (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
o[k2] = m[k];
|
|
29
|
+
}));
|
|
30
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
31
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
32
|
+
}) : function(o, v) {
|
|
33
|
+
o["default"] = v;
|
|
34
|
+
});
|
|
35
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
36
|
+
var ownKeys = function(o) {
|
|
37
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
38
|
+
var ar = [];
|
|
39
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
40
|
+
return ar;
|
|
41
|
+
};
|
|
42
|
+
return ownKeys(o);
|
|
43
|
+
};
|
|
44
|
+
return function (mod) {
|
|
45
|
+
if (mod && mod.__esModule) return mod;
|
|
46
|
+
var result = {};
|
|
47
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
48
|
+
__setModuleDefault(result, mod);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
53
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
54
|
+
};
|
|
55
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
exports.buildGraph = buildGraph;
|
|
57
|
+
exports.diffGraphs = diffGraphs;
|
|
58
|
+
exports.saveGraph = saveGraph;
|
|
59
|
+
exports.loadGraph = loadGraph;
|
|
60
|
+
const fs_1 = __importDefault(require("fs"));
|
|
61
|
+
const path_1 = __importDefault(require("path"));
|
|
62
|
+
const ts = __importStar(require("typescript"));
|
|
63
|
+
const ast_parser_1 = require("./ast-parser");
|
|
64
|
+
const SUPPORTED_EXTENSIONS = [
|
|
65
|
+
".ts", ".tsx", ".js", ".jsx",
|
|
66
|
+
".py", ".go", ".rs", ".php",
|
|
67
|
+
".java", ".kt", ".rb", ".c", ".cpp",
|
|
68
|
+
];
|
|
69
|
+
const TS_JS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
70
|
+
const IGNORED_DIRS = [
|
|
71
|
+
"node_modules", ".git", ".kortex", "dist", "build",
|
|
72
|
+
"out", ".next", "coverage", "__pycache__", "vendor",
|
|
73
|
+
];
|
|
74
|
+
function walkFiles(dir) {
|
|
75
|
+
const results = [];
|
|
76
|
+
try {
|
|
77
|
+
const entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (IGNORED_DIRS.includes(entry.name))
|
|
80
|
+
continue;
|
|
81
|
+
const fullPath = path_1.default.join(dir, entry.name);
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
results.push(...walkFiles(fullPath));
|
|
84
|
+
}
|
|
85
|
+
else if (entry.isFile() && SUPPORTED_EXTENSIONS.includes(path_1.default.extname(entry.name))) {
|
|
86
|
+
results.push(fullPath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch { }
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
function languageForFile(file) {
|
|
94
|
+
const ext = path_1.default.extname(file).toLowerCase();
|
|
95
|
+
const map = {
|
|
96
|
+
".ts": "typescript", ".tsx": "typescript",
|
|
97
|
+
".js": "javascript", ".jsx": "javascript",
|
|
98
|
+
".py": "python", ".go": "go", ".rs": "rust",
|
|
99
|
+
".php": "php", ".java": "java", ".kt": "kotlin",
|
|
100
|
+
".rb": "ruby", ".c": "c", ".cpp": "cpp",
|
|
101
|
+
};
|
|
102
|
+
return map[ext] ?? "unknown";
|
|
103
|
+
}
|
|
104
|
+
async function collectAllSymbols(repoRoot) {
|
|
105
|
+
const files = walkFiles(repoRoot);
|
|
106
|
+
const all = [];
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
try {
|
|
109
|
+
const source = fs_1.default.readFileSync(file, "utf-8");
|
|
110
|
+
const nodes = await (0, ast_parser_1.parseFile)(file, source);
|
|
111
|
+
for (const n of nodes) {
|
|
112
|
+
all.push({ ...n, language: languageForFile(file) });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch { }
|
|
116
|
+
}
|
|
117
|
+
return all;
|
|
118
|
+
}
|
|
119
|
+
function buildNameIndex(symbols) {
|
|
120
|
+
const index = new Map();
|
|
121
|
+
for (const sym of symbols) {
|
|
122
|
+
const shortName = sym.name.includes(".") ? sym.name.split(".").pop() : sym.name;
|
|
123
|
+
const existing = index.get(shortName) ?? [];
|
|
124
|
+
existing.push(sym.fingerprint);
|
|
125
|
+
index.set(shortName, existing);
|
|
126
|
+
}
|
|
127
|
+
return index;
|
|
128
|
+
}
|
|
129
|
+
function resolveTier2Edges(symbols, nameIndex) {
|
|
130
|
+
const edges = [];
|
|
131
|
+
for (const sym of symbols) {
|
|
132
|
+
if (TS_JS_EXTENSIONS.has(path_1.default.extname(sym.file)))
|
|
133
|
+
continue;
|
|
134
|
+
for (const calledName of sym.calls) {
|
|
135
|
+
const candidates = nameIndex.get(calledName);
|
|
136
|
+
if (!candidates || candidates.length === 0)
|
|
137
|
+
continue;
|
|
138
|
+
const uniqueCandidates = Array.from(new Set(candidates));
|
|
139
|
+
const confidence = uniqueCandidates.length === 1 ? 0.8 : Math.max(0.2, 0.6 / uniqueCandidates.length);
|
|
140
|
+
for (const targetFingerprint of uniqueCandidates) {
|
|
141
|
+
if (targetFingerprint === sym.fingerprint)
|
|
142
|
+
continue;
|
|
143
|
+
edges.push({
|
|
144
|
+
from: sym.fingerprint,
|
|
145
|
+
to: targetFingerprint,
|
|
146
|
+
type: "calls",
|
|
147
|
+
confidence,
|
|
148
|
+
file: sym.file,
|
|
149
|
+
line: sym.startLine,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return edges;
|
|
155
|
+
}
|
|
156
|
+
function resolveTier1Edges(repoRoot, tsFiles, symbols) {
|
|
157
|
+
if (tsFiles.length === 0)
|
|
158
|
+
return [];
|
|
159
|
+
const edges = [];
|
|
160
|
+
const symbolsByFile = new Map();
|
|
161
|
+
for (const sym of symbols) {
|
|
162
|
+
if (!TS_JS_EXTENSIONS.has(path_1.default.extname(sym.file)))
|
|
163
|
+
continue;
|
|
164
|
+
const list = symbolsByFile.get(sym.file) ?? [];
|
|
165
|
+
list.push(sym);
|
|
166
|
+
symbolsByFile.set(sym.file, list);
|
|
167
|
+
}
|
|
168
|
+
function findEnclosingSymbol(file, line) {
|
|
169
|
+
const candidates = symbolsByFile.get(file);
|
|
170
|
+
if (!candidates)
|
|
171
|
+
return null;
|
|
172
|
+
let best = null;
|
|
173
|
+
for (const c of candidates) {
|
|
174
|
+
if (line >= c.startLine && line <= c.endLine) {
|
|
175
|
+
if (!best || (c.endLine - c.startLine) < (best.endLine - best.startLine)) {
|
|
176
|
+
best = c;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return best;
|
|
181
|
+
}
|
|
182
|
+
let tsconfigPath;
|
|
183
|
+
try {
|
|
184
|
+
tsconfigPath = ts.findConfigFile(repoRoot, ts.sys.fileExists, "tsconfig.json") ?? undefined;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
tsconfigPath = undefined;
|
|
188
|
+
}
|
|
189
|
+
let compilerOptions = {
|
|
190
|
+
allowJs: true,
|
|
191
|
+
jsx: ts.JsxEmit.Preserve,
|
|
192
|
+
module: ts.ModuleKind.CommonJS,
|
|
193
|
+
target: ts.ScriptTarget.ES2020,
|
|
194
|
+
noEmit: true,
|
|
195
|
+
};
|
|
196
|
+
if (tsconfigPath) {
|
|
197
|
+
try {
|
|
198
|
+
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
199
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path_1.default.dirname(tsconfigPath));
|
|
200
|
+
compilerOptions = { ...compilerOptions, ...parsed.options, noEmit: true };
|
|
201
|
+
}
|
|
202
|
+
catch { }
|
|
203
|
+
}
|
|
204
|
+
let program;
|
|
205
|
+
try {
|
|
206
|
+
program = ts.createProgram(tsFiles, compilerOptions);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
const checker = program.getTypeChecker();
|
|
212
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
213
|
+
if (sourceFile.isDeclarationFile)
|
|
214
|
+
continue;
|
|
215
|
+
if (!tsFiles.includes(sourceFile.fileName))
|
|
216
|
+
continue;
|
|
217
|
+
function recordEdge(identifierNode, edgeType) {
|
|
218
|
+
try {
|
|
219
|
+
let symbol = checker.getSymbolAtLocation(identifierNode);
|
|
220
|
+
// Resolve import aliases to the real declaration — otherwise imported
|
|
221
|
+
// symbols point at the import statement itself, not the function body.
|
|
222
|
+
if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) {
|
|
223
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
224
|
+
}
|
|
225
|
+
const declarations = symbol?.getDeclarations();
|
|
226
|
+
if (!declarations || declarations.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
const decl = declarations[0];
|
|
229
|
+
const declFile = decl.getSourceFile().fileName;
|
|
230
|
+
const declLine = decl.getSourceFile().getLineAndCharacterOfPosition(decl.getStart()).line + 1;
|
|
231
|
+
const targetSymbol = findEnclosingSymbol(declFile, declLine);
|
|
232
|
+
const refLine = sourceFile.getLineAndCharacterOfPosition(identifierNode.getStart()).line + 1;
|
|
233
|
+
const callerSymbol = findEnclosingSymbol(sourceFile.fileName, refLine);
|
|
234
|
+
if (targetSymbol && callerSymbol && targetSymbol.fingerprint !== callerSymbol.fingerprint) {
|
|
235
|
+
edges.push({
|
|
236
|
+
from: callerSymbol.fingerprint,
|
|
237
|
+
to: targetSymbol.fingerprint,
|
|
238
|
+
type: edgeType,
|
|
239
|
+
confidence: edgeType === "calls" ? 1.0 : 0.9,
|
|
240
|
+
file: sourceFile.fileName,
|
|
241
|
+
line: refLine,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch { }
|
|
246
|
+
}
|
|
247
|
+
function visit(node) {
|
|
248
|
+
if (ts.isCallExpression(node)) {
|
|
249
|
+
// The call target itself — e.g. authMiddleware() — direct invocation
|
|
250
|
+
recordEdge(node.expression, "calls");
|
|
251
|
+
// Arguments passed to this call that resolve to known function symbols —
|
|
252
|
+
// e.g. app.addHook("preHandler", authMiddleware) — registration/callback pattern.
|
|
253
|
+
// This is where middleware, event handlers, and hooks get missed if we only
|
|
254
|
+
// look at direct calls.
|
|
255
|
+
for (const arg of node.arguments) {
|
|
256
|
+
if (ts.isIdentifier(arg)) {
|
|
257
|
+
recordEdge(arg, "references");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Also catch bare identifier references outside call arguments —
|
|
262
|
+
// e.g. `export const handler = someFunction;` or object literal values
|
|
263
|
+
// like `{ preHandler: authMiddleware }`.
|
|
264
|
+
if (ts.isIdentifier(node) &&
|
|
265
|
+
!ts.isCallExpression(node.parent) &&
|
|
266
|
+
node.parent &&
|
|
267
|
+
(ts.isPropertyAssignment(node.parent) || ts.isVariableDeclaration(node.parent) || ts.isBinaryExpression(node.parent))) {
|
|
268
|
+
recordEdge(node, "references");
|
|
269
|
+
}
|
|
270
|
+
ts.forEachChild(node, visit);
|
|
271
|
+
}
|
|
272
|
+
visit(sourceFile);
|
|
273
|
+
}
|
|
274
|
+
return edges;
|
|
275
|
+
}
|
|
276
|
+
function computeCentrality(nodes, edges) {
|
|
277
|
+
for (const edge of edges) {
|
|
278
|
+
if (nodes[edge.to])
|
|
279
|
+
nodes[edge.to].fanIn += edge.confidence;
|
|
280
|
+
if (nodes[edge.from])
|
|
281
|
+
nodes[edge.from].fanOut += edge.confidence;
|
|
282
|
+
}
|
|
283
|
+
for (const node of Object.values(nodes)) {
|
|
284
|
+
node.centrality = node.fanIn * 2 + node.fanOut;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async function buildGraph(repoRoot) {
|
|
288
|
+
const symbols = await collectAllSymbols(repoRoot);
|
|
289
|
+
const nodes = {};
|
|
290
|
+
for (const sym of symbols) {
|
|
291
|
+
const isTsJs = TS_JS_EXTENSIONS.has(path_1.default.extname(sym.file));
|
|
292
|
+
nodes[sym.fingerprint] = {
|
|
293
|
+
fingerprint: sym.fingerprint,
|
|
294
|
+
name: sym.name,
|
|
295
|
+
file: sym.file,
|
|
296
|
+
type: sym.type,
|
|
297
|
+
startLine: sym.startLine,
|
|
298
|
+
endLine: sym.endLine,
|
|
299
|
+
language: sym.language,
|
|
300
|
+
resolutionTier: isTsJs ? 1 : 2,
|
|
301
|
+
fanIn: 0,
|
|
302
|
+
fanOut: 0,
|
|
303
|
+
centrality: 0,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const tsFiles = symbols
|
|
307
|
+
.map(s => s.file)
|
|
308
|
+
.filter(f => TS_JS_EXTENSIONS.has(path_1.default.extname(f)))
|
|
309
|
+
.filter((f, i, arr) => arr.indexOf(f) === i);
|
|
310
|
+
const tier1Edges = resolveTier1Edges(repoRoot, tsFiles, symbols);
|
|
311
|
+
const nameIndex = buildNameIndex(symbols);
|
|
312
|
+
const tier2Edges = resolveTier2Edges(symbols, nameIndex);
|
|
313
|
+
const edges = [...tier1Edges, ...tier2Edges];
|
|
314
|
+
computeCentrality(nodes, edges);
|
|
315
|
+
return {
|
|
316
|
+
builtAt: new Date().toISOString(),
|
|
317
|
+
repoRoot,
|
|
318
|
+
nodes,
|
|
319
|
+
edges,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function edgeKey(e) {
|
|
323
|
+
return `${e.from}::${e.to}::${e.type}`;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Diffs the freshly built graph against the previously saved one.
|
|
327
|
+
* Nodes are compared by fingerprint. Edges are compared by (from, to, type) —
|
|
328
|
+
* this prevents re-inserting the same edge on every sync while still allowing
|
|
329
|
+
* confidence values to be refreshed via the "added" path (upsert semantics).
|
|
330
|
+
*/
|
|
331
|
+
function diffGraphs(previous, current) {
|
|
332
|
+
const prevNodeKeys = new Set(previous ? Object.keys(previous.nodes) : []);
|
|
333
|
+
const currNodeKeys = new Set(Object.keys(current.nodes));
|
|
334
|
+
const added = [];
|
|
335
|
+
for (const key of currNodeKeys) {
|
|
336
|
+
// Always include changed nodes — cheap to upsert, and centrality/fanIn/fanOut
|
|
337
|
+
// change frequently even when the symbol itself hasn't moved.
|
|
338
|
+
added.push(current.nodes[key]);
|
|
339
|
+
}
|
|
340
|
+
const removed = [];
|
|
341
|
+
for (const key of prevNodeKeys) {
|
|
342
|
+
if (!currNodeKeys.has(key))
|
|
343
|
+
removed.push(key);
|
|
344
|
+
}
|
|
345
|
+
const prevEdgeMap = new Map();
|
|
346
|
+
for (const e of previous?.edges ?? []) {
|
|
347
|
+
prevEdgeMap.set(edgeKey(e), e);
|
|
348
|
+
}
|
|
349
|
+
const currEdgeMap = new Map();
|
|
350
|
+
for (const e of current.edges) {
|
|
351
|
+
currEdgeMap.set(edgeKey(e), e);
|
|
352
|
+
}
|
|
353
|
+
const edgesAdded = [];
|
|
354
|
+
for (const [key, edge] of currEdgeMap) {
|
|
355
|
+
if (!prevEdgeMap.has(key))
|
|
356
|
+
edgesAdded.push(edge);
|
|
357
|
+
}
|
|
358
|
+
const edgesRemoved = [];
|
|
359
|
+
for (const [key, edge] of prevEdgeMap) {
|
|
360
|
+
if (!currEdgeMap.has(key)) {
|
|
361
|
+
edgesRemoved.push({ from: edge.from, to: edge.to, type: edge.type });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return { added, removed, edgesAdded, edgesRemoved };
|
|
365
|
+
}
|
|
366
|
+
function saveGraph(repoRoot, graph) {
|
|
367
|
+
const kortexDir = path_1.default.join(repoRoot, ".kortex");
|
|
368
|
+
if (!fs_1.default.existsSync(kortexDir)) {
|
|
369
|
+
fs_1.default.mkdirSync(kortexDir, { recursive: true });
|
|
370
|
+
}
|
|
371
|
+
const graphPath = path_1.default.join(kortexDir, "graph.json");
|
|
372
|
+
fs_1.default.writeFileSync(graphPath, JSON.stringify(graph, null, 2), "utf-8");
|
|
373
|
+
}
|
|
374
|
+
function loadGraph(repoRoot) {
|
|
375
|
+
const graphPath = path_1.default.join(repoRoot, ".kortex", "graph.json");
|
|
376
|
+
if (!fs_1.default.existsSync(graphPath))
|
|
377
|
+
return null;
|
|
378
|
+
try {
|
|
379
|
+
return JSON.parse(fs_1.default.readFileSync(graphPath, "utf-8"));
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* tree-sitter-parser.ts
|
|
4
|
+
*
|
|
5
|
+
* Real AST-based symbol extraction for non-TS/JS languages, using web-tree-sitter
|
|
6
|
+
* with prebuilt WASM grammars (tree-sitter-wasms) — no native compilation required,
|
|
7
|
+
* works identically on any developer machine.
|
|
8
|
+
*
|
|
9
|
+
* This replaces regex/brace-counting heuristics (parseStructural, parsePython in
|
|
10
|
+
* ast-parser.ts) with genuine parse trees. Call and declaration extraction is still
|
|
11
|
+
* heuristic in the sense that it isn't type-checked (that's Tier 1, TS/JS only via
|
|
12
|
+
* the compiler), but it is now grounded in a real AST rather than pattern matching
|
|
13
|
+
* over raw text — eliminating false positives from strings/comments that merely
|
|
14
|
+
* look like declarations or calls.
|
|
15
|
+
*/
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.parseWithTreeSitter = parseWithTreeSitter;
|
|
21
|
+
const fs_1 = __importDefault(require("fs"));
|
|
22
|
+
const path_1 = __importDefault(require("path"));
|
|
23
|
+
const web_tree_sitter_1 = __importDefault(require("web-tree-sitter"));
|
|
24
|
+
const ast_parser_1 = require("./ast-parser");
|
|
25
|
+
const LANGUAGE_CONFIGS = {
|
|
26
|
+
python: {
|
|
27
|
+
wasmFile: "tree-sitter-python.wasm",
|
|
28
|
+
functionTypes: ["function_definition"],
|
|
29
|
+
classTypes: ["class_definition"],
|
|
30
|
+
callTypes: ["call"],
|
|
31
|
+
calleeFieldCandidates: ["function"],
|
|
32
|
+
nameFieldCandidates: ["name"],
|
|
33
|
+
importTypes: ["import_statement", "import_from_statement"],
|
|
34
|
+
},
|
|
35
|
+
go: {
|
|
36
|
+
wasmFile: "tree-sitter-go.wasm",
|
|
37
|
+
functionTypes: ["function_declaration", "method_declaration"],
|
|
38
|
+
classTypes: [],
|
|
39
|
+
callTypes: ["call_expression"],
|
|
40
|
+
calleeFieldCandidates: ["function"],
|
|
41
|
+
nameFieldCandidates: ["name"],
|
|
42
|
+
importTypes: ["import_spec"],
|
|
43
|
+
},
|
|
44
|
+
rust: {
|
|
45
|
+
wasmFile: "tree-sitter-rust.wasm",
|
|
46
|
+
functionTypes: ["function_item"],
|
|
47
|
+
classTypes: ["impl_item"],
|
|
48
|
+
callTypes: ["call_expression"],
|
|
49
|
+
calleeFieldCandidates: ["function"],
|
|
50
|
+
nameFieldCandidates: ["name", "type"],
|
|
51
|
+
importTypes: ["use_declaration"],
|
|
52
|
+
},
|
|
53
|
+
java: {
|
|
54
|
+
wasmFile: "tree-sitter-java.wasm",
|
|
55
|
+
functionTypes: ["method_declaration", "constructor_declaration"],
|
|
56
|
+
classTypes: ["class_declaration", "interface_declaration"],
|
|
57
|
+
callTypes: ["method_invocation"],
|
|
58
|
+
calleeFieldCandidates: ["name"],
|
|
59
|
+
nameFieldCandidates: ["name"],
|
|
60
|
+
importTypes: ["import_declaration"],
|
|
61
|
+
},
|
|
62
|
+
kotlin: {
|
|
63
|
+
wasmFile: "tree-sitter-kotlin.wasm",
|
|
64
|
+
functionTypes: ["function_declaration"],
|
|
65
|
+
classTypes: ["class_declaration"],
|
|
66
|
+
callTypes: ["call_expression"],
|
|
67
|
+
calleeFieldCandidates: ["function"],
|
|
68
|
+
nameFieldCandidates: ["name"],
|
|
69
|
+
importTypes: ["import_header"],
|
|
70
|
+
},
|
|
71
|
+
ruby: {
|
|
72
|
+
wasmFile: "tree-sitter-ruby.wasm",
|
|
73
|
+
functionTypes: ["method", "singleton_method"],
|
|
74
|
+
classTypes: ["class", "module"],
|
|
75
|
+
callTypes: ["call", "method_call"],
|
|
76
|
+
calleeFieldCandidates: ["method"],
|
|
77
|
+
nameFieldCandidates: ["name"],
|
|
78
|
+
importTypes: [],
|
|
79
|
+
},
|
|
80
|
+
php: {
|
|
81
|
+
wasmFile: "tree-sitter-php.wasm",
|
|
82
|
+
functionTypes: ["function_definition", "method_declaration"],
|
|
83
|
+
classTypes: ["class_declaration"],
|
|
84
|
+
callTypes: ["function_call_expression", "member_call_expression", "scoped_call_expression"],
|
|
85
|
+
calleeFieldCandidates: ["function", "name"],
|
|
86
|
+
nameFieldCandidates: ["name"],
|
|
87
|
+
importTypes: ["namespace_use_declaration"],
|
|
88
|
+
},
|
|
89
|
+
c: {
|
|
90
|
+
wasmFile: "tree-sitter-c.wasm",
|
|
91
|
+
functionTypes: ["function_definition"],
|
|
92
|
+
classTypes: [],
|
|
93
|
+
callTypes: ["call_expression"],
|
|
94
|
+
calleeFieldCandidates: ["function"],
|
|
95
|
+
nameFieldCandidates: [], // handled via declarator fallback
|
|
96
|
+
importTypes: ["preproc_include"],
|
|
97
|
+
},
|
|
98
|
+
cpp: {
|
|
99
|
+
wasmFile: "tree-sitter-cpp.wasm",
|
|
100
|
+
functionTypes: ["function_definition"],
|
|
101
|
+
classTypes: ["class_specifier", "struct_specifier"],
|
|
102
|
+
callTypes: ["call_expression"],
|
|
103
|
+
calleeFieldCandidates: ["function"],
|
|
104
|
+
nameFieldCandidates: [], // handled via declarator fallback
|
|
105
|
+
importTypes: ["preproc_include"],
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
const EXT_TO_LANGUAGE = {
|
|
109
|
+
".py": "python",
|
|
110
|
+
".go": "go",
|
|
111
|
+
".rs": "rust",
|
|
112
|
+
".java": "java",
|
|
113
|
+
".kt": "kotlin",
|
|
114
|
+
".rb": "ruby",
|
|
115
|
+
".php": "php",
|
|
116
|
+
".c": "c",
|
|
117
|
+
".cpp": "cpp",
|
|
118
|
+
};
|
|
119
|
+
let initialised = false;
|
|
120
|
+
const languageCache = new Map();
|
|
121
|
+
async function ensureInit() {
|
|
122
|
+
if (!initialised) {
|
|
123
|
+
await web_tree_sitter_1.default.init();
|
|
124
|
+
initialised = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function loadLanguage(key, config) {
|
|
128
|
+
const cached = languageCache.get(key);
|
|
129
|
+
if (cached)
|
|
130
|
+
return cached;
|
|
131
|
+
const wasmDir = path_1.default.join(path_1.default.dirname(require.resolve("tree-sitter-wasms/package.json")), "out");
|
|
132
|
+
const wasmPath = path_1.default.join(wasmDir, config.wasmFile);
|
|
133
|
+
const wasmBuffer = fs_1.default.readFileSync(wasmPath);
|
|
134
|
+
const lang = await web_tree_sitter_1.default.Language.load(wasmBuffer);
|
|
135
|
+
languageCache.set(key, lang);
|
|
136
|
+
return lang;
|
|
137
|
+
}
|
|
138
|
+
// ── Node helpers ──────────────────────────────────────────────────────────────
|
|
139
|
+
function getFieldOrFallback(node, candidates) {
|
|
140
|
+
for (const field of candidates) {
|
|
141
|
+
const child = node.childForFieldName(field);
|
|
142
|
+
if (child)
|
|
143
|
+
return child;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
/** Descends through nested declarators (C/C++ function name pattern) to find the innermost identifier. */
|
|
148
|
+
function extractDeclaratorName(node) {
|
|
149
|
+
let current = node.childForFieldName("declarator");
|
|
150
|
+
let guard = 0;
|
|
151
|
+
while (current && guard < 10) {
|
|
152
|
+
guard++;
|
|
153
|
+
if (current.type === "identifier" || current.type === "field_identifier") {
|
|
154
|
+
return current.text;
|
|
155
|
+
}
|
|
156
|
+
const nested = current.childForFieldName("declarator");
|
|
157
|
+
if (!nested)
|
|
158
|
+
break;
|
|
159
|
+
current = nested;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
function getNodeName(node, config) {
|
|
164
|
+
const viaField = getFieldOrFallback(node, config.nameFieldCandidates);
|
|
165
|
+
if (viaField)
|
|
166
|
+
return viaField.text;
|
|
167
|
+
const viaDeclarator = extractDeclaratorName(node);
|
|
168
|
+
if (viaDeclarator)
|
|
169
|
+
return viaDeclarator;
|
|
170
|
+
// Last resort: first identifier-like named child
|
|
171
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
172
|
+
const child = node.namedChild(i);
|
|
173
|
+
if (child && (child.type === "identifier" || child.type === "type_identifier" || child.type === "simple_identifier")) {
|
|
174
|
+
return child.text;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/** Extracts a trailing identifier from a callee expression (e.g. "$this->service->method" -> "method"). */
|
|
180
|
+
function extractTrailingIdentifier(text) {
|
|
181
|
+
const match = text.match(/([A-Za-z_][A-Za-z0-9_]*)$/);
|
|
182
|
+
return match ? match[1] : null;
|
|
183
|
+
}
|
|
184
|
+
function extractCallNamesTs(node, config) {
|
|
185
|
+
const calls = new Set();
|
|
186
|
+
function walk(n) {
|
|
187
|
+
if (config.callTypes.includes(n.type)) {
|
|
188
|
+
const calleeNode = getFieldOrFallback(n, config.calleeFieldCandidates) ?? n.namedChild(0);
|
|
189
|
+
if (calleeNode) {
|
|
190
|
+
const name = extractTrailingIdentifier(calleeNode.text);
|
|
191
|
+
if (name)
|
|
192
|
+
calls.add(name);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (let i = 0; i < n.namedChildCount; i++) {
|
|
196
|
+
const child = n.namedChild(i);
|
|
197
|
+
if (child)
|
|
198
|
+
walk(child);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
walk(node);
|
|
202
|
+
return Array.from(calls);
|
|
203
|
+
}
|
|
204
|
+
function extractImportsTs(root, config) {
|
|
205
|
+
const imports = [];
|
|
206
|
+
if (config.importTypes.length === 0)
|
|
207
|
+
return imports;
|
|
208
|
+
function walk(n) {
|
|
209
|
+
if (config.importTypes.includes(n.type)) {
|
|
210
|
+
imports.push({ imported: "", source: n.text.trim(), isDefault: false });
|
|
211
|
+
}
|
|
212
|
+
for (let i = 0; i < n.namedChildCount; i++) {
|
|
213
|
+
const child = n.namedChild(i);
|
|
214
|
+
if (child)
|
|
215
|
+
walk(child);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
walk(root);
|
|
219
|
+
return imports;
|
|
220
|
+
}
|
|
221
|
+
// ── Main extraction ───────────────────────────────────────────────────────────
|
|
222
|
+
async function parseWithTreeSitter(filePath, source) {
|
|
223
|
+
const ext = path_1.default.extname(filePath).toLowerCase();
|
|
224
|
+
const langKey = EXT_TO_LANGUAGE[ext];
|
|
225
|
+
if (!langKey)
|
|
226
|
+
return [];
|
|
227
|
+
const config = LANGUAGE_CONFIGS[langKey];
|
|
228
|
+
if (!config)
|
|
229
|
+
return [];
|
|
230
|
+
try {
|
|
231
|
+
await ensureInit();
|
|
232
|
+
const language = await loadLanguage(langKey, config);
|
|
233
|
+
const parser = new web_tree_sitter_1.default();
|
|
234
|
+
parser.setLanguage(language);
|
|
235
|
+
const tree = parser.parse(source);
|
|
236
|
+
const root = tree.rootNode;
|
|
237
|
+
const fileImports = extractImportsTs(root, config);
|
|
238
|
+
const symbols = [];
|
|
239
|
+
function visit(node, enclosingClassName) {
|
|
240
|
+
if (config.classTypes.includes(node.type)) {
|
|
241
|
+
const className = getNodeName(node, config);
|
|
242
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
243
|
+
const child = node.namedChild(i);
|
|
244
|
+
if (child)
|
|
245
|
+
visit(child, className ?? undefined);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (config.functionTypes.includes(node.type)) {
|
|
250
|
+
const rawName = getNodeName(node, config);
|
|
251
|
+
if (rawName) {
|
|
252
|
+
const qualifiedName = enclosingClassName ? enclosingClassName + "." + rawName : rawName;
|
|
253
|
+
const body = node.text;
|
|
254
|
+
symbols.push({
|
|
255
|
+
name: qualifiedName,
|
|
256
|
+
type: enclosingClassName ? "method" : "function",
|
|
257
|
+
file: filePath,
|
|
258
|
+
startLine: node.startPosition.row + 1,
|
|
259
|
+
endLine: node.endPosition.row + 1,
|
|
260
|
+
body,
|
|
261
|
+
fingerprint: (0, ast_parser_1.computeFingerprint)(body),
|
|
262
|
+
calls: extractCallNamesTs(node, config),
|
|
263
|
+
imports: fileImports,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// still recurse in case of nested closures/functions
|
|
267
|
+
}
|
|
268
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
269
|
+
const child = node.namedChild(i);
|
|
270
|
+
if (child)
|
|
271
|
+
visit(child, enclosingClassName);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
visit(root);
|
|
275
|
+
return symbols;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Parsing failures must never crash a full-repo scan — return empty for this file
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodingo-cli",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Kodingo CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -28,12 +28,14 @@
|
|
|
28
28
|
"kodingo-core": "^0.2.1",
|
|
29
29
|
"open": "^11.0.0",
|
|
30
30
|
"simple-git": "^3.30.0",
|
|
31
|
-
"
|
|
31
|
+
"tree-sitter-wasms": "^0.1.13",
|
|
32
|
+
"typescript": "^5.9.3",
|
|
33
|
+
"uuid": "^10.0.0",
|
|
34
|
+
"web-tree-sitter": "^0.20.8"
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
34
37
|
"@types/babel__traverse": "^7.28.0",
|
|
35
38
|
"@types/node": "^22.19.20",
|
|
36
|
-
"@types/uuid": "^10.0.0"
|
|
37
|
-
"typescript": "^5.6.0"
|
|
39
|
+
"@types/uuid": "^10.0.0"
|
|
38
40
|
}
|
|
39
41
|
}
|