@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.
- package/dist/commands/hook.js +19 -0
- package/dist/commands/impact.js +32 -0
- package/dist/commands/installClaudeCode.js +12 -0
- package/dist/commands/onboard.js +1 -1
- package/dist/index.js +8 -0
- package/dist/memory/api.js +5 -1
- package/dist/memory/ast.js +365 -0
- package/dist/memory/impact.js +152 -0
- package/dist/memory/indexer.js +178 -32
- package/package.json +1 -1
package/dist/commands/hook.js
CHANGED
|
@@ -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/commands/onboard.js
CHANGED
|
@@ -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)")
|
package/dist/memory/api.js
CHANGED
|
@@ -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) => ({
|
|
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
|
+
}
|
package/dist/memory/indexer.js
CHANGED
|
@@ -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
|
|
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 = {
|
|
62
|
+
const facts = {
|
|
63
|
+
rel, loc: lines.length, exports: [], importSpecs: [], routes: [],
|
|
64
|
+
defs: [], namedImports: {}, rawCalls: [],
|
|
65
|
+
};
|
|
47
66
|
const ext = extname(rel);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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(
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|