@kurtel/cli 0.1.12 → 0.1.14

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
@@ -9,7 +9,7 @@ export async function onboardCommand(opts = {}) {
9
9
  const root = repoRoot();
10
10
  // 1. Index structurel — local, déterministe, 0 token.
11
11
  const spin = opts.json ? null : new Spinner("Indexing codebase (local, deterministic)…").start();
12
- const index = buildIndex(root, (n) => {
12
+ const index = await buildIndex(root, (n) => {
13
13
  spin?.update(`Indexing codebase… ${n} files`);
14
14
  });
15
15
  saveIndex(root, index);
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,365 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ const require = createRequire(import.meta.url);
5
+ let initFailed = false;
6
+ let parser = null;
7
+ const languages = new Map(); // grammarName → Language
8
+ const missing = new Set();
9
+ function grammarFor(ext) {
10
+ switch (ext) {
11
+ case ".ts":
12
+ case ".mts":
13
+ case ".cts": return "typescript";
14
+ case ".tsx": return "tsx";
15
+ case ".js":
16
+ case ".jsx":
17
+ case ".mjs":
18
+ case ".cjs": return "javascript"; // jsx couvert par la grammaire JS
19
+ case ".py": return "python";
20
+ default: return null;
21
+ }
22
+ }
23
+ async function getParser(ext) {
24
+ if (initFailed)
25
+ return null;
26
+ const grammar = grammarFor(ext);
27
+ if (!grammar || missing.has(grammar))
28
+ return null;
29
+ try {
30
+ if (!parser) {
31
+ const Parser = require("web-tree-sitter");
32
+ await Parser.init();
33
+ parser = new Parser();
34
+ getParser.ParserClass = Parser;
35
+ }
36
+ let lang = languages.get(grammar);
37
+ if (!lang) {
38
+ const wasmDir = join(dirname(require.resolve("tree-sitter-wasms/package.json")), "out");
39
+ const wasmPath = join(wasmDir, `tree-sitter-${grammar}.wasm`);
40
+ if (!existsSync(wasmPath)) {
41
+ missing.add(grammar);
42
+ return null;
43
+ }
44
+ const Parser = getParser.ParserClass;
45
+ lang = await Parser.Language.load(wasmPath);
46
+ languages.set(grammar, lang);
47
+ }
48
+ return { parser, lang };
49
+ }
50
+ catch {
51
+ initFailed = true; // environnement sans WASM: fallback regex global, une seule tentative
52
+ return null;
53
+ }
54
+ }
55
+ // ── Helpers communs ──────────────────────────────────────────────────────────
56
+ const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options", "all"]);
57
+ const ROUTE_OBJECTS = new Set(["app", "router", "server", "api", "fastify"]);
58
+ const NEST_DECORATORS = new Set(["Get", "Post", "Put", "Patch", "Delete", "Head", "Options"]);
59
+ function stripQuotes(s) {
60
+ return s.replace(/^["'`]|["'`]$/g, "");
61
+ }
62
+ function line(n) { return n.startPosition.row + 1; }
63
+ /** Ligne de la définition nommée englobante (0 = top-level / handler anonyme → "(module)").
64
+ * Renvoie la ligne du NŒUD PORTEUR DU NOM (def enregistrée dans facts.defs), pour
65
+ * que l'attribution par `owner` matche `byLine` côté buildSymbols. */
66
+ function enclosingDefLine(node, defKinds, nameOf) {
67
+ let cur = node.parent;
68
+ while (cur) {
69
+ if (defKinds.has(cur.type)) {
70
+ const name = nameOf(cur);
71
+ if (name) {
72
+ // La def est enregistrée à la ligne du déclarateur/méthode, pas de l'arrow.
73
+ if (cur.type === "arrow_function" || cur.type === "function" || cur.type === "function_expression") {
74
+ const carrier = cur.parent; // variable_declarator | public_field_definition
75
+ return line(carrier ?? cur);
76
+ }
77
+ return line(cur);
78
+ }
79
+ // fonction anonyme (callback inline): on continue de remonter
80
+ }
81
+ cur = cur.parent;
82
+ }
83
+ return 0;
84
+ }
85
+ function emptyFacts(rel, src) {
86
+ return {
87
+ rel, loc: src.split("\n").length, exports: [], importSpecs: [], routes: [],
88
+ defs: [], namedImports: {}, rawCalls: [],
89
+ };
90
+ }
91
+ // ── TypeScript / JavaScript ──────────────────────────────────────────────────
92
+ const JS_DEF_KINDS = new Set(["function_declaration", "generator_function_declaration", "method_definition", "arrow_function", "function", "function_expression"]);
93
+ function jsDefName(n) {
94
+ if (n.type === "function_declaration" || n.type === "generator_function_declaration" || n.type === "method_definition") {
95
+ return n.childForFieldName("name");
96
+ }
97
+ // arrow/function expression: nommée seulement si assignée directement à un déclarateur
98
+ const p = n.parent;
99
+ if (p?.type === "variable_declarator")
100
+ return p.childForFieldName("name");
101
+ if (p?.type === "public_field_definition")
102
+ return p.childForFieldName("name");
103
+ return null;
104
+ }
105
+ function extractJs(rel, src, root) {
106
+ const facts = emptyFacts(rel, src);
107
+ // Imports (statiques + re-exports + require)
108
+ for (const imp of root.descendantsOfType("import_statement")) {
109
+ const source = imp.childForFieldName("source");
110
+ if (!source)
111
+ continue;
112
+ const spec = stripQuotes(source.text);
113
+ facts.importSpecs.push(spec);
114
+ for (const clause of imp.descendantsOfType("import_clause")) {
115
+ for (const child of clause.namedChildren) {
116
+ if (child.type === "identifier") {
117
+ facts.namedImports[child.text] = spec; // default import
118
+ }
119
+ else if (child.type === "namespace_import") {
120
+ const id = child.descendantsOfType("identifier")[0]; // import * as utils
121
+ if (id)
122
+ facts.namedImports[id.text] = spec;
123
+ }
124
+ else if (child.type === "named_imports") {
125
+ for (const isp of child.descendantsOfType("import_specifier")) {
126
+ // local = alias si présent, sinon name. On ignore les imports de type.
127
+ if (isp.children.some((c) => c.type === "type"))
128
+ continue;
129
+ const nameNode = isp.childForFieldName("name");
130
+ const aliasNode = isp.childForFieldName("alias");
131
+ const local = (aliasNode ?? nameNode)?.text;
132
+ const orig = nameNode?.text;
133
+ if (local && orig)
134
+ facts.namedImports[local] = local === orig ? spec : { spec, orig };
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ for (const exp of root.descendantsOfType("export_statement")) {
141
+ const source = exp.childForFieldName("source");
142
+ if (source)
143
+ facts.importSpecs.push(stripQuotes(source.text)); // re-export = arête
144
+ const decl = exp.childForFieldName("declaration");
145
+ const name = decl?.childForFieldName("name");
146
+ if (name)
147
+ facts.exports.push(name.text);
148
+ for (const spec of exp.descendantsOfType("export_specifier")) {
149
+ const n = (spec.childForFieldName("alias") ?? spec.childForFieldName("name"))?.text;
150
+ if (n)
151
+ facts.exports.push(n);
152
+ }
153
+ }
154
+ // Définitions
155
+ for (const fn of root.descendantsOfType(["function_declaration", "generator_function_declaration"])) {
156
+ const name = fn.childForFieldName("name");
157
+ if (name)
158
+ facts.defs.push({ name: name.text, line: line(fn) });
159
+ }
160
+ for (const decl of root.descendantsOfType("variable_declarator")) {
161
+ const value = decl.childForFieldName("value");
162
+ const name = decl.childForFieldName("name");
163
+ if (name?.type === "identifier" && value && (value.type === "arrow_function" || value.type === "function" || value.type === "function_expression")) {
164
+ facts.defs.push({ name: name.text, line: line(decl) });
165
+ }
166
+ }
167
+ for (const m of root.descendantsOfType("method_definition")) {
168
+ const name = m.childForFieldName("name");
169
+ if (name && name.text !== "constructor")
170
+ facts.defs.push({ name: name.text, line: line(m) });
171
+ }
172
+ for (const f of root.descendantsOfType("public_field_definition")) {
173
+ const value = f.childForFieldName("value");
174
+ const name = f.childForFieldName("name");
175
+ if (name && value?.type === "arrow_function")
176
+ facts.defs.push({ name: name.text, line: line(f) });
177
+ }
178
+ for (const c of root.descendantsOfType("class_declaration")) {
179
+ const name = c.childForFieldName("name");
180
+ if (name)
181
+ facts.exports.push(name.text);
182
+ }
183
+ // Appels + routes
184
+ for (const call of root.descendantsOfType("call_expression")) {
185
+ const fn = call.childForFieldName("function");
186
+ if (!fn)
187
+ continue;
188
+ if (fn.type === "identifier") {
189
+ if (fn.text === "require") {
190
+ const arg = call.childForFieldName("arguments")?.namedChildren[0];
191
+ if (arg && (arg.type === "string" || arg.type === "template_string"))
192
+ facts.importSpecs.push(stripQuotes(arg.text));
193
+ continue;
194
+ }
195
+ facts.rawCalls.push({ name: fn.text, line: line(call), owner: enclosingDefLine(call, JS_DEF_KINDS, jsDefName) });
196
+ }
197
+ else if (fn.type === "member_expression") {
198
+ const obj = fn.childForFieldName("object");
199
+ const prop = fn.childForFieldName("property");
200
+ if (!obj || !prop)
201
+ continue;
202
+ // Route express-like: app.get("/x", ...)
203
+ if (obj.type === "identifier" && ROUTE_OBJECTS.has(obj.text) && HTTP_METHODS.has(prop.text)) {
204
+ const arg = call.childForFieldName("arguments")?.namedChildren[0];
205
+ if (arg && (arg.type === "string" || arg.type === "template_string")) {
206
+ facts.routes.push({ method: prop.text.toUpperCase(), path: stripQuotes(arg.text), file: rel, line: line(call), framework: "express-like" });
207
+ }
208
+ }
209
+ // Appel qualifié résoluble: ImportéOuClasse.method(...)
210
+ if (obj.type === "identifier") {
211
+ facts.rawCalls.push({ name: obj.text + ".", line: line(call), owner: enclosingDefLine(call, JS_DEF_KINDS, jsDefName) });
212
+ }
213
+ }
214
+ }
215
+ // Décorateurs Nest: @Get("/x")
216
+ for (const dec of root.descendantsOfType("decorator")) {
217
+ const call = dec.namedChildren[0];
218
+ if (call?.type !== "call_expression")
219
+ continue;
220
+ const fn = call.childForFieldName("function");
221
+ if (fn?.type === "identifier" && NEST_DECORATORS.has(fn.text)) {
222
+ const arg = call.childForFieldName("arguments")?.namedChildren[0];
223
+ facts.routes.push({
224
+ method: fn.text.toUpperCase(),
225
+ path: arg && (arg.type === "string") ? stripQuotes(arg.text) : "",
226
+ file: rel, line: line(dec), framework: "nest",
227
+ });
228
+ }
229
+ }
230
+ return facts;
231
+ }
232
+ // ── Python ───────────────────────────────────────────────────────────────────
233
+ const PY_DEF_KINDS = new Set(["function_definition"]);
234
+ const pyDefName = (n) => n.childForFieldName("name");
235
+ /** "from .sub import x" → spec JS-style "./sub" résoluble par le résolveur existant. */
236
+ function pyRelativeSpec(dots, mod) {
237
+ const up = dots <= 1 ? "./" : "../".repeat(dots - 1);
238
+ return up + mod.replace(/\./g, "/");
239
+ }
240
+ function extractPy(rel, src, root) {
241
+ const facts = emptyFacts(rel, src);
242
+ for (const imp of root.descendantsOfType("import_from_statement")) {
243
+ // forme: from <relative_import|dotted_name> import a, b as c | from . import service
244
+ const modNode = imp.childForFieldName("module_name");
245
+ let spec = "";
246
+ let relative = false;
247
+ if (modNode) {
248
+ if (modNode.type === "relative_import") {
249
+ relative = true;
250
+ const dots = (modNode.text.match(/^\.+/) ?? ["."])[0].length;
251
+ const mod = modNode.text.slice(dots);
252
+ spec = pyRelativeSpec(dots, mod);
253
+ }
254
+ else {
255
+ spec = modNode.text;
256
+ }
257
+ }
258
+ const names = [];
259
+ for (const child of imp.namedChildren) {
260
+ if (child === modNode)
261
+ continue;
262
+ if (child.type === "dotted_name")
263
+ names.push(child.text);
264
+ if (child.type === "aliased_import") {
265
+ const alias = child.childForFieldName("alias");
266
+ if (alias)
267
+ names.push(alias.text);
268
+ }
269
+ }
270
+ if (relative && !spec.replace(/^\.\/|\.\.\//g, "")) {
271
+ // "from . import service" → chaque nom est un module frère
272
+ for (const n of names) {
273
+ const s = pyRelativeSpec(1, n);
274
+ facts.importSpecs.push(s);
275
+ facts.namedImports[n] = s;
276
+ }
277
+ }
278
+ else if (spec) {
279
+ facts.importSpecs.push(spec);
280
+ for (const n of names)
281
+ facts.namedImports[n] = spec;
282
+ }
283
+ }
284
+ for (const imp of root.descendantsOfType("import_statement")) {
285
+ for (const d of imp.descendantsOfType("dotted_name"))
286
+ facts.importSpecs.push(d.text);
287
+ }
288
+ for (const fn of root.descendantsOfType("function_definition")) {
289
+ const name = fn.childForFieldName("name");
290
+ if (name) {
291
+ facts.defs.push({ name: name.text, line: line(fn) });
292
+ facts.exports.push(name.text);
293
+ }
294
+ }
295
+ for (const cls of root.descendantsOfType("class_definition")) {
296
+ const name = cls.childForFieldName("name");
297
+ if (name)
298
+ facts.exports.push(name.text);
299
+ }
300
+ for (const call of root.descendantsOfType("call")) {
301
+ const fn = call.childForFieldName("function");
302
+ if (!fn)
303
+ continue;
304
+ if (fn.type === "identifier") {
305
+ facts.rawCalls.push({ name: fn.text, line: line(call), owner: enclosingDefLine(call, PY_DEF_KINDS, pyDefName) });
306
+ }
307
+ else if (fn.type === "attribute") {
308
+ const obj = fn.childForFieldName("object");
309
+ const attr = fn.childForFieldName("attribute");
310
+ if (obj?.type === "identifier" && attr) {
311
+ // Route FastAPI/Flask: app.get("/x") en position de décorateur
312
+ if (call.parent?.type === "decorator" && (HTTP_METHODS.has(attr.text) || attr.text === "route")) {
313
+ const arg = call.childForFieldName("arguments")?.namedChildren[0];
314
+ if (arg?.type === "string") {
315
+ facts.routes.push({
316
+ method: attr.text === "route" ? "*" : attr.text.toUpperCase(),
317
+ path: stripQuotes(arg.text), file: rel, line: line(call), framework: "python",
318
+ });
319
+ }
320
+ }
321
+ facts.rawCalls.push({ name: obj.text + ".", line: line(call), owner: enclosingDefLine(call, PY_DEF_KINDS, pyDefName) });
322
+ }
323
+ }
324
+ }
325
+ return facts;
326
+ }
327
+ // ── Point d'entrée ───────────────────────────────────────────────────────────
328
+ const MAX_DEFS = 40;
329
+ const MAX_CALLS = 1000;
330
+ /** Extraction AST. Retourne null si tree-sitter indisponible pour ce fichier → fallback regex. */
331
+ export async function extractFileAst(rel, src, ext) {
332
+ const ctx = await getParser(ext);
333
+ if (!ctx)
334
+ return null;
335
+ let tree = null;
336
+ try {
337
+ ctx.parser.setLanguage(ctx.lang);
338
+ tree = ctx.parser.parse(src);
339
+ const facts = ext === ".py" ? extractPy(rel, src, tree.rootNode) : extractJs(rel, src, tree.rootNode);
340
+ // Bornes + dédupe defs (première occurrence par nom), tri par ligne — comme la voie regex.
341
+ const seen = new Set();
342
+ facts.defs = facts.defs
343
+ .sort((a, b) => a.line - b.line)
344
+ .filter((d) => (seen.has(d.name) ? false : (seen.add(d.name), true)))
345
+ .slice(0, MAX_DEFS);
346
+ facts.rawCalls = facts.rawCalls.slice(0, MAX_CALLS).sort((a, b) => a.line - b.line);
347
+ facts.routes = facts.routes.slice(0, 200);
348
+ facts.exports = [...new Set(facts.exports)];
349
+ return facts;
350
+ }
351
+ catch {
352
+ return null; // fichier illisible par l'AST → regex
353
+ }
354
+ finally {
355
+ tree?.delete(); // mémoire WASM: indispensable sur des milliers de fichiers
356
+ }
357
+ }
358
+ /** Routes par convention de fichier Next.js — indépendant du parseur, partagé. */
359
+ export function nextRouteFor(rel) {
360
+ const nm = rel.replace(/\\/g, "/").match(/(^|\/)(pages|app)\/(.+?)\/?(route|page|index)?\.(ts|tsx|js|jsx)$/);
361
+ if (!nm)
362
+ return null;
363
+ const urlPath = "/" + nm[3].replace(/\[(\w+)\]/g, ":$1").replace(/\/index$/, "");
364
+ return { method: "*", path: urlPath, file: rel, line: 1, framework: "next" };
365
+ }
@@ -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
+ }
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { join, relative, extname, dirname } from "node:path";
3
3
  import { execSync } from "node:child_process";
4
4
  import { repoFullName } from "./store.js";
5
+ import { extractFileAst, nextRouteFor } from "./ast.js";
5
6
  // ─────────────────────────────────────────────────────────────────────────────
6
7
  // Indexeur structurel — 100% local, 0 token, déterministe (même input → même
7
8
  // output). Heuristiques regex pragmatiques pour TS/JS/Python ; le point
@@ -40,26 +41,105 @@ const PY_ROUTE_PATTERNS = [
40
41
  path: (m) => m[2],
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
+ }
51
78
  }
52
- for (const m of src.matchAll(/^(?:def|class)\s+(\w+)/gm))
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) });
82
+ }
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) {
@@ -74,16 +154,10 @@ function extractFile(root, rel, src) {
74
154
  });
75
155
  }
76
156
  }
77
- // Routes par convention de fichier (Next.js app/pages router).
78
- const nm = rel.replace(/\\/g, "/").match(NEXT_ROUTE_FILE);
79
- if (nm) {
80
- const urlPath = "/" + nm[3].replace(/\[(\w+)\]/g, ":$1").replace(/\/index$/, "");
81
- facts.routes.push({ method: "*", path: urlPath, file: rel, line: 1, framework: "next" });
82
- }
83
157
  return facts;
84
158
  }
85
159
  // ── Résolution d'imports internes (TS/JS relatifs + Python par module path) ──
86
- function resolveImports(all) {
160
+ function buildResolver(all) {
87
161
  const byNoExt = new Map();
88
162
  for (const rel of all.keys()) {
89
163
  const noExt = rel.replace(/\.(ts|tsx|js|jsx|mjs|cjs|py)$/, "");
@@ -93,25 +167,25 @@ function resolveImports(all) {
93
167
  if (noExt.endsWith("/__init__"))
94
168
  byNoExt.set(noExt.slice(0, -"/__init__".length), rel);
95
169
  }
170
+ return (rel, spec) => {
171
+ if (spec.startsWith(".")) {
172
+ const base = join(dirname(rel), spec).replace(/\\/g, "/").replace(/\.(js|ts|tsx|jsx)$/, "");
173
+ return byNoExt.get(base);
174
+ }
175
+ if (spec.includes(".") && extname(rel) === ".py") {
176
+ return byNoExt.get(spec.replace(/\./g, "/"));
177
+ }
178
+ const cleaned = spec.replace(/^@\//, "src/").replace(/^~\//, "src/");
179
+ return byNoExt.get(cleaned);
180
+ };
181
+ }
182
+ function resolveImports(all) {
183
+ const resolve = buildResolver(all);
96
184
  const resolved = new Map();
97
185
  for (const [rel, facts] of all) {
98
186
  const targets = [];
99
187
  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
- }
188
+ const candidate = resolve(rel, spec);
115
189
  if (candidate && candidate !== rel)
116
190
  targets.push(candidate);
117
191
  }
@@ -119,6 +193,64 @@ function resolveImports(all) {
119
193
  }
120
194
  return resolved;
121
195
  }
196
+ // ── Graphe d'appels: résolution des sites d'appel en arêtes symbole→symbole ──
197
+ const MAX_SYMBOLS_PER_FILE = 25;
198
+ const MAX_CALLS_PER_SYMBOL = 15;
199
+ function buildSymbols(all, resolve) {
200
+ const defNames = new Map();
201
+ const exportNames = new Map();
202
+ for (const [rel, f] of all) {
203
+ defNames.set(rel, new Set(f.defs.map((d) => d.name)));
204
+ exportNames.set(rel, new Set(f.exports));
205
+ }
206
+ const out = new Map();
207
+ for (const [rel, f] of all) {
208
+ const locals = defNames.get(rel);
209
+ const symbols = f.defs.slice(0, MAX_SYMBOLS_PER_FILE).map((d) => ({ ...d, calls: [] }));
210
+ // Pseudo-symbole pour les appels hors fonction (handlers de routes inline,
211
+ // initialisation module) — c'est là que vivent les arêtes des fichiers routes.
212
+ const topLevel = { name: "(module)", line: 0, calls: [] };
213
+ const resolveCall = (raw) => {
214
+ const qualified = raw.endsWith(".");
215
+ const name = qualified ? raw.slice(0, -1) : raw;
216
+ if (!qualified && locals.has(name))
217
+ return `${rel}::${name}`;
218
+ const entry = f.namedImports[name];
219
+ if (entry) {
220
+ const spec = typeof entry === "string" ? entry : entry.spec;
221
+ const orig = typeof entry === "string" ? name : entry.orig; // alias → nom exporté côté cible
222
+ const file = resolve(rel, spec);
223
+ if (file && file !== rel && (defNames.get(file)?.has(orig) || exportNames.get(file)?.has(orig))) {
224
+ return `${file}::${orig}`;
225
+ }
226
+ }
227
+ return null;
228
+ };
229
+ const byLine = new Map(symbols.map((s) => [s.line, s]));
230
+ for (const call of f.rawCalls) {
231
+ let caller = topLevel;
232
+ if (call.owner !== undefined) {
233
+ caller = call.owner === 0 ? topLevel : (byLine.get(call.owner) ?? topLevel);
234
+ }
235
+ else {
236
+ for (const s of symbols) {
237
+ if (s.line <= call.line)
238
+ caller = s;
239
+ else
240
+ break;
241
+ }
242
+ }
243
+ if (caller.name === call.name || caller.calls.length >= MAX_CALLS_PER_SYMBOL)
244
+ continue;
245
+ const target = resolveCall(call.name);
246
+ if (target && target !== `${rel}::${caller.name}` && !caller.calls.includes(target)) {
247
+ caller.calls.push(target);
248
+ }
249
+ }
250
+ out.set(rel, topLevel.calls.length ? [topLevel, ...symbols] : symbols);
251
+ }
252
+ return out;
253
+ }
122
254
  // ── Parcours ────────────────────────────────────────────────────────────────
123
255
  function* walk(dir, root) {
124
256
  let entries;
@@ -151,19 +283,31 @@ function* walk(dir, root) {
151
283
  }
152
284
  }
153
285
  // ── Construction de l'index ─────────────────────────────────────────────────
154
- export function buildIndex(root, onProgress) {
286
+ export async function buildIndex(root, onProgress) {
155
287
  const files = new Map();
156
288
  let n = 0;
289
+ let regexFallbacks = 0;
157
290
  for (const rel of walk(root, root)) {
158
291
  try {
159
292
  const src = readFileSync(join(root, rel), "utf8");
160
- files.set(rel, extractFile(root, rel, src));
293
+ const ast = await extractFileAst(rel, src, extname(rel));
294
+ if (ast) {
295
+ files.set(rel, ast);
296
+ }
297
+ else {
298
+ files.set(rel, extractFile(root, rel, src));
299
+ regexFallbacks++;
300
+ }
301
+ const next = nextRouteFor(rel);
302
+ if (next)
303
+ files.get(rel).routes.push(next);
161
304
  if (onProgress && ++n % 50 === 0)
162
305
  onProgress(n);
163
306
  }
164
307
  catch { /* binaire/illisible: skip */ }
165
308
  }
166
309
  const imports = resolveImports(files);
310
+ const symbolMap = buildSymbols(files, buildResolver(files));
167
311
  const inDegree = new Map();
168
312
  for (const targets of imports.values()) {
169
313
  for (const t of targets)
@@ -178,6 +322,7 @@ export function buildIndex(root, onProgress) {
178
322
  imports: out,
179
323
  loc: f.loc,
180
324
  degree: out.length + (inDegree.get(rel) ?? 0),
325
+ symbols: symbolMap.get(rel) ?? [],
181
326
  };
182
327
  })
183
328
  .sort((a, b) => a.id.localeCompare(b.id)); // tri stable → déterminisme
@@ -206,6 +351,7 @@ export function buildIndex(root, onProgress) {
206
351
  catch { /* pas un repo git */ }
207
352
  return {
208
353
  version: 1,
354
+ parser: { engine: regexFallbacks === files.size ? "regex" : "tree-sitter", regex_fallbacks: regexFallbacks },
209
355
  repo: repoFullName(root),
210
356
  commit,
211
357
  generated_at: new Date().toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtel/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Launch self-improving coding agents in the cloud — the Kurtel CLI.",
5
5
  "type": "module",
6
6
  "bin": {