kodingo-cli 1.0.37 → 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 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")
@@ -273,6 +273,20 @@ function registerInitCommand(program) {
273
273
  (0, persistence_config_1.writeConfig)({ ...existingConfig, mode: "cloud", apiUrl, machineId });
274
274
  (0, persistence_config_1.writeWorkspaceConfig)(repoRoot, { mode: "cloud", apiUrl, token: selectedProject.token });
275
275
  ensureGitignoreEntry(repoRoot, ".kortex/");
276
+ // Register this repo with the project on the API
277
+ // This is the authoritative binding that prevents cross-project contamination
278
+ try {
279
+ const repoName = path.basename(repoRoot);
280
+ await fetch(`${apiUrl}/org/projects/${selectedProject.id}/repos`, {
281
+ method: "POST",
282
+ headers: {
283
+ "Content-Type": "application/json",
284
+ "Authorization": `Bearer ${authToken}`,
285
+ },
286
+ body: JSON.stringify({ repoName }),
287
+ });
288
+ }
289
+ catch { }
276
290
  console.log(`✔ Connected. Token saved to ${persistence_config_1.CONFIG_PATH}`);
277
291
  console.log("\nKortex is now watching this repo.");
278
292
  console.log("Run `kodingo install-hook` to capture memories on every commit.\n");
@@ -25,7 +25,7 @@ function getCredentials(repoRoot) {
25
25
  current = path_1.default.dirname(current);
26
26
  wsEntry = config.workspaces?.[current] ?? config.workspaces?.[current + "/"];
27
27
  }
28
- const token = wsEntry?.token ?? config.token ?? "";
28
+ const token = wsEntry?.token ?? "";
29
29
  const apiUrl = wsEntry?.apiUrl ?? config.apiUrl ?? API_BASE_DEFAULT;
30
30
  return { token, apiUrl };
31
31
  }
@@ -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
- for (const m of data.memories ?? []) {
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 (offset >= (data.total ?? 0))
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
- const inferRes = await fetch(`${API_BASE}/infer`, {
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({ symbol: symbol.name, code: symbol.code }),
178
+ body: JSON.stringify(body),
159
179
  });
160
180
  if (!inferRes.ok)
161
181
  return false;
@@ -186,11 +206,11 @@ async function scanRepoCommand(options = {}) {
186
206
  const config = (0, persistence_config_1.readConfig)();
187
207
  const repoRoot2 = options.repo ? path_1.default.resolve(options.repo) : findRepoRoot(process.cwd());
188
208
  const wsConfig = (0, persistence_config_1.readWorkspaceConfig)(repoRoot2);
189
- const token = wsConfig?.token ?? config.token ?? "";
209
+ const token = wsConfig?.token ?? "";
190
210
  const apiUrl = wsConfig?.apiUrl ?? config.apiUrl ?? "https://kodingo-api.onrender.com";
191
211
  if (!token) {
192
212
  if (!options.silent)
193
- console.error("✖ No project token found. Run `kodingo init` first.");
213
+ console.error("✖ This repo is not connected to Kodingo. Run `kodingo init` in this directory first.");
194
214
  return 0;
195
215
  }
196
216
  const repoRoot = options.repo
@@ -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
- // Shuffle so repeated runs cover different parts of the codebase
221
- const shuffled = [...allSymbols].sort(() => Math.random() - 0.5);
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
+ }
@@ -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.37",
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
- "uuid": "^10.0.0"
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
  }