Files
micro-scout/src/micro_scout/symbols.py
T

211 lines
6.9 KiB
Python

"""Bounded source parsing and conservative static relationships."""
from __future__ import annotations
import ast
import os
import subprocess
from collections import defaultdict
from dataclasses import asdict, dataclass
from pathlib import Path
from micro_scout.text import code_text, digest
EXTENSIONS = {
".py": "python",
".js": "javascript",
".jsx": "javascript",
".ts": "typescript",
".tsx": "typescript",
".go": "go",
".rs": "rust",
".java": "java",
".c": "c",
".h": "c",
".cpp": "cpp",
".cs": "csharp",
".rb": "ruby",
".php": "php",
".md": "markdown",
".toml": "toml",
".yaml": "yaml",
".yml": "yaml",
}
EXCLUDED = {
".git",
".venv",
"venv",
"node_modules",
"vendor",
"dist",
"build",
"__pycache__",
".micro-scout",
".mypy_cache",
".pytest_cache",
}
@dataclass(frozen=True)
class Symbol:
id: str
path: str
name: str
kind: str
language: str
start_line: int
end_line: int
content: str
file_hash: str
parent: str | None = None
@property
def model_text(self) -> str:
return code_text(self.content, self.language)
@property
def lexical_text(self) -> str:
return f"{self.path}\n{self.name}\n{self.content}"
def to_dict(self) -> dict:
return asdict(self)
def source_paths(root: Path, max_file_bytes: int = 1_000_000) -> list[Path]:
"""Honor gitignore when available; never traverse symlinks or hidden trees."""
root = root.resolve(strict=True)
if not root.is_dir():
raise ValueError("Repository root must be a directory")
try:
result = subprocess.run(
[
"git",
"-C",
str(root),
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"-z",
],
check=True,
capture_output=True,
timeout=20,
)
paths = [root / os.fsdecode(p) for p in result.stdout.split(b"\0") if p]
except (subprocess.CalledProcessError, FileNotFoundError):
paths = []
for directory, names, files in os.walk(root, followlinks=False):
names[:] = [
n
for n in names
if n not in EXCLUDED
and not n.startswith(".")
and not (Path(directory) / n).is_symlink()
]
paths.extend(Path(directory) / name for name in files)
selected = []
for path in sorted(set(paths)):
relative = path.relative_to(root)
if any(part in EXCLUDED or part.startswith(".") for part in relative.parts):
continue
if path.suffix.lower() not in EXTENSIONS or path.is_symlink() or not path.is_file():
continue
if any(parent.is_symlink() for parent in path.parents if parent != root.parent):
continue
if not path.resolve().is_relative_to(root) or path.stat().st_size > max_file_bytes:
continue
selected.append(path)
return selected
def parse_source(relative: str, text: str, *, chunk_lines: int = 60) -> list[Symbol]:
if chunk_lines < 1:
raise ValueError("chunk_lines must be positive")
language = EXTENSIONS.get(Path(relative).suffix.lower(), "text")
lines = text.splitlines()
file_hash = digest(text)
symbols = []
def add(name, kind, start, end, parent=None):
content = "\n".join(lines[start - 1 : end])
symbol_id = digest(f"{relative}:{name}:{start}:{end}:{file_hash}")[:24]
symbol = Symbol(
symbol_id, relative, name, kind, language, start, end, content, file_hash, parent
)
symbols.append(symbol)
return symbol
if language == "python":
try:
tree = ast.parse(text)
def walk(node, prefix="", parent=None):
for child in ast.iter_child_nodes(node):
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
name = f"{prefix}.{child.name}" if prefix else child.name
start = min([child.lineno] + [d.lineno for d in child.decorator_list])
symbol = add(
name,
"class" if isinstance(child, ast.ClassDef) else "function",
start,
child.end_lineno,
parent,
)
walk(child, name, symbol.id)
else:
walk(child, prefix, parent)
walk(tree)
covered = set()
for symbol in symbols:
covered.update(range(symbol.start_line, symbol.end_line + 1))
# Module-level imports/constants are useful context too.
start = None
for number in range(1, len(lines) + 2):
uncovered = number <= len(lines) and number not in covered
if uncovered and start is None:
start = number
if start is not None and (not uncovered or number - start >= chunk_lines):
if any(line.strip() for line in lines[start - 1 : number - 1]):
add(f"<module:{start}>", "module", start, number - 1)
start = number if uncovered else None
return symbols
except (SyntaxError, ValueError, RecursionError):
symbols.clear()
for start in range(0, len(lines), chunk_lines):
end = min(start + chunk_lines, len(lines))
if any(line.strip() for line in lines[start:end]):
add(f"<chunk:{start + 1}>", "chunk", start + 1, end)
return symbols
def build_edges(symbols: list[Symbol]) -> list[dict]:
"""Containment and unambiguous same-module bare-name calls only.
Attribute calls, cross-module resolution, dynamic dispatch, and name shadowing
are not claimed to be fully resolved. Calls are explicitly approximate.
"""
names = defaultdict(list)
for s in symbols:
if s.kind == "function" and "." not in s.name:
names[(s.path, s.name)].append(s.id)
edges = set()
for symbol in symbols:
if symbol.parent:
edges.add((symbol.parent, symbol.id, "contains"))
if symbol.language != "python" or symbol.kind != "function":
continue
try:
import textwrap
tree = ast.parse(textwrap.dedent(symbol.content))
except (SyntaxError, ValueError):
continue
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
targets = names[(symbol.path, node.func.id)]
if len(targets) == 1 and targets[0] != symbol.id:
edges.add((symbol.id, targets[0], "possible_call"))
return [{"source": s, "target": t, "kind": k} for s, t, k in sorted(edges)]