reponexia 0.1.0__py3-none-any.whl
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.
- reponexia/__init__.py +1 -0
- reponexia/__main__.py +5 -0
- reponexia/ask.py +50 -0
- reponexia/cache.py +40 -0
- reponexia/cli.py +178 -0
- reponexia/contract.py +43 -0
- reponexia/core_runner.py +61 -0
- reponexia/graph.schema.json +118 -0
- reponexia/guide.py +88 -0
- reponexia/modules.py +21 -0
- reponexia/pipeline.py +101 -0
- reponexia/redact.py +28 -0
- reponexia/render/__init__.py +4 -0
- reponexia/render/markdown.py +144 -0
- reponexia/render/mermaid.py +44 -0
- reponexia/summarize/__init__.py +3 -0
- reponexia/summarize/base.py +39 -0
- reponexia/summarize/context.py +57 -0
- reponexia/summarize/litellm_impl.py +112 -0
- reponexia/summarize/skeleton.py +28 -0
- reponexia-0.1.0.dist-info/METADATA +88 -0
- reponexia-0.1.0.dist-info/RECORD +26 -0
- reponexia-0.1.0.dist-info/WHEEL +5 -0
- reponexia-0.1.0.dist-info/entry_points.txt +2 -0
- reponexia-0.1.0.dist-info/licenses/LICENSE +21 -0
- reponexia-0.1.0.dist-info/top_level.txt +1 -0
reponexia/pipeline.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .cache import cache_key
|
|
7
|
+
from .redact import redact
|
|
8
|
+
from .summarize.base import ModuleContext, RepoContext, Summarizer
|
|
9
|
+
from .summarize.context import build_module_contexts
|
|
10
|
+
from .summarize.skeleton import MAX_LINES_FOR_LLM, skeletonize
|
|
11
|
+
|
|
12
|
+
# Teto de linhas de um snippet enviado ao LLM (mesmo abaixo do limite de esqueleto).
|
|
13
|
+
MAX_SNIPPET_LINES = 200
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _symbols_by_file(graph: dict) -> dict[str, list[dict]]:
|
|
17
|
+
result: dict[str, list[dict]] = {}
|
|
18
|
+
for symbol in graph.get("symbols", []):
|
|
19
|
+
result.setdefault(symbol["file"], []).append(symbol)
|
|
20
|
+
return result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Lê o arquivo do disco, esqueletiza se for grande, fatia e redige segredos.
|
|
24
|
+
# O LLM nunca vê o repositório inteiro.
|
|
25
|
+
def _snippet(root: Path, path: str, symbols: list[dict]) -> str | None:
|
|
26
|
+
try:
|
|
27
|
+
text = (root / path).read_text(encoding="utf-8", errors="replace")
|
|
28
|
+
except OSError:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
if text.count("\n") + 1 > MAX_LINES_FOR_LLM:
|
|
32
|
+
text = skeletonize(text, symbols)
|
|
33
|
+
|
|
34
|
+
lines = text.splitlines()[:MAX_SNIPPET_LINES]
|
|
35
|
+
return redact("\n".join(lines))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def attach_snippets(contexts: list[ModuleContext], graph: dict) -> list[ModuleContext]:
|
|
39
|
+
root = Path(graph["root"])
|
|
40
|
+
by_file = _symbols_by_file(graph)
|
|
41
|
+
|
|
42
|
+
attached = []
|
|
43
|
+
for ctx in contexts:
|
|
44
|
+
snippets = []
|
|
45
|
+
for path in ctx.files:
|
|
46
|
+
snippet = _snippet(root, path, by_file.get(path, []))
|
|
47
|
+
if snippet:
|
|
48
|
+
snippets.append(f"# {path}\n{snippet}")
|
|
49
|
+
attached.append(replace(ctx, snippets=tuple(snippets)))
|
|
50
|
+
return attached
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _module_hash(graph: dict, files: tuple[str, ...]) -> str:
|
|
54
|
+
hashes = {file["path"]: file.get("hash", "") for file in graph.get("files", [])}
|
|
55
|
+
return cache_key(*[hashes.get(path, "") for path in files])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Pipeline hierárquico: sumariza módulo a módulo e depois o repo a partir desses
|
|
59
|
+
# resumos. Cache por hash de módulo evita re-chamar o LLM sem mudança.
|
|
60
|
+
def build_narrative(
|
|
61
|
+
graph: dict,
|
|
62
|
+
summarizer: Summarizer,
|
|
63
|
+
*,
|
|
64
|
+
cache=None,
|
|
65
|
+
verbose: bool = False,
|
|
66
|
+
) -> dict:
|
|
67
|
+
contexts = attach_snippets(build_module_contexts(graph), graph)
|
|
68
|
+
model = getattr(summarizer, "model", "")
|
|
69
|
+
|
|
70
|
+
modules: dict[str, str] = {}
|
|
71
|
+
for ctx in contexts:
|
|
72
|
+
key = cache_key("module", model, _module_hash(graph, ctx.files))
|
|
73
|
+
cached = cache.get(key) if cache else None
|
|
74
|
+
if cached is not None:
|
|
75
|
+
if verbose:
|
|
76
|
+
print(f"[cache] modulo {ctx.name}")
|
|
77
|
+
modules[ctx.name] = cached
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
text = summarizer.summarize_module(ctx)
|
|
81
|
+
modules[ctx.name] = text
|
|
82
|
+
if cache is not None:
|
|
83
|
+
cache.set(key, text)
|
|
84
|
+
|
|
85
|
+
repo_context = RepoContext(
|
|
86
|
+
name=Path(graph["root"]).name or graph["root"],
|
|
87
|
+
languages=tuple(graph.get("languages", [])),
|
|
88
|
+
file_count=len(graph.get("files", [])),
|
|
89
|
+
module_summaries=tuple(
|
|
90
|
+
f"## {name}\n{text}" for name, text in modules.items()
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
repo_key = cache_key("repo", model, graph.get("repo_hash", ""))
|
|
95
|
+
cached_repo = cache.get(repo_key) if cache else None
|
|
96
|
+
if cached_repo is None:
|
|
97
|
+
cached_repo = summarizer.summarize_repo(repo_context)
|
|
98
|
+
if cache is not None:
|
|
99
|
+
cache.set(repo_key, cached_repo)
|
|
100
|
+
|
|
101
|
+
return {"repo": cached_repo, "modules": modules}
|
reponexia/redact.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
PLACEHOLDER = "[REDACTED]"
|
|
6
|
+
|
|
7
|
+
# Regex de padrões comuns de segredo. Configuráveis via o parâmetro `patterns`,
|
|
8
|
+
# para quando existir um arquivo de config (por ora o default cobre os casos do DESIGN).
|
|
9
|
+
PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
10
|
+
re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key
|
|
11
|
+
re.compile(r"sk-[A-Za-z0-9]{20,}"), # chave estilo OpenAI
|
|
12
|
+
re.compile(r"ghp_[A-Za-z0-9]{36}"), # GitHub token
|
|
13
|
+
re.compile(r"xox[baprs]-[A-Za-z0-9-]+"), # Slack token
|
|
14
|
+
re.compile( # bloco de chave privada
|
|
15
|
+
r"-----BEGIN[^-]*PRIVATE KEY-----.*?-----END[^-]*PRIVATE KEY-----",
|
|
16
|
+
re.DOTALL, # o corpo tem quebras de linha
|
|
17
|
+
),
|
|
18
|
+
re.compile( # connection string com senha
|
|
19
|
+
r"\b(?:postgres(?:ql)?|mysql|mongodb|redis)://[^:\s]+:[^@\s]+@[^\s]+"
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Substitui todo segredo detectado por um marcador fixo.
|
|
25
|
+
def redact(text: str, patterns: tuple[re.Pattern[str], ...] = PATTERNS) -> str:
|
|
26
|
+
for pattern in patterns:
|
|
27
|
+
text = pattern.sub(PLACEHOLDER, text)
|
|
28
|
+
return text
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
|
|
5
|
+
TOP_FILES=10
|
|
6
|
+
|
|
7
|
+
def render_markdown(graph:dict, narrative: dict | None = None) -> str:
|
|
8
|
+
sections=[
|
|
9
|
+
_header(graph),
|
|
10
|
+
_entry_points(graph),
|
|
11
|
+
_top_files(graph),
|
|
12
|
+
_modules(graph),
|
|
13
|
+
_dependencies(graph),
|
|
14
|
+
_warnings(graph),
|
|
15
|
+
]
|
|
16
|
+
# O bloco do LLM é opcional: no --dry-run não existe.
|
|
17
|
+
if narrative:
|
|
18
|
+
sections.append(_narrative(narrative))
|
|
19
|
+
return "\n\n".join(section for section in sections if section) + "\n"
|
|
20
|
+
|
|
21
|
+
def _header(graph: dict) -> str:
|
|
22
|
+
name = PurePosixPath(graph["root"]).name or graph["root"]
|
|
23
|
+
languages = ", ".join(graph.get("languages", [])) or "nenhuma"
|
|
24
|
+
|
|
25
|
+
return "\n".join([
|
|
26
|
+
f"# Onboarding: {name}",
|
|
27
|
+
"",
|
|
28
|
+
f"- schema: {graph['schema_version']}",
|
|
29
|
+
f"- repo_hash: {graph['repo_hash'][:12]}" if graph.get("repo_hash") else "",
|
|
30
|
+
f"- linguagens: {languages}",
|
|
31
|
+
f"- arquivos: {len(graph.get('files', []))}",
|
|
32
|
+
f"- símbolos: {len(graph.get('symbols', []))}",
|
|
33
|
+
f"- dependências internas: {len(graph.get('edges', []))}",
|
|
34
|
+
f"- entry points: {len(graph.get('entry_points', []))}",
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
def _entry_points(graph: dict) -> str:
|
|
38
|
+
entries = graph.get("entry_points", [])
|
|
39
|
+
if not entries:
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
lines = ["## Entry points", "", "| path | kind | evidência |", "|---|---|---|"]
|
|
43
|
+
for entry in sorted(entries, key=lambda e: (e["path"], e["kind"])):
|
|
44
|
+
lines.append(f"| `{entry['path']}` | {entry['kind']} | `{entry['evidence']}` |")
|
|
45
|
+
return "\n".join(lines)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Mais centrais primeiro, desempate por path.
|
|
49
|
+
def _top_files(graph: dict) -> str:
|
|
50
|
+
metrics = graph.get("metrics", [])
|
|
51
|
+
if not metrics:
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
top = sorted(metrics, key=lambda m: (-m["page_rank"], m["path"]))[:TOP_FILES]
|
|
55
|
+
|
|
56
|
+
lines = [
|
|
57
|
+
"## Arquivos centrais",
|
|
58
|
+
"",
|
|
59
|
+
"| path | fan_in | fan_out | page_rank |",
|
|
60
|
+
"|---|---|---|---|",
|
|
61
|
+
]
|
|
62
|
+
for metric in top:
|
|
63
|
+
lines.append(
|
|
64
|
+
f"| `{metric['path']}` | {metric['fan_in']} | {metric['fan_out']} | "
|
|
65
|
+
f"{metric['page_rank']:.6f} |"
|
|
66
|
+
)
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _modules(graph: dict) -> str:
|
|
71
|
+
files = graph.get("files", [])
|
|
72
|
+
if not files:
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
symbols_by_file: dict[str, int] = defaultdict(int)
|
|
76
|
+
for symbol in graph.get("symbols", []):
|
|
77
|
+
symbols_by_file[symbol["file"]] += 1
|
|
78
|
+
|
|
79
|
+
groups: dict[str, list[dict]] = defaultdict(list)
|
|
80
|
+
for file in files:
|
|
81
|
+
parent = str(PurePosixPath(file["path"]).parent)
|
|
82
|
+
groups["(raiz)" if parent == "." else parent].append(file)
|
|
83
|
+
|
|
84
|
+
lines = ["## Módulos"]
|
|
85
|
+
for module in sorted(groups):
|
|
86
|
+
members = sorted(groups[module], key=lambda f: f["path"])
|
|
87
|
+
total_symbols = sum(symbols_by_file[file["path"]] for file in members)
|
|
88
|
+
lines.append("")
|
|
89
|
+
lines.append(
|
|
90
|
+
f"### `{module}` ({_count(len(members), 'arquivo', 'arquivos')}, "
|
|
91
|
+
f"{_count(total_symbols, 'símbolo', 'símbolos')})"
|
|
92
|
+
)
|
|
93
|
+
for file in members:
|
|
94
|
+
lines.append(f"- `{file['path']}` — {file['loc']} linhas")
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Concordância simples para os contadores do cabeçalho de módulo.
|
|
99
|
+
def _count(n: int, singular: str, plural: str) -> str:
|
|
100
|
+
return f"{n} {singular if n == 1 else plural}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Arestas agrupadas por origem.
|
|
104
|
+
def _dependencies(graph: dict) -> str:
|
|
105
|
+
edges = graph.get("edges", [])
|
|
106
|
+
if not edges:
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
by_source: dict[str, list[dict]] = defaultdict(list)
|
|
110
|
+
for edge in edges:
|
|
111
|
+
by_source[edge["from"]].append(edge)
|
|
112
|
+
|
|
113
|
+
lines = ["## Dependências internas"]
|
|
114
|
+
for source in sorted(by_source):
|
|
115
|
+
lines.append("")
|
|
116
|
+
lines.append(f"### `{source}`")
|
|
117
|
+
for edge in sorted(by_source[source], key=lambda e: (e["to"], e["line"])):
|
|
118
|
+
lines.append(f"- linha {edge['line']} → `{edge['to']}`")
|
|
119
|
+
return "\n".join(lines)
|
|
120
|
+
|
|
121
|
+
def _warnings(graph: dict) -> str:
|
|
122
|
+
warnings = graph.get("warnings", [])
|
|
123
|
+
if not warnings:
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
lines = ["## Avisos"]
|
|
127
|
+
for warning in warnings:
|
|
128
|
+
path = warning.get("path")
|
|
129
|
+
where = f"`{path}`: " if path else ""
|
|
130
|
+
lines.append(f"- {where}{warning['message']}")
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Seções narrativas produzidas pelo LLM (repo + módulos).
|
|
135
|
+
def _narrative(narrative: dict) -> str:
|
|
136
|
+
lines = ["## Visão geral (LLM)", "", (narrative.get("repo") or "").strip()]
|
|
137
|
+
|
|
138
|
+
modules = narrative.get("modules") or {}
|
|
139
|
+
if modules:
|
|
140
|
+
lines += ["", "## Módulos (resumo LLM)"]
|
|
141
|
+
for name in sorted(modules):
|
|
142
|
+
lines += ["", f"### `{name}`", "", (modules[name] or "").strip()]
|
|
143
|
+
|
|
144
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# Evita espaguete: só os N arquivos mais centrais (PageRank) + entry points.
|
|
4
|
+
DEFAULT_TOP_N = 50
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Renderiza as dependências internas como um grafo Mermaid.
|
|
8
|
+
def render_mermaid(graph: dict, top_n: int = DEFAULT_TOP_N) -> str:
|
|
9
|
+
edges = graph.get("edges", [])
|
|
10
|
+
entry_points = {entry["path"] for entry in graph.get("entry_points", [])}
|
|
11
|
+
|
|
12
|
+
# Nós = arquivos ligados por arestas + entry points.
|
|
13
|
+
paths = {edge["from"] for edge in edges} | {edge["to"] for edge in edges}
|
|
14
|
+
paths |= entry_points
|
|
15
|
+
if not paths:
|
|
16
|
+
return "graph LR\n %% nenhuma dependencia interna detectada\n"
|
|
17
|
+
|
|
18
|
+
# Filtro top-N por PageRank; entry points sempre ficam.
|
|
19
|
+
metrics = graph.get("metrics", [])
|
|
20
|
+
if metrics and top_n > 0:
|
|
21
|
+
ranked = sorted(metrics, key=lambda m: (-m["page_rank"], m["path"]))
|
|
22
|
+
keep = {m["path"] for m in ranked[:top_n]} | entry_points
|
|
23
|
+
paths &= keep
|
|
24
|
+
edges = [e for e in edges if e["from"] in paths and e["to"] in paths]
|
|
25
|
+
if not paths:
|
|
26
|
+
return "graph LR\n %% nenhuma dependencia interna detectada\n"
|
|
27
|
+
|
|
28
|
+
# IDs determinísticos: ordem alfabética, sem depender do path no nó.
|
|
29
|
+
node_id = {path: f"n{index}" for index, path in enumerate(sorted(paths))}
|
|
30
|
+
|
|
31
|
+
lines = ["graph LR"]
|
|
32
|
+
for path in sorted(paths):
|
|
33
|
+
# Entry points ganham formato de estádio para se destacarem.
|
|
34
|
+
if path in entry_points:
|
|
35
|
+
lines.append(f' {node_id[path]}(["{path}"])')
|
|
36
|
+
else:
|
|
37
|
+
lines.append(f' {node_id[path]}["{path}"]')
|
|
38
|
+
|
|
39
|
+
# Uma seta por par origem/destino, em ordem estável.
|
|
40
|
+
pairs = {(edge["from"], edge["to"]) for edge in edges}
|
|
41
|
+
for source, target in sorted(pairs):
|
|
42
|
+
lines.append(f" {node_id[source]} --> {node_id[target]}")
|
|
43
|
+
|
|
44
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class SymbolRef:
|
|
9
|
+
name: str
|
|
10
|
+
kind: str
|
|
11
|
+
line: int
|
|
12
|
+
file: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ModuleContext:
|
|
17
|
+
name: str
|
|
18
|
+
files: tuple[str, ...]
|
|
19
|
+
symbols: tuple[SymbolRef, ...]
|
|
20
|
+
imports_out: tuple[str, ...]
|
|
21
|
+
imports_in: tuple[str, ...]
|
|
22
|
+
# Snippets já redigidos/esqueletizados; vazio no modo estrutural.
|
|
23
|
+
snippets: tuple[str, ...] = ()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Resumo do repositório é montado a partir dos resumos de módulo (hierárquico),
|
|
27
|
+
# por isso carrega os textos e não o código bruto.
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RepoContext:
|
|
30
|
+
name: str
|
|
31
|
+
languages: tuple[str, ...]
|
|
32
|
+
file_count: int
|
|
33
|
+
module_summaries: tuple[str, ...]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Summarizer(Protocol):
|
|
37
|
+
def summarize_module(self, module: ModuleContext) -> str: ...
|
|
38
|
+
|
|
39
|
+
def summarize_repo(self, ctx: RepoContext) -> str: ...
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
from ..modules import group_files_by_module
|
|
6
|
+
from .base import ModuleContext, SymbolRef
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Monta o contexto resumido por módulo a partir do grafo.
|
|
10
|
+
# Por quê: o LLM recebe resumo estrutural (símbolos/imports), nunca o código inteiro.
|
|
11
|
+
def build_module_contexts(graph: dict) -> list[ModuleContext]:
|
|
12
|
+
files_by_module = group_files_by_module(graph.get("files", []))
|
|
13
|
+
|
|
14
|
+
# Mapa arquivo -> módulo, para atribuir símbolos e arestas ao módulo certo.
|
|
15
|
+
module_of_file = {
|
|
16
|
+
file["path"]: name
|
|
17
|
+
for name, members in files_by_module.items()
|
|
18
|
+
for file in members
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Um balde por módulo. Sets evitam import duplicado (A importa B 2x).
|
|
22
|
+
symbols_by_module: dict[str, list[SymbolRef]] = defaultdict(list)
|
|
23
|
+
out_by_module: dict[str, set[str]] = defaultdict(set)
|
|
24
|
+
in_by_module: dict[str, set[str]] = defaultdict(set)
|
|
25
|
+
|
|
26
|
+
for symbol in graph.get("symbols", []):
|
|
27
|
+
module = module_of_file.get(symbol["file"])
|
|
28
|
+
if module is None:
|
|
29
|
+
continue
|
|
30
|
+
symbols_by_module[module].append(
|
|
31
|
+
SymbolRef(
|
|
32
|
+
name=symbol["name"],
|
|
33
|
+
kind=symbol["kind"],
|
|
34
|
+
line=symbol["line"],
|
|
35
|
+
file=symbol["file"],
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
for edge in graph.get("edges", []):
|
|
40
|
+
source = module_of_file.get(edge["from"])
|
|
41
|
+
target = module_of_file.get(edge["to"])
|
|
42
|
+
# Import dentro do mesmo módulo não é dependência entre módulos.
|
|
43
|
+
if source is None or target is None or source == target:
|
|
44
|
+
continue
|
|
45
|
+
out_by_module[source].add(target)
|
|
46
|
+
in_by_module[target].add(source)
|
|
47
|
+
|
|
48
|
+
return [
|
|
49
|
+
ModuleContext(
|
|
50
|
+
name=name,
|
|
51
|
+
files=tuple(file["path"] for file in members),
|
|
52
|
+
symbols=tuple(symbols_by_module[name]),
|
|
53
|
+
imports_out=tuple(sorted(out_by_module[name])),
|
|
54
|
+
imports_in=tuple(sorted(in_by_module[name])),
|
|
55
|
+
)
|
|
56
|
+
for name, members in sorted(files_by_module.items())
|
|
57
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from .base import ModuleContext, RepoContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SummarizerError(RuntimeError):
|
|
10
|
+
"""Falha ao obter resumo do LLM (após os retries)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BudgetExceeded(SummarizerError):
|
|
14
|
+
"""O orçamento de tokens configurado seria estourado."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _estimate_tokens(text: str) -> int:
|
|
18
|
+
# ponytail: estimativa chars/4; trocar por tokenizer real se o custo precisar ser exato
|
|
19
|
+
return max(1, len(text) // 4)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Implementação única sobre o litellm, que já abstrai OpenAI/Anthropic/Ollama.
|
|
23
|
+
# O Protocol Summarizer existe só para permitir mock/offline nos testes.
|
|
24
|
+
class LiteLLMSummarizer:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
model: str,
|
|
28
|
+
*,
|
|
29
|
+
max_tokens: int = 1_000_000,
|
|
30
|
+
retries: int = 3,
|
|
31
|
+
call: Callable[[str], str] | None = None,
|
|
32
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.model = model
|
|
35
|
+
self.max_tokens = max_tokens
|
|
36
|
+
self.retries = retries
|
|
37
|
+
self.spent = 0
|
|
38
|
+
self._call = call or self._litellm_call
|
|
39
|
+
self._sleep = sleep
|
|
40
|
+
|
|
41
|
+
# Chamada real; o import fica lazy para testar sem o litellm instalado.
|
|
42
|
+
def _litellm_call(self, prompt: str) -> str:
|
|
43
|
+
import litellm
|
|
44
|
+
|
|
45
|
+
response = litellm.completion(
|
|
46
|
+
model=self.model,
|
|
47
|
+
messages=[{"role": "user", "content": prompt}],
|
|
48
|
+
max_tokens=1024,
|
|
49
|
+
)
|
|
50
|
+
return response.choices[0].message.content or ""
|
|
51
|
+
|
|
52
|
+
# Executa com retry/backoff e contabiliza o orçamento antes de devolver.
|
|
53
|
+
def _complete(self, prompt: str) -> str:
|
|
54
|
+
for attempt in range(self.retries + 1):
|
|
55
|
+
try:
|
|
56
|
+
text = self._call(prompt)
|
|
57
|
+
except Exception as err: # noqa: BLE001 - qualquer falha de provider
|
|
58
|
+
if attempt == self.retries:
|
|
59
|
+
raise SummarizerError(
|
|
60
|
+
f"LLM falhou apos {self.retries + 1} tentativas: {err}"
|
|
61
|
+
) from err
|
|
62
|
+
self._sleep(2**attempt)
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
self.spent += _estimate_tokens(prompt) + _estimate_tokens(text)
|
|
66
|
+
if self.spent > self.max_tokens:
|
|
67
|
+
raise BudgetExceeded(
|
|
68
|
+
f"orcamento de {self.max_tokens} tokens estourado "
|
|
69
|
+
f"(~{self.spent} usados)"
|
|
70
|
+
)
|
|
71
|
+
return text
|
|
72
|
+
|
|
73
|
+
raise AssertionError("inalcancavel")
|
|
74
|
+
|
|
75
|
+
def _module_prompt(self, module: ModuleContext) -> str:
|
|
76
|
+
parts = [f"Modulo: {module.name}"]
|
|
77
|
+
parts.append("Arquivos:\n" + "\n".join(f"- {path}" for path in module.files))
|
|
78
|
+
if module.symbols:
|
|
79
|
+
parts.append(
|
|
80
|
+
"Simbolos:\n"
|
|
81
|
+
+ "\n".join(
|
|
82
|
+
f"- {symbol.name} ({symbol.kind}) em {symbol.file}:{symbol.line}"
|
|
83
|
+
for symbol in module.symbols
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
if module.imports_out:
|
|
87
|
+
parts.append("Depende de: " + ", ".join(module.imports_out))
|
|
88
|
+
if module.imports_in:
|
|
89
|
+
parts.append("E usado por: " + ", ".join(module.imports_in))
|
|
90
|
+
if module.snippets:
|
|
91
|
+
parts.append("Snippets (redigidos):\n" + "\n\n".join(module.snippets))
|
|
92
|
+
parts.append(
|
|
93
|
+
"Explique o proposito deste modulo em 3 a 6 frases. "
|
|
94
|
+
"Cite arquivo:linha ao afirmar algo sobre o codigo."
|
|
95
|
+
)
|
|
96
|
+
return "\n\n".join(parts)
|
|
97
|
+
|
|
98
|
+
def summarize_module(self, module: ModuleContext) -> str:
|
|
99
|
+
return self._complete(self._module_prompt(module))
|
|
100
|
+
|
|
101
|
+
def _repo_prompt(self, ctx: RepoContext) -> str:
|
|
102
|
+
parts = [f"Repositorio: {ctx.name}"]
|
|
103
|
+
parts.append(f"Arquivos: {ctx.file_count}. Linguagens: {', '.join(ctx.languages)}.")
|
|
104
|
+
parts.append("Resumos por modulo:\n\n" + "\n\n".join(ctx.module_summaries))
|
|
105
|
+
parts.append(
|
|
106
|
+
"Com base apenas nos resumos acima, escreva uma visao geral da arquitetura "
|
|
107
|
+
"em 2 a 4 paragrafos. Nao invente fatos nem arquivos."
|
|
108
|
+
)
|
|
109
|
+
return "\n\n".join(parts)
|
|
110
|
+
|
|
111
|
+
def summarize_repo(self, ctx: RepoContext) -> str:
|
|
112
|
+
return self._complete(self._repo_prompt(ctx))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# Acima disso o arquivo não vai inteiro para o LLM: vira esqueleto.
|
|
4
|
+
MAX_LINES_FOR_LLM = 1500
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Reduz um arquivo grande às suas "costuras": imports + linhas de declaração de
|
|
8
|
+
# símbolos (que o grafo já conhece), omitindo corpos.
|
|
9
|
+
# Por quê: mandar 5k linhas estoura a context window e encarece a chamada.
|
|
10
|
+
def skeletonize(text: str, symbols: list[dict]) -> str:
|
|
11
|
+
lines = text.splitlines()
|
|
12
|
+
if len(lines) <= MAX_LINES_FOR_LLM:
|
|
13
|
+
return text
|
|
14
|
+
|
|
15
|
+
kept: dict[int, str] = {}
|
|
16
|
+
for number, line in enumerate(lines, start=1):
|
|
17
|
+
stripped = line.lstrip()
|
|
18
|
+
# Imports dão o "de onde vem" sem custo de corpo.
|
|
19
|
+
if stripped.startswith(("import ", "from ")):
|
|
20
|
+
kept[number] = line
|
|
21
|
+
|
|
22
|
+
# Assinaturas vêm do grafo (line já é 1-based).
|
|
23
|
+
for symbol in symbols:
|
|
24
|
+
number = symbol.get("line", 0)
|
|
25
|
+
if 1 <= number <= len(lines):
|
|
26
|
+
kept[number] = lines[number - 1]
|
|
27
|
+
|
|
28
|
+
return "\n".join(kept[number] for number in sorted(kept))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reponexia
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI que explica um repositório desconhecido
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/rhyan05/reponexia
|
|
7
|
+
Project-URL: Repository, https://github.com/rhyan05/reponexia
|
|
8
|
+
Project-URL: Issues, https://github.com/rhyan05/reponexia/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: jsonschema>=4.0
|
|
17
|
+
Requires-Dist: reponexia-core==0.1.*
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Provides-Extra: llm
|
|
21
|
+
Requires-Dist: litellm; extra == "llm"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# RepoNexia
|
|
25
|
+
|
|
26
|
+
CLI que explica um repositório desconhecido: mapeia arquitetura, entry points e
|
|
27
|
+
dependências críticas, e gera um onboarding doc.
|
|
28
|
+
|
|
29
|
+
Motor em Rust (`reponexia-core`) faz o scan/parse e monta o grafo; o orquestrador
|
|
30
|
+
em Python chama o LLM e renderiza a documentação. A fronteira é um JSON
|
|
31
|
+
versionado (`src/reponexia/graph.schema.json`).
|
|
32
|
+
|
|
33
|
+
## Build e instalação
|
|
34
|
+
|
|
35
|
+
**Via PyPI** (após o release):
|
|
36
|
+
```bash
|
|
37
|
+
pip install reponexia
|
|
38
|
+
pip install "reponexia[llm]" # LLM real (litellm)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**A partir do código-fonte** (clone):
|
|
42
|
+
```bash
|
|
43
|
+
python -m venv .venv
|
|
44
|
+
# .venv\Scripts\Activate.ps1 (Windows) | source .venv/bin/activate (Unix)
|
|
45
|
+
|
|
46
|
+
pip install -e . # usa o wheel publicado do core (sem Rust)
|
|
47
|
+
# ou, para compilar/testar o core local:
|
|
48
|
+
pip install ./core
|
|
49
|
+
pip install -e ".[llm]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
O Python descobre o binário nesta ordem: `REPONEXIA_CORE` → `Scripts/bin` do
|
|
53
|
+
ambiente → `PATH` → `core/target/{release,debug}`.
|
|
54
|
+
|
|
55
|
+
## Uso
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
reponexia guia # guia de uso no terminal
|
|
59
|
+
reponexia map <path> # só o core; imprime o graph.json
|
|
60
|
+
reponexia graph <path> --format mermaid # exporta o grafo (json|mermaid)
|
|
61
|
+
reponexia explain <path> --dry-run # esqueleto estrutural, sem LLM
|
|
62
|
+
reponexia explain <path> --model openai/gpt-4o-mini
|
|
63
|
+
reponexia ask <path> "onde fica a autenticação?"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
O mesmo guia está em [`docs/USAGE.md`](docs/USAGE.md) (gerado por
|
|
67
|
+
`reponexia guia --markdown`).
|
|
68
|
+
|
|
69
|
+
No modo real o modelo vem de `--model` ou `REPONEXIA_MODEL`. `--max-tokens`
|
|
70
|
+
limita o orçamento; sem mudança no repo, o cache evita novas chamadas LLM.
|
|
71
|
+
|
|
72
|
+
Saída padrão em `<path>/.reponexia/`: `graph.json`, `ONBOARDING.md` e
|
|
73
|
+
`architecture.mmd` (`--out` sobrescreve o diretório).
|
|
74
|
+
|
|
75
|
+
Exemplo de saída: [`examples/ONBOARDING.md`](examples/ONBOARDING.md).
|
|
76
|
+
|
|
77
|
+
## Segurança
|
|
78
|
+
|
|
79
|
+
Antes de qualquer chamada LLM, snippets passam por `redact.py` (AWS, OpenAI,
|
|
80
|
+
GitHub, Slack, chave privada e connection strings). O scan ignora `.gitignore`,
|
|
81
|
+
dotfiles e `.reponexia`.
|
|
82
|
+
|
|
83
|
+
## Testes
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
cargo test --manifest-path core/Cargo.toml
|
|
87
|
+
pytest
|
|
88
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
reponexia/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
reponexia/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
reponexia/ask.py,sha256=MmNwvOHu8IRHiCAl2pxWDFhvuUHhl-JQOVzk-E11i7M,1661
|
|
4
|
+
reponexia/cache.py,sha256=3aPatv0hTMQd-Gl7R8vYgDdjR2-72bXJ4eSdicnWpaQ,1200
|
|
5
|
+
reponexia/cli.py,sha256=NR277b0LW17L7N4H-YcdGEghrPgif81VDFYQAY-VpvA,6224
|
|
6
|
+
reponexia/contract.py,sha256=7uZNdHGJYmQA-Kz8fNtznbvO16HV0omdOAHoXZ9H4Ag,1224
|
|
7
|
+
reponexia/core_runner.py,sha256=UJ0QgrUoqQJtnM2_ZRjYSC3Ltmat8wqjHE8i23GuBJc,1788
|
|
8
|
+
reponexia/graph.schema.json,sha256=f0seWkYZaML5uBrw3KVsz5fzolDe586hJjWVAYsZ_U8,3686
|
|
9
|
+
reponexia/guide.py,sha256=UupeNAVSzUn7W1Rs1eSGrXSiqjDK1EMM8Tj0CsKIxOk,3545
|
|
10
|
+
reponexia/modules.py,sha256=2pBDeh4mZLk7AzVDJdMEx6YLl8t99pMq3-acwvCNujo,804
|
|
11
|
+
reponexia/pipeline.py,sha256=D_wCblplFWQCgwZMM-zQIPLlue3LuEJYpch1POBZo5g,3408
|
|
12
|
+
reponexia/redact.py,sha256=sPeN5INPgKjXSVcYrHcjXsfSmzLJERzkaTN6zpSmSVM,1235
|
|
13
|
+
reponexia/render/__init__.py,sha256=LYnbbSoB_oZMK53csmve1YuadzHLYg-vXe253T2VCTA,123
|
|
14
|
+
reponexia/render/markdown.py,sha256=hetup0KN8W2IG76jzr8OIb-R9UhLrHpczx6KIoclyVQ,4906
|
|
15
|
+
reponexia/render/mermaid.py,sha256=C54G1uNEMOF5U4S9iuUo0AJEcn_eeV00IUXDJ_tToy4,1818
|
|
16
|
+
reponexia/summarize/__init__.py,sha256=IDsQ85V8Z0VSAUy8YEfyEjmguExsyY29HxMc-FyTD5E,139
|
|
17
|
+
reponexia/summarize/base.py,sha256=eP4H2OjsVRBrG306vAJH3pnf5P4CKBY60pvXDJlYKtE,932
|
|
18
|
+
reponexia/summarize/context.py,sha256=MiUHqUqFB3cCSf-T7jBcDus5YXp9kQvcGlBShGfOOJU,2057
|
|
19
|
+
reponexia/summarize/litellm_impl.py,sha256=X8o3hZI1QTKaqw49Uyy7AcCamfgHewWzecPo5BLdT_8,4153
|
|
20
|
+
reponexia/summarize/skeleton.py,sha256=nynCSQah8M3xcqzeidWGzpM1VHuLOX5uWikOKfMU9_o,1021
|
|
21
|
+
reponexia-0.1.0.dist-info/licenses/LICENSE,sha256=FZArt7fexl-UElv3m5k5vXGZU4AdMbvLJNL82DLXTIQ,1100
|
|
22
|
+
reponexia-0.1.0.dist-info/METADATA,sha256=44pHszslHckiwWCWhQu7KBFtrSAvgiWPjXTsMhNqulE,2971
|
|
23
|
+
reponexia-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
24
|
+
reponexia-0.1.0.dist-info/entry_points.txt,sha256=gYlw4Dspp5OaIuFWnrQc9gxclHE3Dl0A9_ea-SLpJrI,49
|
|
25
|
+
reponexia-0.1.0.dist-info/top_level.txt,sha256=mMigYfHgWU2gwKnE1APLF3YVVy12gqXO4hoPL5LMAHw,10
|
|
26
|
+
reponexia-0.1.0.dist-info/RECORD,,
|