brito-cli 0.0.0-stage → 0.3.2
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/.brito/agents/reviewer.md +7 -0
- package/LICENSE +75 -0
- package/README.md +166 -2
- package/README.pt-BR.md +167 -0
- package/dist/agent/agents.js +65 -0
- package/dist/agent/claude-cli.js +216 -0
- package/dist/agent/context.js +40 -0
- package/dist/agent/loop.js +125 -0
- package/dist/agent/prompt.js +54 -0
- package/dist/cli.js +568 -0
- package/dist/graph/aliases.js +196 -0
- package/dist/graph/indexer.js +438 -0
- package/dist/graph/langs.js +526 -0
- package/dist/graph/sfc.js +52 -0
- package/dist/graph/store.js +96 -0
- package/dist/mcp/server.js +98 -0
- package/dist/model.js +136 -0
- package/dist/permissions.js +39 -0
- package/dist/repl.js +297 -0
- package/dist/skills/consolidate.js +76 -0
- package/dist/skills/describe.js +14 -0
- package/dist/skills/journal.js +72 -0
- package/dist/skills/learn-cli.js +55 -0
- package/dist/skills/learn.js +217 -0
- package/dist/skills/store.js +234 -0
- package/dist/tools/bash.js +17 -0
- package/dist/tools/delegate.js +61 -0
- package/dist/tools/fs.js +91 -0
- package/dist/tools/graph.js +117 -0
- package/dist/tools/plan.js +17 -0
- package/dist/tools/registry.js +56 -0
- package/dist/tools/skills.js +58 -0
- package/dist/ui/banner.js +138 -0
- package/dist/ui/dock.js +616 -0
- package/dist/ui/theme.js +63 -0
- package/dist/ui/ui.js +426 -0
- package/dist/ui/web-art.js +131 -0
- package/dist/util/diff.js +36 -0
- package/dist/util/frontmatter.js +16 -0
- package/package.json +66 -4
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Aliases de importação: `import x from "$lib/a"`, `"@/a"`, `"#interno/a"`. Sem resolvê-los, esses arquivos ficam
|
|
2
|
+
// soltos no grafo (sem aresta IMPORTS), e a análise de impacto subestima quem depende de quem.
|
|
3
|
+
//
|
|
4
|
+
// Fontes, na ordem do que cada ferramenta faz:
|
|
5
|
+
// 1. tsconfig.json / jsconfig.json: compilerOptions.paths e baseUrl (seguindo `extends` relativo, como o TypeScript);
|
|
6
|
+
// 2. package.json "imports" (subpath imports do Node, chaves "#...");
|
|
7
|
+
// 3. `$lib` → src/lib, o alias fixo do SvelteKit, quando não declarado (o tsconfig dele é gerado e pode não existir).
|
|
8
|
+
// A configuração vale por arquivo: sobe do arquivo até a raiz e usa a pasta mais próxima com tsconfig/jsconfig/package.json
|
|
9
|
+
// (monorepos com um tsconfig por app). Só lê arquivos do projeto; nunca executa nada.
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
const px = path.posix;
|
|
14
|
+
/** JSON com comentários (de linha e de bloco) e vírgula sobrando: o formato do tsconfig. */
|
|
15
|
+
export function parseJsonc(text) {
|
|
16
|
+
let out = "";
|
|
17
|
+
let i = 0;
|
|
18
|
+
let inStr = false;
|
|
19
|
+
while (i < text.length) {
|
|
20
|
+
const c = text[i], n = text[i + 1];
|
|
21
|
+
if (inStr) {
|
|
22
|
+
out += c;
|
|
23
|
+
if (c === "\\") {
|
|
24
|
+
out += n ?? "";
|
|
25
|
+
i += 2;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (c === '"')
|
|
29
|
+
inStr = false;
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
else if (c === '"') {
|
|
33
|
+
inStr = true;
|
|
34
|
+
out += c;
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
else if (c === "/" && n === "/") {
|
|
38
|
+
while (i < text.length && text[i] !== "\n")
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
else if (c === "/" && n === "*") {
|
|
42
|
+
i += 2;
|
|
43
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
44
|
+
i++;
|
|
45
|
+
i += 2;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
out += c;
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
53
|
+
}
|
|
54
|
+
function readJsonc(file) {
|
|
55
|
+
try {
|
|
56
|
+
return parseJsonc(fs.readFileSync(file, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** baseUrl/paths efetivos de um tsconfig: o que o arquivo declara vence o que vem de `extends` (como no TypeScript). */
|
|
63
|
+
function readTsconfig(root, relFile, depth = 0) {
|
|
64
|
+
if (depth > 5)
|
|
65
|
+
return {};
|
|
66
|
+
const json = readJsonc(path.join(root, relFile));
|
|
67
|
+
if (!json || typeof json !== "object")
|
|
68
|
+
return {};
|
|
69
|
+
const dir = px.dirname(relFile) === "." ? "" : px.dirname(relFile);
|
|
70
|
+
let out = {};
|
|
71
|
+
const ext = Array.isArray(json.extends) ? json.extends : json.extends ? [json.extends] : [];
|
|
72
|
+
for (const e of ext) {
|
|
73
|
+
// extends por pacote ("@tsconfig/node20") fica de fora: só configs do próprio projeto.
|
|
74
|
+
if (typeof e !== "string" || !e.startsWith("."))
|
|
75
|
+
continue;
|
|
76
|
+
const target = px.normalize(px.join(dir, e.endsWith(".json") ? e : e + ".json"));
|
|
77
|
+
out = { ...out, ...readTsconfig(root, target, depth + 1) };
|
|
78
|
+
}
|
|
79
|
+
const co = json.compilerOptions;
|
|
80
|
+
if (co && typeof co.baseUrl === "string")
|
|
81
|
+
out.baseUrl = { dir, value: co.baseUrl };
|
|
82
|
+
if (co && co.paths && typeof co.paths === "object")
|
|
83
|
+
out.paths = { dir, value: co.paths };
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
const inside = (p) => p !== ".." && !p.startsWith("../") && !px.isAbsolute(p);
|
|
87
|
+
const clean = (p) => { const n = px.normalize(p); return n === "." ? "" : n; };
|
|
88
|
+
function rule(pattern, targets) {
|
|
89
|
+
const ts = targets.filter((t) => inside(t));
|
|
90
|
+
if (!ts.length)
|
|
91
|
+
return null;
|
|
92
|
+
const star = pattern.indexOf("*");
|
|
93
|
+
return star < 0 ? { prefix: pattern, suffix: "", wildcard: false, targets: ts } : { prefix: pattern.slice(0, star), suffix: pattern.slice(star + 1), wildcard: true, targets: ts };
|
|
94
|
+
}
|
|
95
|
+
/** Subpath imports do Node: { "imports": { "#lib/*": "./src/lib/*" } }, inclusive com condições ({ "default": "..." }). */
|
|
96
|
+
function packageImports(root, dir) {
|
|
97
|
+
const imports = readJsonc(path.join(root, dir, "package.json"))?.imports;
|
|
98
|
+
if (!imports || typeof imports !== "object")
|
|
99
|
+
return [];
|
|
100
|
+
// Como no Node: vale a primeira condição aceita, na ordem em que as chaves aparecem no objeto.
|
|
101
|
+
const CONDITIONS = new Set(["import", "node", "default", "require", "types"]);
|
|
102
|
+
const pick = (v) => {
|
|
103
|
+
if (typeof v === "string")
|
|
104
|
+
return v;
|
|
105
|
+
if (v && typeof v === "object")
|
|
106
|
+
for (const [k, sub] of Object.entries(v))
|
|
107
|
+
if (CONDITIONS.has(k)) {
|
|
108
|
+
const r = pick(sub);
|
|
109
|
+
if (r)
|
|
110
|
+
return r;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const [key, val] of Object.entries(imports)) {
|
|
116
|
+
const t = pick(val);
|
|
117
|
+
if (!key.startsWith("#") || !t || !t.startsWith("."))
|
|
118
|
+
continue;
|
|
119
|
+
const r = rule(key, [clean(px.join(dir, t))]);
|
|
120
|
+
if (r)
|
|
121
|
+
out.push(r);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function build(root, dir) {
|
|
126
|
+
const rules = [];
|
|
127
|
+
let baseUrl = null;
|
|
128
|
+
const cfgFile = ["tsconfig.json", "jsconfig.json"].map((f) => px.join(dir, f)).find((f) => fs.existsSync(path.join(root, f)));
|
|
129
|
+
if (cfgFile) {
|
|
130
|
+
const ts = readTsconfig(root, cfgFile);
|
|
131
|
+
if (ts.baseUrl)
|
|
132
|
+
baseUrl = clean(px.join(ts.baseUrl.dir, ts.baseUrl.value));
|
|
133
|
+
if (ts.paths) {
|
|
134
|
+
// `paths` é relativo ao baseUrl, se houver; senão, à pasta do tsconfig que o declarou.
|
|
135
|
+
const from = baseUrl ?? ts.paths.dir;
|
|
136
|
+
for (const [pattern, targets] of Object.entries(ts.paths.value)) {
|
|
137
|
+
if (!Array.isArray(targets))
|
|
138
|
+
continue;
|
|
139
|
+
const r = rule(pattern, targets.filter((t) => typeof t === "string").map((t) => clean(px.join(from, t))));
|
|
140
|
+
if (r)
|
|
141
|
+
rules.push(r);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
rules.push(...packageImports(root, dir));
|
|
146
|
+
if (baseUrl !== null && !inside(baseUrl === "" ? "." : baseUrl))
|
|
147
|
+
baseUrl = null;
|
|
148
|
+
// SvelteKit: `$lib` sempre aponta para src/lib. Só entra se o projeto não o declarou.
|
|
149
|
+
if (!rules.some((r) => r.prefix === "$lib/" || r.prefix === "$lib")) {
|
|
150
|
+
const r = rule("$lib/*", [clean(px.join(dir, "src/lib/*"))]);
|
|
151
|
+
if (r)
|
|
152
|
+
rules.push(r);
|
|
153
|
+
}
|
|
154
|
+
const signature = crypto.createHash("sha1").update(JSON.stringify({ rules, baseUrl })).digest("hex").slice(0, 12);
|
|
155
|
+
return { rules, baseUrl, signature };
|
|
156
|
+
}
|
|
157
|
+
/** Configuração de aliases por arquivo (a mais próxima, subindo até a raiz), com cache por pasta. */
|
|
158
|
+
export class Aliases {
|
|
159
|
+
root;
|
|
160
|
+
byDir = new Map();
|
|
161
|
+
cfg = new Map();
|
|
162
|
+
constructor(root) {
|
|
163
|
+
this.root = root;
|
|
164
|
+
}
|
|
165
|
+
forFile(rel) {
|
|
166
|
+
const fileDir = px.dirname(rel) === "." ? "" : px.dirname(rel);
|
|
167
|
+
const hit = this.byDir.get(fileDir);
|
|
168
|
+
if (hit)
|
|
169
|
+
return hit;
|
|
170
|
+
let dir = fileDir;
|
|
171
|
+
for (;;) {
|
|
172
|
+
const has = ["tsconfig.json", "jsconfig.json", "package.json"].some((f) => fs.existsSync(path.join(this.root, dir, f)));
|
|
173
|
+
if (has || dir === "")
|
|
174
|
+
break;
|
|
175
|
+
dir = px.dirname(dir) === "." ? "" : px.dirname(dir);
|
|
176
|
+
}
|
|
177
|
+
const set = this.cfg.get(dir) ?? this.cfg.set(dir, build(this.root, dir)).get(dir);
|
|
178
|
+
this.byDir.set(fileDir, set);
|
|
179
|
+
return set;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Caminhos candidatos (relativos à raiz, sem extensão resolvida) para um import não relativo. Mais específico primeiro. */
|
|
183
|
+
export function aliasCandidates(set, spec) {
|
|
184
|
+
const out = [];
|
|
185
|
+
const matches = set.rules
|
|
186
|
+
.filter((r) => (r.wildcard ? spec.length >= r.prefix.length + r.suffix.length && spec.startsWith(r.prefix) && spec.endsWith(r.suffix) : spec === r.prefix))
|
|
187
|
+
.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
188
|
+
for (const r of matches) {
|
|
189
|
+
const captured = r.wildcard ? spec.slice(r.prefix.length, spec.length - r.suffix.length) : "";
|
|
190
|
+
for (const t of r.targets)
|
|
191
|
+
out.push(clean(t.replace("*", captured)));
|
|
192
|
+
}
|
|
193
|
+
if (set.baseUrl !== null && !spec.startsWith("."))
|
|
194
|
+
out.push(clean(px.join(set.baseUrl, spec)));
|
|
195
|
+
return out.filter(inside);
|
|
196
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import Parser from "web-tree-sitter";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { SFC_EXT, componentName, splitSfc, templateCalleeNames } from "./sfc.js";
|
|
9
|
+
import { Aliases, aliasCandidates } from "./aliases.js";
|
|
10
|
+
import { EXTRA_EXT, ProjectIndex, extractLang, isExtraLang } from "./langs.js";
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const WASM_DIR = path.dirname(require.resolve("tree-sitter-wasms/package.json")) + "/out";
|
|
13
|
+
const EXT = {
|
|
14
|
+
".ts": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
15
|
+
".tsx": "tsx", ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
16
|
+
".py": "python",
|
|
17
|
+
...EXTRA_EXT,
|
|
18
|
+
};
|
|
19
|
+
/** Tudo que o grafo indexa: código em EXT e os scripts de componentes (.svelte/.vue/.astro). Usado também nas instruções do MCP. */
|
|
20
|
+
export const INDEXED_EXTENSIONS = [...Object.keys(EXT), ...SFC_EXT];
|
|
21
|
+
const parsers = new Map();
|
|
22
|
+
async function getParser(lang) {
|
|
23
|
+
let p = parsers.get(lang);
|
|
24
|
+
if (p)
|
|
25
|
+
return p;
|
|
26
|
+
await Parser.init();
|
|
27
|
+
p = new Parser();
|
|
28
|
+
const wasm = lang === "javascript" ? "javascript" : lang;
|
|
29
|
+
p.setLanguage(await Parser.Language.load(`${WASM_DIR}/tree-sitter-${wasm}.wasm`));
|
|
30
|
+
parsers.set(lang, p);
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
const FN_VALUES = new Set(["arrow_function", "function_expression", "function", "generator_function"]);
|
|
34
|
+
function extract(root, lang, file) {
|
|
35
|
+
const syms = [];
|
|
36
|
+
const calls = [];
|
|
37
|
+
const imports = [];
|
|
38
|
+
const bindings = [];
|
|
39
|
+
const isPy = lang === "python";
|
|
40
|
+
const mk = (name, kind, n) => {
|
|
41
|
+
const s = { id: `${file}::${name}:${n.startPosition.row + 1}`, name, kind, file, startLine: n.startPosition.row + 1, endLine: n.endPosition.row + 1 };
|
|
42
|
+
syms.push(s);
|
|
43
|
+
return s;
|
|
44
|
+
};
|
|
45
|
+
const calleeOf = (fn) => {
|
|
46
|
+
if (!fn)
|
|
47
|
+
return null;
|
|
48
|
+
if (fn.type === "identifier")
|
|
49
|
+
return { name: fn.text, obj: "" };
|
|
50
|
+
if (fn.type === "member_expression" || fn.type === "attribute") {
|
|
51
|
+
const name = fn.childForFieldName(fn.type === "attribute" ? "attribute" : "property")?.text;
|
|
52
|
+
const o = fn.childForFieldName("object");
|
|
53
|
+
if (!name)
|
|
54
|
+
return null;
|
|
55
|
+
return { name, obj: o && (o.type === "identifier" || o.type === "this") ? o.text : "?" }; // "?" = membro de valor não nomeado
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
};
|
|
59
|
+
const walk = (n, owner, cls) => {
|
|
60
|
+
let nextOwner = owner;
|
|
61
|
+
let nextCls = cls;
|
|
62
|
+
switch (n.type) {
|
|
63
|
+
case "function_declaration":
|
|
64
|
+
case "generator_function_declaration":
|
|
65
|
+
case "function_definition": {
|
|
66
|
+
const name = n.childForFieldName("name")?.text;
|
|
67
|
+
if (name)
|
|
68
|
+
nextOwner = mk(cls ? `${cls}.${name}` : name, cls ? "method" : "function", n);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "method_definition": {
|
|
72
|
+
const name = n.childForFieldName("name")?.text;
|
|
73
|
+
if (name)
|
|
74
|
+
nextOwner = mk(cls ? `${cls}.${name}` : name, "method", n);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case "class_declaration":
|
|
78
|
+
case "class_definition": {
|
|
79
|
+
const name = n.childForFieldName("name")?.text;
|
|
80
|
+
if (name) {
|
|
81
|
+
nextOwner = mk(name, "class", n);
|
|
82
|
+
nextCls = name;
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case "variable_declarator": {
|
|
87
|
+
const name = n.childForFieldName("name");
|
|
88
|
+
const val = n.childForFieldName("value");
|
|
89
|
+
if (name?.type === "identifier" && val && FN_VALUES.has(val.type))
|
|
90
|
+
nextOwner = mk(name.text, "function", n);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "interface_declaration":
|
|
94
|
+
case "type_alias_declaration":
|
|
95
|
+
case "enum_declaration": {
|
|
96
|
+
const name = n.childForFieldName("name")?.text;
|
|
97
|
+
if (name)
|
|
98
|
+
mk(name, n.type.split("_")[0], n);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "call_expression":
|
|
102
|
+
case "call": {
|
|
103
|
+
const c = calleeOf(n.childForFieldName("function"));
|
|
104
|
+
if (c)
|
|
105
|
+
calls.push({ callerId: owner?.id ?? null, callee: c.name, obj: c.obj, file });
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "import_statement": {
|
|
109
|
+
if (isPy) {
|
|
110
|
+
const m = n.text.match(/import\s+([\w.]+)/);
|
|
111
|
+
if (m)
|
|
112
|
+
imports.push(m[1]);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const src = n.childForFieldName("source")?.text.replace(/['"]/g, "");
|
|
116
|
+
if (src) {
|
|
117
|
+
imports.push(src);
|
|
118
|
+
const clause = n.namedChildren.find((c) => c.type === "import_clause");
|
|
119
|
+
for (const c of clause?.namedChildren ?? []) {
|
|
120
|
+
if (c.type === "identifier")
|
|
121
|
+
bindings.push([c.text, src, "default"]);
|
|
122
|
+
else if (c.type === "namespace_import") {
|
|
123
|
+
const id = c.namedChildren.find((x) => x.type === "identifier");
|
|
124
|
+
if (id)
|
|
125
|
+
bindings.push([id.text, src, "*"]);
|
|
126
|
+
}
|
|
127
|
+
else if (c.type === "named_imports") {
|
|
128
|
+
for (const sp of c.namedChildren) {
|
|
129
|
+
const nm = sp.type === "import_specifier" ? sp.childForFieldName("name")?.text : null;
|
|
130
|
+
if (nm)
|
|
131
|
+
bindings.push([sp.childForFieldName("alias")?.text ?? nm, src, nm]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case "import_from_statement": {
|
|
140
|
+
const m = n.text.match(/from\s+([.\w]+)\s+import\s+\(?([\s\S]+?)\)?\s*$/);
|
|
141
|
+
if (m) {
|
|
142
|
+
imports.push(m[1]);
|
|
143
|
+
for (const part of m[2].split(",")) {
|
|
144
|
+
const [nm, al] = part.trim().split(/\s+as\s+/);
|
|
145
|
+
if (nm && nm !== "*")
|
|
146
|
+
bindings.push([al ?? nm, m[1], nm]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const c of n.namedChildren)
|
|
153
|
+
walk(c, nextOwner, nextCls);
|
|
154
|
+
};
|
|
155
|
+
walk(root, null, null);
|
|
156
|
+
return { syms, calls, imports, bindings };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Resolve um import para um arquivo indexado: relativo ("./foo", "../a/b", ".mod") ou por alias ("$lib/a", "@/a", "#x/a";
|
|
160
|
+
* ver aliases.ts). Pacote externo (react, zod…) não casa com nada indexado e volta null.
|
|
161
|
+
*/
|
|
162
|
+
function resolveImport(from, spec, known, aliases) {
|
|
163
|
+
const dir = path.posix.dirname(from);
|
|
164
|
+
let bases;
|
|
165
|
+
if (spec.startsWith(".")) {
|
|
166
|
+
if (from.endsWith(".py")) {
|
|
167
|
+
const dots = spec.match(/^\.+/)[0].length;
|
|
168
|
+
const rest = spec.slice(dots).replace(/\./g, "/");
|
|
169
|
+
bases = [path.posix.normalize(path.posix.join(dir, ...Array(dots - 1).fill(".."), rest))];
|
|
170
|
+
}
|
|
171
|
+
else
|
|
172
|
+
bases = [path.posix.normalize(path.posix.join(dir, spec))];
|
|
173
|
+
}
|
|
174
|
+
else
|
|
175
|
+
bases = aliases ? aliasCandidates(aliases.forFile(from), spec) : [];
|
|
176
|
+
for (const raw of bases) {
|
|
177
|
+
const base = raw.replace(/\.(m|c)?jsx?$/, "");
|
|
178
|
+
const cands = [base, ...INDEXED_EXTENSIONS.map((e) => base + e), ...INDEXED_EXTENSIONS.map((e) => `${base}/index${e}`), `${base}/__init__.py`];
|
|
179
|
+
const hit = cands.find((c) => known.has(c));
|
|
180
|
+
if (hit)
|
|
181
|
+
return hit;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const INDEXER_VERSION = "v4";
|
|
186
|
+
const TYPE_KINDS = new Set(["class", "interface", "struct", "trait", "enum", "module", "type"]);
|
|
187
|
+
const JS_LIKE_RE = /\.(m|c)?[jt]sx?$|\.py$|\.svelte$|\.vue$|\.astro$/;
|
|
188
|
+
const SEP = "\t";
|
|
189
|
+
const EXT_GLOB = INDEXED_EXTENSIONS.map((e) => e.slice(1)).join(",");
|
|
190
|
+
const EXT_RE = new RegExp(`\\.(${INDEXED_EXTENSIONS.map((e) => e.slice(1)).join("|")})$`);
|
|
191
|
+
/** Pastas que nunca entram, mesmo que estejam no git. */
|
|
192
|
+
const HARD_IGNORE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**", "**/.brito/**", "**/__pycache__/**", "**/*.d.ts", "**/.venv/**",
|
|
193
|
+
"**/target/**", "**/vendor/**", "**/obj/**", "**/.gradle/**", "**/*.pb.go", "**/*.g.cs", "**/*.Designer.cs"];
|
|
194
|
+
const HARD_IGNORE_RE = /(^|\/)(node_modules|dist|build|\.git|\.brito|__pycache__|\.venv|target|vendor|obj|\.gradle)(\/|$)|\.d\.ts$|\.pb\.go$|\.g\.cs$|\.Designer\.cs$/;
|
|
195
|
+
/**
|
|
196
|
+
* Arquivos que o git considera parte do projeto: rastreados + novos que não estão no .gitignore. Assim o grafo não
|
|
197
|
+
* indexa o que o projeto ignora (gerado, vendorizado, privado), em qualquer linguagem. null = não é repositório git,
|
|
198
|
+
* git indisponível ou `BRITO_INDEX_IGNORED=1` (indexa tudo, como antes).
|
|
199
|
+
*/
|
|
200
|
+
function gitListed(root) {
|
|
201
|
+
if (process.env.BRITO_INDEX_IGNORED === "1")
|
|
202
|
+
return null;
|
|
203
|
+
const r = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
204
|
+
if (r.error || r.status !== 0)
|
|
205
|
+
return null;
|
|
206
|
+
return r.stdout.split("\0").filter(Boolean);
|
|
207
|
+
}
|
|
208
|
+
async function sourceFiles(root) {
|
|
209
|
+
const viaGit = gitListed(root);
|
|
210
|
+
// `ls-files -c` ainda cita arquivo rastreado que foi apagado do disco: confere se existe.
|
|
211
|
+
if (viaGit)
|
|
212
|
+
return viaGit.filter((f) => EXT_RE.test(f) && !HARD_IGNORE_RE.test(f) && fs.existsSync(path.join(root, f)));
|
|
213
|
+
return fg([`**/*.{${EXT_GLOB}}`], { cwd: root, ignore: HARD_IGNORE });
|
|
214
|
+
}
|
|
215
|
+
/** O hash de cada arquivo inclui a assinatura dos aliases dele: editar o tsconfig reindexa só quem depende dele. */
|
|
216
|
+
async function scan(root, aliases) {
|
|
217
|
+
const files = await sourceFiles(root);
|
|
218
|
+
const out = new Map();
|
|
219
|
+
for (const rel of files) {
|
|
220
|
+
const src = fs.readFileSync(path.join(root, rel), "utf8");
|
|
221
|
+
if (src.length > 1_000_000)
|
|
222
|
+
continue;
|
|
223
|
+
out.set(rel, { src, hash: crypto.createHash("sha1").update(src).digest("hex") + ":" + INDEXER_VERSION + ":" + aliases.forFile(rel).signature });
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
const count = async (store, q) => Number((await store.run(q))[0].n);
|
|
228
|
+
/**
|
|
229
|
+
* Indexação incremental: só reparseia arquivos cujo hash mudou. As arestas (CALLS/IMPORTS)
|
|
230
|
+
* são recalculadas em memória a partir dos dados brutos guardados em cada File e aplicadas
|
|
231
|
+
* como diff contra o banco.
|
|
232
|
+
*/
|
|
233
|
+
export async function indexProject(root, store, opts = {}) {
|
|
234
|
+
const aliases = new Aliases(root);
|
|
235
|
+
const current = await scan(root, aliases);
|
|
236
|
+
if (opts.full)
|
|
237
|
+
await store.clearCode();
|
|
238
|
+
const stored = new Map();
|
|
239
|
+
for (const r of await store.run("MATCH (f:File) RETURN f.path AS path, f.hash AS hash, f.calls AS calls, f.imports AS imports, f.bindings AS bindings")) {
|
|
240
|
+
stored.set(r.path, { hash: r.hash, calls: r.calls ?? [], imports: r.imports ?? [], bindings: r.bindings ?? [] });
|
|
241
|
+
}
|
|
242
|
+
const added = [], changed = [], removed = [];
|
|
243
|
+
for (const [p, { hash }] of current) {
|
|
244
|
+
const old = stored.get(p);
|
|
245
|
+
if (!old)
|
|
246
|
+
added.push(p);
|
|
247
|
+
else if (old.hash !== hash)
|
|
248
|
+
changed.push(p);
|
|
249
|
+
}
|
|
250
|
+
for (const p of stored.keys())
|
|
251
|
+
if (!current.has(p))
|
|
252
|
+
removed.push(p);
|
|
253
|
+
const unchanged = current.size - added.length - changed.length;
|
|
254
|
+
const totals = async () => ({
|
|
255
|
+
files: await count(store, "MATCH (f:File) RETURN count(f) AS n"),
|
|
256
|
+
symbols: await count(store, "MATCH (s:Symbol) RETURN count(s) AS n"),
|
|
257
|
+
calls: await count(store, "MATCH ()-[r:CALLS]->() RETURN count(r) AS n"),
|
|
258
|
+
imports: await count(store, "MATCH ()-[r:IMPORTS]->() RETURN count(r) AS n"),
|
|
259
|
+
});
|
|
260
|
+
const delta = { added: added.length, changed: changed.length, removed: removed.length, unchanged };
|
|
261
|
+
if (!added.length && !changed.length && !removed.length)
|
|
262
|
+
return { ...(await totals()), ...delta };
|
|
263
|
+
opts.onProgress?.(`+${added.length} ~${changed.length} -${removed.length} (=${unchanged}), atualizando grafo…`);
|
|
264
|
+
// 1. Remove versões antigas (DETACH apaga DEFINES/CALLS/IMPORTS ligados).
|
|
265
|
+
for (const p of [...removed, ...changed]) {
|
|
266
|
+
await store.run("MATCH (s:Symbol {file:$p}) DETACH DELETE s", { p });
|
|
267
|
+
await store.run("MATCH (f:File {path:$p}) DETACH DELETE f", { p });
|
|
268
|
+
stored.delete(p);
|
|
269
|
+
}
|
|
270
|
+
// 2. Parseia e grava só o que mudou.
|
|
271
|
+
for (const rel of [...added, ...changed]) {
|
|
272
|
+
const { src, hash } = current.get(rel);
|
|
273
|
+
const ext = path.extname(rel);
|
|
274
|
+
// Componente (.svelte/.vue/.astro): o parser vê só os blocos de script; o template vira chamadas de um símbolo próprio.
|
|
275
|
+
const sfc = SFC_EXT.has(ext) ? splitSfc(src, ext) : null;
|
|
276
|
+
const lang = sfc ? (sfc.tsx ? "tsx" : "typescript") : EXT[ext];
|
|
277
|
+
const tree = (await getParser(lang)).parse(sfc ? sfc.script : src);
|
|
278
|
+
const info = isExtraLang(lang) ? extractLang(tree.rootNode, lang, rel) : extract(tree.rootNode, lang, rel);
|
|
279
|
+
tree.delete();
|
|
280
|
+
if (sfc) {
|
|
281
|
+
const name = componentName(rel);
|
|
282
|
+
const comp = { id: `${rel}::${name}:1`, name, kind: "component", file: rel, startLine: 1, endLine: src.split("\n").length };
|
|
283
|
+
const known = new Set([...info.syms.filter((s) => s.kind === "function" || s.kind === "class").map((s) => s.name), ...info.bindings.map((b) => b[0])]);
|
|
284
|
+
for (const callee of templateCalleeNames(sfc.template, known))
|
|
285
|
+
info.calls.push({ callerId: comp.id, callee, obj: "", file: rel });
|
|
286
|
+
info.syms.push(comp);
|
|
287
|
+
}
|
|
288
|
+
const calls = [...new Set(info.calls.filter((c) => c.callerId).map((c) => [c.callerId, c.callee, c.obj].join(SEP)))];
|
|
289
|
+
const bindings = [...new Set(info.bindings.map((b) => b.join(SEP)))];
|
|
290
|
+
await store.run("CREATE (:File {path:$p, lang:$l, hash:$h, calls:$c, imports:$i, bindings:$b})", { p: rel, l: sfc ? ext.slice(1) : lang, h: hash, c: calls, i: [...new Set(info.imports)], b: bindings });
|
|
291
|
+
for (const s of info.syms) {
|
|
292
|
+
await store.run("CREATE (:Symbol {id:$id, name:$n, kind:$k, file:$f, startLine:$s, endLine:$e})", { id: s.id, n: s.name, k: s.kind, f: s.file, s: s.startLine, e: s.endLine });
|
|
293
|
+
await store.run("MATCH (f:File {path:$f}), (s:Symbol {id:$id}) CREATE (f)-[:DEFINES]->(s)", { f: rel, id: s.id });
|
|
294
|
+
}
|
|
295
|
+
stored.set(rel, { hash, calls, imports: [...new Set(info.imports)], bindings });
|
|
296
|
+
}
|
|
297
|
+
// 3. Recalcula arestas desejadas em memória (sem parsear) e aplica diff.
|
|
298
|
+
const known = new Set(stored.keys());
|
|
299
|
+
const projectIndex = new ProjectIndex(root, known);
|
|
300
|
+
/** Arquivos que um import pode designar: vários em linguagens de pacote (Go, Java/Kotlin com wildcard), no máximo um em TS/Python. */
|
|
301
|
+
const resolveAll = (from, spec) => {
|
|
302
|
+
const r = projectIndex.resolve(from, spec);
|
|
303
|
+
if (r)
|
|
304
|
+
return r;
|
|
305
|
+
const one = resolveImport(from, spec, known, aliases);
|
|
306
|
+
return one ? [one] : [];
|
|
307
|
+
};
|
|
308
|
+
const byName = new Map();
|
|
309
|
+
const symByFile = new Map();
|
|
310
|
+
const shortByFile = new Map();
|
|
311
|
+
const typesByName = new Map();
|
|
312
|
+
for (const r of await store.run("MATCH (s:Symbol) RETURN s.id AS id, s.name AS name, s.kind AS kind, s.file AS file")) {
|
|
313
|
+
const short = String(r.name).split(".").pop();
|
|
314
|
+
(byName.get(short) ?? byName.set(short, []).get(short)).push({ id: r.id, file: r.file, method: r.kind === "method" });
|
|
315
|
+
const m = symByFile.get(r.file) ?? symByFile.set(r.file, new Map()).get(r.file);
|
|
316
|
+
(m.get(r.name) ?? m.set(r.name, []).get(r.name)).push(r.id);
|
|
317
|
+
const sm = shortByFile.get(r.file) ?? shortByFile.set(r.file, new Map()).get(r.file);
|
|
318
|
+
(sm.get(short) ?? sm.set(short, []).get(short)).push(r.id);
|
|
319
|
+
if (TYPE_KINDS.has(r.kind)) {
|
|
320
|
+
const key = String(r.name).toLowerCase();
|
|
321
|
+
(typesByName.get(key) ?? typesByName.set(key, []).get(key)).push({ name: r.name, file: r.file });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const lookup = (file, name) => symByFile.get(file)?.get(name) ?? [];
|
|
325
|
+
/** Símbolo pelo nome curto, ignorando a classe (Strings.trim importado como `trim`). Só para linguagens que aninham métodos em tipos. */
|
|
326
|
+
const lookupShort = (file, name) => symByFile.has(file) ? shortByFile.get(file)?.get(name) ?? [] : [];
|
|
327
|
+
const jsLike = (file) => JS_LIKE_RE.test(file);
|
|
328
|
+
const clsOf = (callerName) => (callerName.includes(".") ? callerName.split(".")[0] : null);
|
|
329
|
+
/**
|
|
330
|
+
* `repo.load()` / `Repo.create()` / `_userService.find()`: casa o receptor com um tipo do projeto pelo nome (sem `_`/`m_`,
|
|
331
|
+
* sem diferenciar caixa) e procura o método nele. Só vale com poucos candidatos; é heurística, sem inferência de tipos.
|
|
332
|
+
*/
|
|
333
|
+
const viaTypeName = (obj, callee) => {
|
|
334
|
+
const cands = typesByName.get(obj.replace(/^(m?_+|m(?=[A-Z]))/, "").toLowerCase()) ?? [];
|
|
335
|
+
if (!cands.length || cands.length > 3)
|
|
336
|
+
return [];
|
|
337
|
+
return cands.flatMap((c) => lookup(c.file, `${c.name}.${callee}`));
|
|
338
|
+
};
|
|
339
|
+
const bindingsOf = new Map();
|
|
340
|
+
for (const [file, f] of stored) {
|
|
341
|
+
const m = new Map();
|
|
342
|
+
for (const raw of f.bindings) {
|
|
343
|
+
const [local, spec, imported] = raw.split(SEP);
|
|
344
|
+
m.set(local, { spec, imported });
|
|
345
|
+
}
|
|
346
|
+
bindingsOf.set(file, m);
|
|
347
|
+
}
|
|
348
|
+
/** Fallback por nome: só aceita se o conjunto de candidatos for pequeno. */
|
|
349
|
+
const heuristic = (file, callee, member) => {
|
|
350
|
+
// chamada `x.foo()` só pode ser método; `foo()` só pode ser função/classe
|
|
351
|
+
const targets = (byName.get(callee) ?? []).filter((t) => t.method === member);
|
|
352
|
+
const local = targets.filter((t) => t.file === file);
|
|
353
|
+
const pool = local.length ? local : targets;
|
|
354
|
+
return pool.length > 3 ? [] : pool.map((t) => t.id);
|
|
355
|
+
};
|
|
356
|
+
/** Ordem: import explícito → this/self → mesmo arquivo → tipo pelo nome → heurística por nome. */
|
|
357
|
+
const resolveCall = (file, callerId, callee, obj) => {
|
|
358
|
+
const binds = bindingsOf.get(file);
|
|
359
|
+
const callerName = callerId.slice(callerId.indexOf("::") + 2, callerId.lastIndexOf(":"));
|
|
360
|
+
if (!obj) {
|
|
361
|
+
const b = binds.get(callee);
|
|
362
|
+
if (b) {
|
|
363
|
+
const tfs = resolveAll(file, b.spec);
|
|
364
|
+
if (!tfs.length && !b.spec.startsWith("."))
|
|
365
|
+
return []; // pacote externo (não é relativo nem alias do projeto)
|
|
366
|
+
const want = b.imported === "default" ? callee : b.imported;
|
|
367
|
+
let ids = tfs.flatMap((tf) => lookup(tf, want));
|
|
368
|
+
if (!ids.length && !jsLike(file))
|
|
369
|
+
ids = tfs.flatMap((tf) => lookupShort(tf, want));
|
|
370
|
+
return ids.length ? ids : heuristic(file, callee, false); // re-export/barrel: cai no fallback
|
|
371
|
+
}
|
|
372
|
+
const own = lookup(file, callee);
|
|
373
|
+
return own.length ? own : heuristic(file, callee, false);
|
|
374
|
+
}
|
|
375
|
+
if (obj === "@") { // chamada solta dentro de classe: método da própria classe, senão função/import, senão herdado (por nome)
|
|
376
|
+
const cls = clsOf(callerName);
|
|
377
|
+
const own = cls ? lookup(file, `${cls}.${callee}`) : [];
|
|
378
|
+
if (own.length)
|
|
379
|
+
return own;
|
|
380
|
+
const free = resolveCall(file, callerId, callee, "");
|
|
381
|
+
return free.length ? free : heuristic(file, callee, true);
|
|
382
|
+
}
|
|
383
|
+
if (obj === "this" || obj === "self") {
|
|
384
|
+
const cls = clsOf(callerName);
|
|
385
|
+
const ids = cls ? lookup(file, `${cls}.${callee}`) : [];
|
|
386
|
+
return ids.length ? ids : heuristic(file, callee, true);
|
|
387
|
+
}
|
|
388
|
+
const b = binds.get(obj);
|
|
389
|
+
if (b) {
|
|
390
|
+
const tfs = resolveAll(file, b.spec);
|
|
391
|
+
if (!tfs.length && !b.spec.startsWith("."))
|
|
392
|
+
return []; // fs.readFileSync, z.string()…
|
|
393
|
+
const ids = tfs.flatMap((tf) => lookup(tf, callee).concat(lookup(tf, `${b.imported}.${callee}`)));
|
|
394
|
+
if (ids.length)
|
|
395
|
+
return ids;
|
|
396
|
+
}
|
|
397
|
+
const typed = viaTypeName(obj, callee);
|
|
398
|
+
return typed.length ? typed : heuristic(file, callee, true);
|
|
399
|
+
};
|
|
400
|
+
const wantCalls = new Set();
|
|
401
|
+
const wantImports = new Set();
|
|
402
|
+
for (const [file, f] of stored) {
|
|
403
|
+
for (const raw of f.calls) {
|
|
404
|
+
const [callerId, callee, obj] = raw.split(SEP);
|
|
405
|
+
for (const id of resolveCall(file, callerId, callee, obj ?? ""))
|
|
406
|
+
if (id !== callerId)
|
|
407
|
+
wantCalls.add(callerId + SEP + id);
|
|
408
|
+
}
|
|
409
|
+
for (const spec of f.imports) {
|
|
410
|
+
for (const target of resolveAll(file, spec))
|
|
411
|
+
if (target !== file)
|
|
412
|
+
wantImports.add(file + SEP + target);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const haveCalls = new Set((await store.run("MATCH (a:Symbol)-[:CALLS]->(b:Symbol) RETURN a.id AS a, b.id AS b")).map((r) => r.a + SEP + r.b));
|
|
416
|
+
const haveImports = new Set((await store.run("MATCH (a:File)-[:IMPORTS]->(b:File) RETURN a.path AS a, b.path AS b")).map((r) => r.a + SEP + r.b));
|
|
417
|
+
for (const k of wantCalls)
|
|
418
|
+
if (!haveCalls.has(k)) {
|
|
419
|
+
const [a, b] = k.split(SEP);
|
|
420
|
+
await store.run("MATCH (x:Symbol {id:$a}), (y:Symbol {id:$b}) CREATE (x)-[:CALLS]->(y)", { a, b });
|
|
421
|
+
}
|
|
422
|
+
for (const k of haveCalls)
|
|
423
|
+
if (!wantCalls.has(k)) {
|
|
424
|
+
const [a, b] = k.split(SEP);
|
|
425
|
+
await store.run("MATCH (x:Symbol {id:$a})-[r:CALLS]->(y:Symbol {id:$b}) DELETE r", { a, b });
|
|
426
|
+
}
|
|
427
|
+
for (const k of wantImports)
|
|
428
|
+
if (!haveImports.has(k)) {
|
|
429
|
+
const [a, b] = k.split(SEP);
|
|
430
|
+
await store.run("MATCH (x:File {path:$a}), (y:File {path:$b}) CREATE (x)-[:IMPORTS]->(y)", { a, b });
|
|
431
|
+
}
|
|
432
|
+
for (const k of haveImports)
|
|
433
|
+
if (!wantImports.has(k)) {
|
|
434
|
+
const [a, b] = k.split(SEP);
|
|
435
|
+
await store.run("MATCH (x:File {path:$a})-[r:IMPORTS]->(y:File {path:$b}) DELETE r", { a, b });
|
|
436
|
+
}
|
|
437
|
+
return { ...(await totals()), ...delta };
|
|
438
|
+
}
|