@kurtel/cli 0.1.11 → 0.1.13

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.
@@ -1,5 +1,6 @@
1
1
  import { repoRoot, repoFullName, loadIndex, loadMemoryCache, memoryEnabled, loadMemoryState, saveMemoryState, queueTelemetry, } from "../memory/store.js";
2
2
  import { compileCapsule, compileZoneCapsule, findSimilarRoutes } from "../memory/capsule.js";
3
+ import { resolveTarget, computeImpact } from "../memory/impact.js";
3
4
  import { syncInBackground } from "../memory/sync.js";
4
5
  function readStdin() {
5
6
  return new Promise((resolve) => {
@@ -129,6 +130,24 @@ function onPostToolUse(root, input) {
129
130
  state.session_zones[sid] = [...seen, zone];
130
131
  saveMemoryState(root, state);
131
132
  }
133
+ // 3. Fichier à fort couplage: une ligne d'impact, la même vue que le dev.
134
+ if (index) {
135
+ const mod = index.modules.find((m) => m.id === filePath);
136
+ const isHot = mod && (mod.degree >= 8 || index.god_nodes.some((g) => g.id === filePath));
137
+ if (isHot && !seen.has("impact:" + filePath)) {
138
+ const t = resolveTarget(index, filePath);
139
+ if (t) {
140
+ const r = computeImpact(index, t, 3);
141
+ if (r.transitive > 0) {
142
+ messages.push(`[Kurtel impact] ${filePath}: ${r.direct} direct dependents, ${r.transitive} transitive (≤3 hops)` +
143
+ (r.affectedRoutes.length ? ` — routes in blast radius: ${r.affectedRoutes.slice(0, 3).map((x) => x.path).join(", ")}` : "") +
144
+ `. Check dependents before changing signatures; run \`kurtel impact ${filePath} --json\` for the full set.`);
145
+ state.session_zones[sid] = [...(state.session_zones[sid] ?? []), "impact:" + filePath];
146
+ saveMemoryState(root, state);
147
+ }
148
+ }
149
+ }
150
+ }
132
151
  if (messages.length)
133
152
  emitContext("PostToolUse", messages.join("\n\n"));
134
153
  }
@@ -0,0 +1,32 @@
1
+ import { c, symbols } from "../ui/colors.js";
2
+ import { repoRoot, loadIndex } from "../memory/store.js";
3
+ import { resolveTarget, computeImpact, renderImpactForAgent } from "../memory/impact.js";
4
+ export async function impactCommand(target, opts = {}) {
5
+ if (!target) {
6
+ console.log(`${c.red(symbols.cross)} Usage: ${c.indigo('kurtel impact <file | file::function | function>')}`);
7
+ process.exitCode = 1;
8
+ return;
9
+ }
10
+ const root = repoRoot();
11
+ const index = loadIndex(root);
12
+ if (!index) {
13
+ console.log(`${c.red(symbols.cross)} No index. Run ${c.indigo("kurtel onboard")} first.`);
14
+ process.exitCode = 1;
15
+ return;
16
+ }
17
+ const resolved = resolveTarget(index, target);
18
+ if (!resolved) {
19
+ console.log(`${c.red(symbols.cross)} Target ${c.white(target)} not found (or ambiguous — use file::function).`);
20
+ process.exitCode = 1;
21
+ return;
22
+ }
23
+ const depth = Math.min(6, Math.max(1, parseInt(opts.depth ?? "4", 10) || 4));
24
+ const result = computeImpact(index, resolved, depth);
25
+ if (opts.json) {
26
+ process.stdout.write(JSON.stringify(result));
27
+ return;
28
+ }
29
+ console.log("");
30
+ console.log(renderImpactForAgent(result));
31
+ console.log("");
32
+ }
@@ -42,6 +42,18 @@ The user said: "$ARGUMENTS"
42
42
  - If it contains "pattern" → run \`kurtel memory patterns --json\` and present the patterns as a readable list (rule, confidence, zones, evidence count), sorted by confidence
43
43
  - Otherwise → run \`kurtel memory status --json\` and present a one-line summary
44
44
  Relay the result conversationally. Never paste raw JSON to the user.
45
+ `,
46
+ "impact.md": `---
47
+ description: Blast radius of changing a file or function (who breaks if I touch X)
48
+ allowed-tools: Bash(kurtel impact:*)
49
+ ---
50
+ The user wants the impact of changing: "$ARGUMENTS"
51
+ Run \`kurtel impact $ARGUMENTS --json\` with the Bash tool, then present:
52
+ 1. Direct vs transitive dependent counts.
53
+ 2. The dependency layers (depth 1 first) as a short readable list.
54
+ 3. The reverse call chain if present (which functions call the target).
55
+ 4. Routes in the blast radius — these are user-facing surfaces, flag them clearly.
56
+ If the command says the target was not found, suggest the file::function syntax.
45
57
  `,
46
58
  "status.md": `---
47
59
  description: Show Kurtel memory status for this repo
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { onboardCommand } from "./commands/onboard.js";
15
15
  import { memoryCommand } from "./commands/memory.js";
16
16
  import { installClaudeCodeCommand, uninstallClaudeCodeCommand } from "./commands/installClaudeCode.js";
17
17
  import { hookCommand } from "./commands/hook.js";
18
+ import { impactCommand } from "./commands/impact.js";
18
19
  const program = new Command();
19
20
  program
20
21
  .name("kurtel")
@@ -118,6 +119,13 @@ program
118
119
  .option("--json", "Machine-readable output (used by /kurtel:onboard)", false)
119
120
  .option("--local", "Skip cloud upload — index stays on disk", false)
120
121
  .action(async (opts) => onboardCommand(opts));
122
+ program
123
+ .command("impact")
124
+ .description("Blast radius of changing a file or function (reverse imports + call graph)")
125
+ .argument("[target...]", "file path, file::function, or unique function name")
126
+ .option("--json", "Machine-readable output", false)
127
+ .option("--depth <n>", "Max BFS depth (default 4)")
128
+ .action(async (parts, opts) => impactCommand((parts ?? []).join(" "), opts));
121
129
  program
122
130
  .command("memory")
123
131
  .description("Inspect or toggle Kurtel memory (status | on | off | sync | patterns)")
@@ -39,7 +39,11 @@ export function pullPatterns(repo, since) {
39
39
  export function pushIndex(repo, index) {
40
40
  const digest = {
41
41
  ...index,
42
- modules: index.modules.map((m) => ({ ...m, exports: m.exports.slice(0, 10) })).slice(0, 3000),
42
+ modules: index.modules.map((m) => ({
43
+ ...m,
44
+ exports: m.exports.slice(0, 10),
45
+ symbols: (m.symbols ?? []).slice(0, 25).map((sy) => ({ ...sy, calls: sy.calls.slice(0, 15) })),
46
+ })).slice(0, 3000),
43
47
  };
44
48
  return authed("PUT", `/api/memory/index${q(repo)}`, digest);
45
49
  }
@@ -0,0 +1,152 @@
1
+ /** Adjacences inverses précalculées depuis l'index. */
2
+ export function buildReverse(index) {
3
+ const fileRev = new Map(); // file ← importeurs
4
+ const symbolRev = new Map(); // "f::fn" ← appelants
5
+ for (const m of index.modules) {
6
+ for (const t of m.imports) {
7
+ (fileRev.get(t) ?? fileRev.set(t, []).get(t)).push(m.id);
8
+ }
9
+ for (const s of m.symbols ?? []) {
10
+ const from = `${m.id}::${s.name}`;
11
+ for (const call of s.calls) {
12
+ (symbolRev.get(call) ?? symbolRev.set(call, []).get(call)).push({ from, via: call });
13
+ }
14
+ }
15
+ }
16
+ return { fileRev, symbolRev };
17
+ }
18
+ const MAX_NODES = 400;
19
+ /** Résout une cible libre: "src/billing/service.ts", "service.ts::chargeCustomer", ou juste "chargeCustomer". */
20
+ export function resolveTarget(index, query) {
21
+ const q = query.trim().replace(/\\/g, "/");
22
+ if (q.includes("::")) {
23
+ const [file, fn] = q.split("::");
24
+ const mod = index.modules.find((m) => m.id === file || m.id.endsWith("/" + file));
25
+ if (mod?.symbols?.some((s) => s.name === fn))
26
+ return { id: `${mod.id}::${fn}`, kind: "symbol" };
27
+ return null;
28
+ }
29
+ const exact = index.modules.find((m) => m.id === q);
30
+ if (exact)
31
+ return { id: exact.id, kind: "file" };
32
+ const suffix = index.modules.filter((m) => m.id.endsWith("/" + q) || m.id.endsWith(q));
33
+ if (suffix.length === 1)
34
+ return { id: suffix[0].id, kind: "file" };
35
+ // nom de fonction seul: unique dans le repo ?
36
+ const owners = index.modules.filter((m) => m.symbols?.some((s) => s.name === q));
37
+ if (owners.length === 1)
38
+ return { id: `${owners[0].id}::${q}`, kind: "symbol" };
39
+ return null;
40
+ }
41
+ export function computeImpact(index, target, maxDepth = 4) {
42
+ const { fileRev, symbolRev } = buildReverse(index);
43
+ const fileDepth = new Map();
44
+ const symbols = [];
45
+ let truncated = false;
46
+ if (target.kind === "symbol") {
47
+ // BFS inverse sur le graphe d'appels; les fichiers porteurs héritent de la profondeur.
48
+ const seen = new Map([[target.id, 0]]);
49
+ let frontier = [target.id];
50
+ for (let d = 1; d <= maxDepth && frontier.length; d++) {
51
+ const next = [];
52
+ for (const sym of frontier) {
53
+ for (const caller of symbolRev.get(sym) ?? []) {
54
+ if (seen.has(caller.from))
55
+ continue;
56
+ if (seen.size >= MAX_NODES) {
57
+ truncated = true;
58
+ break;
59
+ }
60
+ seen.set(caller.from, d);
61
+ symbols.push({ symbol: caller.from, depth: d, via: sym });
62
+ next.push(caller.from);
63
+ const file = caller.from.split("::")[0];
64
+ if (!fileDepth.has(file))
65
+ fileDepth.set(file, d);
66
+ }
67
+ }
68
+ frontier = next;
69
+ }
70
+ // Le fichier hôte du symbole est impacté à profondeur 0 (on le modifie).
71
+ fileDepth.set(target.id.split("::")[0], 0);
72
+ }
73
+ else {
74
+ const seen = new Map([[target.id, 0]]);
75
+ let frontier = [target.id];
76
+ for (let d = 1; d <= maxDepth && frontier.length; d++) {
77
+ const next = [];
78
+ for (const f of frontier) {
79
+ for (const importer of fileRev.get(f) ?? []) {
80
+ if (seen.has(importer))
81
+ continue;
82
+ if (seen.size >= MAX_NODES) {
83
+ truncated = true;
84
+ break;
85
+ }
86
+ seen.set(importer, d);
87
+ next.push(importer);
88
+ }
89
+ }
90
+ frontier = next;
91
+ }
92
+ for (const [f, d] of seen)
93
+ if (d > 0)
94
+ fileDepth.set(f, d);
95
+ // Bonus: appels symboliques inverses depuis tous les symboles du fichier (profondeur 1 du détail).
96
+ const mod = index.modules.find((m) => m.id === target.id);
97
+ for (const s of mod?.symbols ?? []) {
98
+ for (const caller of symbolRev.get(`${target.id}::${s.name}`) ?? []) {
99
+ symbols.push({ symbol: caller.from, depth: 1, via: `${target.id}::${s.name}` });
100
+ if (symbols.length >= 60)
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ const byDepth = new Map();
106
+ for (const [f, d] of fileDepth) {
107
+ if (d === 0)
108
+ continue;
109
+ (byDepth.get(d) ?? byDepth.set(d, []).get(d)).push(f);
110
+ }
111
+ const layers = [...byDepth.entries()]
112
+ .sort((a, b) => a[0] - b[0])
113
+ .map(([depth, files]) => ({ depth, files: files.sort() }));
114
+ const impactedFiles = new Set(fileDepth.keys());
115
+ const affectedRoutes = index.routes
116
+ .filter((r) => impactedFiles.has(r.file))
117
+ .map((r) => ({ method: r.method, path: r.path, file: r.file }))
118
+ .slice(0, 40);
119
+ return {
120
+ target: target.id,
121
+ kind: target.kind,
122
+ direct: layers[0]?.files.length ?? 0,
123
+ transitive: layers.reduce((s, l) => s + l.files.length, 0),
124
+ layers,
125
+ symbols: symbols.slice(0, 60),
126
+ affectedRoutes,
127
+ truncated,
128
+ };
129
+ }
130
+ /** Rendu texte compact pour l'agent (~ borné, lisible par un LLM). */
131
+ export function renderImpactForAgent(r) {
132
+ const lines = [];
133
+ lines.push(`[Impact analysis] ${r.target} (${r.kind})`);
134
+ lines.push(`Direct dependents: ${r.direct} · transitive (≤4 hops): ${r.transitive}${r.truncated ? " (truncated)" : ""}`);
135
+ for (const layer of r.layers.slice(0, 3)) {
136
+ lines.push(`Depth ${layer.depth}: ${layer.files.slice(0, 10).join(", ")}${layer.files.length > 10 ? ` … +${layer.files.length - 10}` : ""}`);
137
+ }
138
+ if (r.symbols.length) {
139
+ lines.push(`Call chain (who calls this):`);
140
+ for (const s of r.symbols.slice(0, 10))
141
+ lines.push(` d${s.depth} ${s.symbol} → ${s.via}`);
142
+ }
143
+ if (r.affectedRoutes.length) {
144
+ lines.push(`Routes in blast radius:`);
145
+ for (const rt of r.affectedRoutes.slice(0, 8))
146
+ lines.push(` ${rt.method} ${rt.path} (${rt.file})`);
147
+ }
148
+ let out = lines.join("\n");
149
+ if (out.length > 1800)
150
+ out = out.slice(0, 1799) + "…";
151
+ return out;
152
+ }
@@ -41,25 +41,105 @@ const PY_ROUTE_PATTERNS = [
41
41
  },
42
42
  ];
43
43
  const NEXT_ROUTE_FILE = /(^|\/)(pages|app)\/(.+?)\/?(route|page|index)?\.(ts|tsx|js|jsx)$/;
44
+ const JS_KEYWORDS = new Set([
45
+ "if", "for", "while", "switch", "catch", "return", "function", "new", "typeof",
46
+ "await", "async", "import", "export", "require", "console", "constructor",
47
+ "super", "this", "throw", "delete", "void", "in", "of", "do", "else", "try",
48
+ ]);
49
+ const PY_KEYWORDS = new Set([
50
+ "if", "for", "while", "return", "print", "len", "range", "str", "int", "dict",
51
+ "list", "set", "tuple", "type", "isinstance", "super", "open", "enumerate",
52
+ ]);
53
+ function lineOf(src, index) {
54
+ let n = 1;
55
+ for (let i = 0; i < index; i++)
56
+ if (src.charCodeAt(i) === 10)
57
+ n++;
58
+ return n;
59
+ }
44
60
  function extractFile(root, rel, src) {
45
61
  const lines = src.split("\n");
46
- const facts = { rel, loc: lines.length, exports: [], importSpecs: [], routes: [] };
62
+ const facts = {
63
+ rel, loc: lines.length, exports: [], importSpecs: [], routes: [],
64
+ defs: [], namedImports: {}, rawCalls: [],
65
+ };
47
66
  const ext = extname(rel);
48
- if (ext === ".py") {
49
- for (const m of src.matchAll(/^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))/gm)) {
50
- facts.importSpecs.push(m[1] ?? m[2]);
67
+ const isPy = ext === ".py";
68
+ if (isPy) {
69
+ for (const m of src.matchAll(/^\s*(?:from\s+([\w.]+)\s+import\s+([\w ,]+)|import\s+([\w.]+))/gm)) {
70
+ facts.importSpecs.push(m[1] ?? m[3]);
71
+ // from x import a, b → imports nommés
72
+ if (m[1] && m[2]) {
73
+ for (const name of m[2].split(",").map((s) => s.trim().split(/\s+as\s+/)[0])) {
74
+ if (/^\w+$/.test(name))
75
+ facts.namedImports[name] = m[1];
76
+ }
77
+ }
78
+ }
79
+ for (const m of src.matchAll(/^(?:\s*)(?:async\s+)?def\s+(\w+)/gm)) {
80
+ facts.exports.push(m[1]);
81
+ facts.defs.push({ name: m[1], line: lineOf(src, m.index ?? 0) });
51
82
  }
52
- for (const m of src.matchAll(/^(?:def|class)\s+(\w+)/gm))
83
+ for (const m of src.matchAll(/^class\s+(\w+)/gm))
53
84
  facts.exports.push(m[1]);
54
85
  }
55
86
  else {
56
- for (const m of src.matchAll(/\bfrom\s+["']([^"']+)["']|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g)) {
57
- facts.importSpecs.push(m[1] ?? m[2]);
87
+ for (const m of src.matchAll(/import\s+(?:type\s+)?(?:(\w+)\s*,?\s*)?(?:{([^}]*)})?\s*from\s+["']([^"']+)["']|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g)) {
88
+ const spec = m[3] ?? m[4];
89
+ if (!spec)
90
+ continue;
91
+ facts.importSpecs.push(spec);
92
+ if (m[1])
93
+ facts.namedImports[m[1]] = spec; // import défaut
94
+ if (m[2]) {
95
+ for (const part of m[2].split(",")) {
96
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
97
+ if (name && /^\w+$/.test(name))
98
+ facts.namedImports[name] = spec;
99
+ }
100
+ }
101
+ }
102
+ // Définitions: function decl, const fléchée, méthodes de classe.
103
+ for (const m of src.matchAll(/\b(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(\w+)/g)) {
104
+ facts.defs.push({ name: m[1], line: lineOf(src, m.index ?? 0) });
105
+ }
106
+ for (const m of src.matchAll(/\b(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|\w+)\s*(?::\s*[^=]+)?=>/g)) {
107
+ facts.defs.push({ name: m[1], line: lineOf(src, m.index ?? 0) });
108
+ }
109
+ for (const m of src.matchAll(/^\s{2,}(?:public\s+|private\s+|protected\s+|static\s+)*(?:async\s+)?(\w+)\s*\([^)]*\)\s*(?::\s*[\w<>[\]| .]+)?\s*{/gm)) {
110
+ if (!JS_KEYWORDS.has(m[1]))
111
+ facts.defs.push({ name: m[1], line: lineOf(src, m.index ?? 0) });
58
112
  }
59
113
  for (const m of src.matchAll(/\bexport\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+(\w+)/g)) {
60
114
  facts.exports.push(m[1]);
61
115
  }
62
116
  }
117
+ // Dédupe les defs (même nom: garder la première occurrence), tri par ligne.
118
+ const seen = new Set();
119
+ facts.defs = facts.defs
120
+ .sort((a, b) => a.line - b.line)
121
+ .filter((d) => (seen.has(d.name) ? false : (seen.add(d.name), true)))
122
+ .slice(0, 40);
123
+ // Sites d'appel candidats: identifiant suivi de "(", hors mots-clés.
124
+ const kw = isPy ? PY_KEYWORDS : JS_KEYWORDS;
125
+ let count = 0;
126
+ for (const m of src.matchAll(/(?<![\w.])([A-Za-z_]\w{2,})\s*\(/g)) {
127
+ if (kw.has(m[1]))
128
+ continue;
129
+ facts.rawCalls.push({ name: m[1], line: lineOf(src, m.index ?? 0) });
130
+ if (++count >= 800)
131
+ break;
132
+ }
133
+ // Appels qualifiés "Prefix.method(" → candidat "Prefix." (résolu vers la
134
+ // classe/module importé en post-passe; le point final marque le cas qualifié).
135
+ for (const m of src.matchAll(/(?<![\w.])([A-Z]\w{2,})\.\w+\s*\(/g)) {
136
+ if (kw.has(m[1]))
137
+ continue;
138
+ facts.rawCalls.push({ name: m[1] + ".", line: lineOf(src, m.index ?? 0) });
139
+ if (++count >= 1000)
140
+ break;
141
+ }
142
+ facts.rawCalls.sort((a, b) => a.line - b.line);
63
143
  // Routes par appel/décorateur, avec n° de ligne — patterns du bon langage uniquement.
64
144
  const routePatterns = ext === ".py" ? PY_ROUTE_PATTERNS : JS_ROUTE_PATTERNS;
65
145
  for (const p of routePatterns) {
@@ -83,7 +163,7 @@ function extractFile(root, rel, src) {
83
163
  return facts;
84
164
  }
85
165
  // ── Résolution d'imports internes (TS/JS relatifs + Python par module path) ──
86
- function resolveImports(all) {
166
+ function buildResolver(all) {
87
167
  const byNoExt = new Map();
88
168
  for (const rel of all.keys()) {
89
169
  const noExt = rel.replace(/\.(ts|tsx|js|jsx|mjs|cjs|py)$/, "");
@@ -93,25 +173,25 @@ function resolveImports(all) {
93
173
  if (noExt.endsWith("/__init__"))
94
174
  byNoExt.set(noExt.slice(0, -"/__init__".length), rel);
95
175
  }
176
+ return (rel, spec) => {
177
+ if (spec.startsWith(".")) {
178
+ const base = join(dirname(rel), spec).replace(/\\/g, "/").replace(/\.(js|ts|tsx|jsx)$/, "");
179
+ return byNoExt.get(base);
180
+ }
181
+ if (spec.includes(".") && extname(rel) === ".py") {
182
+ return byNoExt.get(spec.replace(/\./g, "/"));
183
+ }
184
+ const cleaned = spec.replace(/^@\//, "src/").replace(/^~\//, "src/");
185
+ return byNoExt.get(cleaned);
186
+ };
187
+ }
188
+ function resolveImports(all) {
189
+ const resolve = buildResolver(all);
96
190
  const resolved = new Map();
97
191
  for (const [rel, facts] of all) {
98
192
  const targets = [];
99
193
  for (const spec of facts.importSpecs) {
100
- let candidate;
101
- if (spec.startsWith(".")) {
102
- // relatif TS/JS
103
- const base = join(dirname(rel), spec).replace(/\\/g, "/").replace(/\.(js|ts|tsx|jsx)$/, "");
104
- candidate = byNoExt.get(base);
105
- }
106
- else if (spec.includes(".") && extname(rel) === ".py") {
107
- // module python "app.services.billing" → app/services/billing.py
108
- candidate = byNoExt.get(spec.replace(/\./g, "/"));
109
- }
110
- else {
111
- // alias absolu fréquent: "src/lib/foo", "@/lib/foo"
112
- const cleaned = spec.replace(/^@\//, "src/").replace(/^~\//, "src/");
113
- candidate = byNoExt.get(cleaned);
114
- }
194
+ const candidate = resolve(rel, spec);
115
195
  if (candidate && candidate !== rel)
116
196
  targets.push(candidate);
117
197
  }
@@ -119,6 +199,56 @@ function resolveImports(all) {
119
199
  }
120
200
  return resolved;
121
201
  }
202
+ // ── Graphe d'appels: résolution des sites d'appel en arêtes symbole→symbole ──
203
+ const MAX_SYMBOLS_PER_FILE = 25;
204
+ const MAX_CALLS_PER_SYMBOL = 15;
205
+ function buildSymbols(all, resolve) {
206
+ const defNames = new Map();
207
+ const exportNames = new Map();
208
+ for (const [rel, f] of all) {
209
+ defNames.set(rel, new Set(f.defs.map((d) => d.name)));
210
+ exportNames.set(rel, new Set(f.exports));
211
+ }
212
+ const out = new Map();
213
+ for (const [rel, f] of all) {
214
+ const locals = defNames.get(rel);
215
+ const symbols = f.defs.slice(0, MAX_SYMBOLS_PER_FILE).map((d) => ({ ...d, calls: [] }));
216
+ // Pseudo-symbole pour les appels hors fonction (handlers de routes inline,
217
+ // initialisation module) — c'est là que vivent les arêtes des fichiers routes.
218
+ const topLevel = { name: "(module)", line: 0, calls: [] };
219
+ const resolveCall = (raw) => {
220
+ const qualified = raw.endsWith(".");
221
+ const name = qualified ? raw.slice(0, -1) : raw;
222
+ if (!qualified && locals.has(name))
223
+ return `${rel}::${name}`;
224
+ const spec = f.namedImports[name];
225
+ if (spec) {
226
+ const file = resolve(rel, spec);
227
+ if (file && file !== rel && (defNames.get(file)?.has(name) || exportNames.get(file)?.has(name))) {
228
+ return `${file}::${name}`;
229
+ }
230
+ }
231
+ return null;
232
+ };
233
+ for (const call of f.rawCalls) {
234
+ let caller = topLevel;
235
+ for (const s of symbols) {
236
+ if (s.line <= call.line)
237
+ caller = s;
238
+ else
239
+ break;
240
+ }
241
+ if (caller.name === call.name || caller.calls.length >= MAX_CALLS_PER_SYMBOL)
242
+ continue;
243
+ const target = resolveCall(call.name);
244
+ if (target && target !== `${rel}::${caller.name}` && !caller.calls.includes(target)) {
245
+ caller.calls.push(target);
246
+ }
247
+ }
248
+ out.set(rel, topLevel.calls.length ? [topLevel, ...symbols] : symbols);
249
+ }
250
+ return out;
251
+ }
122
252
  // ── Parcours ────────────────────────────────────────────────────────────────
123
253
  function* walk(dir, root) {
124
254
  let entries;
@@ -164,6 +294,7 @@ export function buildIndex(root, onProgress) {
164
294
  catch { /* binaire/illisible: skip */ }
165
295
  }
166
296
  const imports = resolveImports(files);
297
+ const symbolMap = buildSymbols(files, buildResolver(files));
167
298
  const inDegree = new Map();
168
299
  for (const targets of imports.values()) {
169
300
  for (const t of targets)
@@ -178,6 +309,7 @@ export function buildIndex(root, onProgress) {
178
309
  imports: out,
179
310
  loc: f.loc,
180
311
  degree: out.length + (inDegree.get(rel) ?? 0),
312
+ symbols: symbolMap.get(rel) ?? [],
181
313
  };
182
314
  })
183
315
  .sort((a, b) => a.id.localeCompare(b.id)); // tri stable → déterminisme
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtel/cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Launch self-improving coding agents in the cloud — the Kurtel CLI.",
5
5
  "type": "module",
6
6
  "bin": {