@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.
- package/dist/commands/hook.js +19 -0
- package/dist/commands/impact.js +32 -0
- package/dist/commands/installClaudeCode.js +12 -0
- package/dist/index.js +8 -0
- package/dist/memory/api.js +5 -1
- package/dist/memory/impact.js +152 -0
- package/dist/memory/indexer.js +155 -23
- 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/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,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
|
@@ -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 = {
|
|
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
|
+
}
|
|
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(/^
|
|
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) {
|
|
@@ -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
|
|
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
|
-
|
|
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
|