Coder preparations
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Idempotent repository entrypoint analyzer (LLMClient edition).
|
||||
|
||||
Flow:
|
||||
1. List repo files (deterministic walk).
|
||||
2. Ask LLM for primary language.
|
||||
3. Loop:
|
||||
a. Ask LLM to pick ONE candidate file.
|
||||
b. Read an excerpt.
|
||||
c. Ask LLM: is it the entrypoint / does it forward to another file / wrong?
|
||||
d. On "entrypoint" -> stop. On "forward" -> probe next_file. On "wrong" -> pick again.
|
||||
|
||||
All LLM calls go through the project's `LLMClient.chat_with_schema` with a
|
||||
strict JSON schema, validated against a Pydantic model. Validation failures are
|
||||
retried at most 2 times (per project convention). Successful responses are
|
||||
cached keyed on (schema_name, system, user) so re-running against the same
|
||||
repository is idempotent and makes zero additional LLM calls.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Literal, Optional, Type, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, TypeAdapter
|
||||
|
||||
from agents.coders.repo import RepoContext
|
||||
from common.llm_client import LLMClient
|
||||
from contracts.RepoContext import Language, LanguageLiteral
|
||||
from contracts.coders.VerifyOutput import sanitize_for_openai
|
||||
|
||||
log = logging.getLogger("entrypoint_analyzer")
|
||||
|
||||
MAX_LLM_RETRIES = 2 # retries AFTER the initial attempt (so <= 3 total)
|
||||
EXCERPT_CHARS = 4_000
|
||||
MAX_ITERATIONS = 40
|
||||
MAX_LISTING_ENTRIES = 2_000
|
||||
|
||||
IGNORED_DIRS = {
|
||||
".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
|
||||
"env", "dist", "build", ".idea", ".vscode", ".mypy_cache", ".pytest_cache",
|
||||
".tox", "target", "out", "obj", "bin", "coverage",
|
||||
}
|
||||
IGNORED_EXTS = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip", ".tar", ".gz", ".lock",
|
||||
".pyc", ".so", ".dll", ".dylib", ".exe", ".woff", ".woff2", ".ico",
|
||||
".svg", ".mp4", ".mp3", ".class", ".jar",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pydantic schemas #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class LanguageGuess(BaseModel):
|
||||
"""Primary language of the repository."""
|
||||
language: LanguageLiteral = Field(description="Primary programming language, lowercase (e.g. 'python').")
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
reasoning: str
|
||||
|
||||
|
||||
class FilePick(BaseModel):
|
||||
"""A single candidate file to inspect, chosen from the listing."""
|
||||
file_path: str = Field(description="Relative path exactly as it appears in the listing.")
|
||||
reason: str
|
||||
|
||||
|
||||
class ProbeVerdict(BaseModel):
|
||||
"""Verdict for the currently inspected file."""
|
||||
verdict: Literal["entrypoint", "forward", "wrong"]
|
||||
next_file: Optional[str] = Field(
|
||||
default=None,
|
||||
description="If verdict='forward': the next file to inspect (must be in listing).",
|
||||
)
|
||||
reason: str
|
||||
|
||||
|
||||
class Attempt(BaseModel):
|
||||
file_path: str
|
||||
excerpt: str
|
||||
verdict: Optional[ProbeVerdict] = None
|
||||
|
||||
|
||||
class AnalysisResult(BaseModel):
|
||||
language: Language
|
||||
entrypoint: str
|
||||
attempts: List[Attempt]
|
||||
reasoning: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# LLM plumbing: LLMClient wrapper with strict schema + cache + retries #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
|
||||
class ResponseCache:
|
||||
"""File-backed cache: prompt-hash -> validated-model JSON. Makes runs idempotent."""
|
||||
|
||||
def __init__(self, path: Optional[Path] = None) -> None:
|
||||
self.path = path
|
||||
self._mem: dict[str, str] = {}
|
||||
if path and path.exists():
|
||||
try:
|
||||
self._mem = json.loads(path.read_text("utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning("Corrupt cache at %s; starting fresh", path)
|
||||
|
||||
@staticmethod
|
||||
def key(system: str, user: str, schema: Type[BaseModel]) -> str:
|
||||
h = hashlib.sha256()
|
||||
h.update(schema.__name__.encode())
|
||||
h.update(b"\x00")
|
||||
h.update(system.encode())
|
||||
h.update(b"\x00")
|
||||
h.update(user.encode())
|
||||
return h.hexdigest()
|
||||
|
||||
def get(self, k: str) -> Optional[str]:
|
||||
return self._mem.get(k)
|
||||
|
||||
def put(self, k: str, v: str) -> None:
|
||||
self._mem[k] = v
|
||||
if self.path:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(self._mem, indent=2, sort_keys=True), "utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
|
||||
class StructuredLLM:
|
||||
"""
|
||||
Wrap the project's `LLMClient` with:
|
||||
|
||||
* strict JSON-schema enforcement (same shape as the reference usage),
|
||||
* Pydantic validation of the response,
|
||||
* at most MAX_LLM_RETRIES retries on validation failure,
|
||||
* a deterministic prompt-keyed cache so reruns are idempotent.
|
||||
|
||||
Usage mirrors the reference snippet but is generic over the output model.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: LLMClient,
|
||||
cache: Optional[ResponseCache] = None,
|
||||
max_retries: int = MAX_LLM_RETRIES,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.cache = cache
|
||||
self.max_retries = max_retries
|
||||
|
||||
def call(self, system: str, user: str, schema: Type[T]) -> T:
|
||||
schema_adapter = TypeAdapter(schema)
|
||||
# --- idempotency: serve from cache when prompt+system+schema match ---
|
||||
cache_key = ResponseCache.key(system, user, schema) if self.cache else None
|
||||
if cache_key and (hit := self.cache.get(cache_key)) is not None:
|
||||
try:
|
||||
return schema.model_validate_json(hit)
|
||||
except ValidationError:
|
||||
log.warning("Stale cache entry for %s; ignoring", schema.__name__)
|
||||
|
||||
# --- build the strict response_format exactly as in the reference ---
|
||||
response_format = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": schema.__name__,
|
||||
"schema": sanitize_for_openai(schema_adapter.json_schema()),
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
last_err: Optional[BaseException] = None
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
augmented_system = system
|
||||
if attempt > 0 and last_err is not None:
|
||||
augmented_system = (
|
||||
f"{system}\n\n"
|
||||
f"Your previous response failed schema validation with: {last_err}. "
|
||||
f"Return ONLY a single JSON object conforming exactly to the "
|
||||
f"provided schema — no prose, no code fences."
|
||||
)
|
||||
try:
|
||||
raw = self.client.chat_with_schema(
|
||||
user,
|
||||
response_format,
|
||||
system=augmented_system,
|
||||
)
|
||||
if isinstance(raw, str):
|
||||
raw = json.loads(raw)
|
||||
parsed = schema_adapter.validate_python(raw) # pydantic v2
|
||||
except (ValidationError, json.JSONDecodeError, ValueError) as exc:
|
||||
last_err = exc
|
||||
log.warning(
|
||||
"Schema %s failed validation (attempt %d/%d): %s",
|
||||
schema.__name__, attempt + 1, self.max_retries + 1, exc,
|
||||
)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001 (transport / API errors)
|
||||
last_err = exc
|
||||
log.warning(
|
||||
"LLM transport error for %s (attempt %d/%d): %s",
|
||||
schema.__name__, attempt + 1, self.max_retries + 1, exc,
|
||||
)
|
||||
continue
|
||||
|
||||
# --- memoize under the *original* prompt key ---------------------
|
||||
if cache_key and self.cache:
|
||||
self.cache.put(cache_key, parsed.model_dump_json())
|
||||
return parsed
|
||||
|
||||
# Exhausted retries — hard failure, per project convention.
|
||||
raise RuntimeError(
|
||||
f"LLM failed to produce a valid {schema.__name__} "
|
||||
f"after {self.max_retries + 1} attempt(s)"
|
||||
) from last_err
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Repository listing #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def list_repo(root: Path) -> List[str]:
|
||||
"""Deterministic, sorted listing of analyzable files relative to `root`."""
|
||||
root = root.resolve()
|
||||
out: List[str] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = sorted(d for d in dirnames if d not in IGNORED_DIRS)
|
||||
for fname in sorted(filenames):
|
||||
if Path(fname).suffix.lower() in IGNORED_EXTS:
|
||||
continue
|
||||
rel = (Path(dirpath) / fname).relative_to(root)
|
||||
out.append(rel.as_posix())
|
||||
return out
|
||||
|
||||
|
||||
def _listing_repr(listing: List[str], cap: int = MAX_LISTING_ENTRIES) -> str:
|
||||
if len(listing) <= cap:
|
||||
return "\n".join(listing)
|
||||
return "\n".join(listing[:cap]) + f"\n...<{len(listing) - cap} more entries truncated>..."
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Analyzer #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class EntrypointAnalyzer:
|
||||
def __init__(
|
||||
self,
|
||||
llm: StructuredLLM,
|
||||
max_iterations: int = MAX_ITERATIONS,
|
||||
excerpt_chars: int = EXCERPT_CHARS,
|
||||
) -> None:
|
||||
self.llm = llm
|
||||
self.max_iterations = max_iterations
|
||||
self.excerpt_chars = excerpt_chars
|
||||
|
||||
# -- public ------------------------------------------------------------ #
|
||||
|
||||
def analyze(self, repo_root: Path) -> AnalysisResult:
|
||||
repo_root = Path(repo_root).resolve()
|
||||
if not repo_root.is_dir():
|
||||
raise ValueError(f"Not a directory: {repo_root}")
|
||||
|
||||
listing = list_repo(repo_root)
|
||||
if not listing:
|
||||
raise ValueError(f"No analyzable files under {repo_root}")
|
||||
|
||||
guess = self._guess_language(listing)
|
||||
language: Language = guess.language
|
||||
log.info("Detected language=%s (confidence=%.2f)", language.value, guess.confidence)
|
||||
|
||||
attempts: List[Attempt] = []
|
||||
carried: Optional[str] = None # "forward" hint from a previous verdict
|
||||
|
||||
for iteration in range(1, self.max_iterations + 1):
|
||||
# Resolve to a definitely-non-None local for this iteration.
|
||||
current_file: str = (
|
||||
carried if carried is not None
|
||||
else self._pick_file(listing, language, attempts)
|
||||
)
|
||||
|
||||
if current_file not in listing:
|
||||
log.warning(
|
||||
"LLM suggested %r which is not in the listing; discarding.",
|
||||
current_file,
|
||||
)
|
||||
carried = None
|
||||
continue
|
||||
|
||||
excerpt = self._read_excerpt(repo_root / current_file)
|
||||
verdict = self._probe(language, current_file, excerpt, attempts)
|
||||
attempts.append(
|
||||
Attempt(file_path=current_file, excerpt=excerpt, verdict=verdict)
|
||||
)
|
||||
|
||||
log.info(
|
||||
"iter=%d file=%s verdict=%s reason=%s",
|
||||
iteration, current_file, verdict.verdict, verdict.reason,
|
||||
)
|
||||
|
||||
if verdict.verdict == "entrypoint":
|
||||
return AnalysisResult(
|
||||
language=language,
|
||||
entrypoint=current_file,
|
||||
attempts=attempts,
|
||||
reasoning=verdict.reason,
|
||||
)
|
||||
|
||||
if verdict.verdict == "forward" and verdict.next_file:
|
||||
carried = verdict.next_file
|
||||
else:
|
||||
if verdict.verdict == "forward":
|
||||
log.warning("Verdict 'forward' without next_file; falling back to fresh pick.")
|
||||
carried = None
|
||||
|
||||
raise RuntimeError(
|
||||
f"Entrypoint not found after {self.max_iterations} iterations. "
|
||||
f"Tried: {[a.file_path for a in attempts]}"
|
||||
)
|
||||
|
||||
# -- LLM steps --------------------------------------------------------- #
|
||||
|
||||
def _guess_language(self, listing: List[str]) -> LanguageGuess:
|
||||
system = (
|
||||
"You are a senior code analyst. Given a repository's file listing "
|
||||
"(relative paths), determine the primary programming language of "
|
||||
"the project. Weigh file extensions, conventional filenames "
|
||||
"(setup.py, package.json, Cargo.toml, go.mod, pom.xml, ...), and "
|
||||
"directory structure."
|
||||
)
|
||||
user = "Repository file listing:\n" + _listing_repr(listing)
|
||||
return self.llm.call(system, user, LanguageGuess)
|
||||
|
||||
def _pick_file(
|
||||
self, listing: List[str], language: Language, attempts: List[Attempt]
|
||||
) -> str:
|
||||
system = (
|
||||
"You are a senior code analyst. Pick exactly ONE file from the "
|
||||
"provided listing that is the most promising candidate to be, or "
|
||||
"to lead toward, the program's entrypoint. Prefer conventional "
|
||||
"entrypoint names for the detected language "
|
||||
"(main.py / __main__.py / app.py / cli.py / manage.py for Python; "
|
||||
"index.js / server.js / main.ts for JS/TS; cmd/main.go for Go; "
|
||||
"Program.cs / Startup.cs for C#; main.rs / lib.rs for Rust). "
|
||||
"You MUST NOT pick a file that has already been tried. Return the "
|
||||
"path exactly as it appears in the listing."
|
||||
)
|
||||
tried = [a.file_path for a in attempts]
|
||||
user = (
|
||||
f"Language: {language.value}\n\n"
|
||||
f"Listing:\n{_listing_repr(listing)}\n\n"
|
||||
f"Already tried (DO NOT pick these): {tried or '[]'}"
|
||||
)
|
||||
return self.llm.call(system, user, FilePick).file_path
|
||||
|
||||
def _probe(
|
||||
self,
|
||||
language: Language,
|
||||
path: str,
|
||||
excerpt: str,
|
||||
attempts: List[Attempt],
|
||||
) -> ProbeVerdict:
|
||||
system = (
|
||||
f"You are a senior code analyst locating the entrypoint of a "
|
||||
f"{language.value} project. You are shown one file at a time and must "
|
||||
f"decide exactly one of:\n"
|
||||
f" - 'entrypoint': this file IS the program's entrypoint — e.g. it "
|
||||
f"contains 'if __name__ == \"__main__\"', calls main() at module "
|
||||
f"scope, does top-level CLI dispatch, is a declared binary target, "
|
||||
f"or is the bootstrapping module itself.\n"
|
||||
f" - 'forward': this file is not the entrypoint itself but clearly "
|
||||
f"imports/dispatches into another file that is closer — set "
|
||||
f"'next_file' to that file's path (it MUST appear in the listing).\n"
|
||||
f" - 'wrong': this file is unrelated or a dead end — a different "
|
||||
f"file must be picked.\n"
|
||||
f"Be strict: only return 'entrypoint' when the file itself "
|
||||
f"bootstraps execution."
|
||||
)
|
||||
tried = [a.file_path for a in attempts]
|
||||
user = (
|
||||
f"File under inspection: {path}\n\n"
|
||||
f"--- content excerpt ---\n{excerpt}\n--- end excerpt ---\n\n"
|
||||
f"Previously tried files: {tried or '[]'}"
|
||||
)
|
||||
return self.llm.call(system, user, ProbeVerdict)
|
||||
|
||||
# -- helpers ----------------------------------------------------------- #
|
||||
|
||||
def _read_excerpt(self, path: Path) -> str:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"<unreadable: {exc}>"
|
||||
if len(text) > self.excerpt_chars:
|
||||
return text[: self.excerpt_chars] + "\n...<truncated>..."
|
||||
return text
|
||||
|
||||
|
||||
|
||||
def gather_repo_entrypoint(llm: LLMClient, repo: RepoContext, cache_path: Optional[str] = None) -> AnalysisResult:
|
||||
return EntrypointAnalyzer(
|
||||
StructuredLLM(llm, cache=ResponseCache(Path(cache_path)) if cache_path else None)
|
||||
).analyze(Path(repo.repo_dir))
|
||||
@@ -0,0 +1,850 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
|
||||
import networkx as nx
|
||||
import pandas as pd
|
||||
from networkx.algorithms.community import louvain_communities
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from common.llm_client import LLMClient
|
||||
from contracts.RepoContext import Language, ToolRequirement, DependencyGraph, DependencyNode, DependencyEdge, \
|
||||
AnalysisResult, AnalysisInput, AnalysisError
|
||||
|
||||
|
||||
def find_tool(command: str) -> Optional[str]:
|
||||
"""Return the absolute path to `command` if it is on PATH, else None."""
|
||||
return shutil.which(command)
|
||||
|
||||
TOOL_REGISTRY: dict[Language, ToolRequirement] = {
|
||||
Language.PYTHON: ToolRequirement(
|
||||
tool_name="pydeps",
|
||||
command="pydeps",
|
||||
install_guide="""\
|
||||
## Install pydeps
|
||||
|
||||
```bash
|
||||
pip install pydeps
|
||||
You also need Graphviz for rendering:
|
||||
Ubuntu/Debian: sudo apt install graphviz
|
||||
macOS: brew install graphviz
|
||||
Windows: Download from https://graphviz.org/download/
|
||||
Ensure the dot command is on your PATH.""",
|
||||
docs_url="https://github.com/thebjorn/pydeps",
|
||||
),
|
||||
Language.JAVASCRIPT: ToolRequirement(
|
||||
tool_name="madge",
|
||||
command="madge",
|
||||
install_guide="""\
|
||||
Install madge
|
||||
bash
|
||||
npm -g install madge
|
||||
Graphviz (optional, for image output):
|
||||
Ubuntu/Debian: sudo apt install graphviz
|
||||
macOS: brew install graphviz""",
|
||||
docs_url="https://github.com/pahen/madge",
|
||||
),
|
||||
Language.TYPESCRIPT: ToolRequirement(
|
||||
tool_name="madge",
|
||||
command="madge",
|
||||
install_guide="""\
|
||||
|
||||
Install madge (TypeScript support)
|
||||
bash
|
||||
npm -g install madge
|
||||
For TypeScript projects, ensure typescript is also installed:
|
||||
|
||||
bash
|
||||
npm install -g typescript
|
||||
```""",
|
||||
docs_url="https://github.com/pahen/madge",
|
||||
),
|
||||
Language.JAVA: ToolRequirement(
|
||||
tool_name="mvn",
|
||||
command="mvn",
|
||||
install_guide="""\
|
||||
## Java dependency analysis
|
||||
|
||||
**Maven:**
|
||||
```bash
|
||||
mvn dependency:tree -DoutputFile=deps.txt
|
||||
Gradle:
|
||||
|
||||
bash
|
||||
gradle dependencies > deps.txt
|
||||
Install Maven: https://maven.apache.org/install.html
|
||||
Install Gradle: https://gradle.org/install/[reference:9]""",
|
||||
docs_url="https://maven.apache.org/plugins/maven-dependency-plugin/",
|
||||
),
|
||||
Language.GO: ToolRequirement(
|
||||
tool_name="go",
|
||||
command="go",
|
||||
install_guide="""\
|
||||
|
||||
Install Go
|
||||
The Go toolchain provides built-in dependency listing:
|
||||
|
||||
bash
|
||||
# Full module graph
|
||||
go mod graph
|
||||
|
||||
# All modules (direct + indirect)
|
||||
go list -m all
|
||||
|
||||
# Package import graph
|
||||
go list -f '{{.ImportPath}} {{.Imports}}' ./...
|
||||
Install Go: https://go.dev/doc/install[reference:10]""",
|
||||
docs_url="https://go.dev/ref/mod#go-list-m",
|
||||
),
|
||||
Language.RUST: ToolRequirement(
|
||||
tool_name="cargo",
|
||||
command="cargo",
|
||||
install_guide="""\
|
||||
Install Rust / Cargo
|
||||
cargo tree is built-in since Rust 1.44:
|
||||
|
||||
bash
|
||||
cargo tree
|
||||
cargo tree --depth 1 # direct deps only
|
||||
cargo tree -i <crate> # reverse deps
|
||||
Install Rust: https://www.rust-lang.org/tools/install[reference:11]""",
|
||||
docs_url="https://doc.rust-lang.org/cargo/commands/cargo-tree.html",
|
||||
),
|
||||
Language.CPP: ToolRequirement(
|
||||
tool_name="include-what-you-use",
|
||||
command="include-what-you-use",
|
||||
install_guide="""\
|
||||
|
||||
Install include-what-you-use (IWYU)
|
||||
Ubuntu/Debian: sudo apt-get install iwyu
|
||||
macOS: brew install include-what-you-use
|
||||
Arch: sudo pacman -S include-what-you-use
|
||||
Ensure include-what-you-use is on your PATH.""",
|
||||
docs_url="https://include-what-you-use.org/",
|
||||
),
|
||||
Language.C: ToolRequirement(
|
||||
tool_name="include-what-you-use",
|
||||
command="include-what-you-use",
|
||||
install_guide="""\
|
||||
Install include-what-you-use (IWYU)
|
||||
Ubuntu/Debian: sudo apt-get install iwyu
|
||||
macOS: brew install include-what-you-use
|
||||
Arch: sudo pacman -S include-what-you-use""",
|
||||
docs_url="https://include-what-you-use.org/",
|
||||
),
|
||||
}
|
||||
|
||||
class LanguageAdapter(ABC):
|
||||
"""Contract for every language-specific analyzer."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def language(self) -> Language: ...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def required_tools(self) -> list[ToolRequirement]: ...
|
||||
|
||||
@abstractmethod
|
||||
def build_graph(
|
||||
self, repo_path: Path, entrypoint: str
|
||||
) -> DependencyGraph: ...
|
||||
|
||||
class PythonAdapter(LanguageAdapter):
|
||||
@property
|
||||
def language(self) -> Language:
|
||||
return Language.PYTHON
|
||||
|
||||
@property
|
||||
def required_tools(self) -> list[ToolRequirement]:
|
||||
return [TOOL_REGISTRY[Language.PYTHON]]
|
||||
|
||||
def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph:
|
||||
entry_abs = repo_path / entrypoint
|
||||
|
||||
# pydeps emits JSON when --show-deps is used
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pydeps",
|
||||
str(entry_abs),
|
||||
"--show-deps",
|
||||
"--no-output",
|
||||
"--no-config",
|
||||
"-T", "json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_path),
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"pydeps failed (rc={result.returncode}): {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
raw = json.loads(result.stdout)
|
||||
nodes: list[DependencyNode] = []
|
||||
edges: list[DependencyEdge] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add_node(name: str, external: bool = False) -> None:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
nodes.append(
|
||||
DependencyNode(
|
||||
id=name,
|
||||
label=name.split(".")[-1],
|
||||
language=Language.PYTHON,
|
||||
is_external=external,
|
||||
)
|
||||
)
|
||||
|
||||
_add_node(entrypoint)
|
||||
for mod_name, info in raw.items():
|
||||
_add_node(mod_name, external=info.get("external", False))
|
||||
for imported in info.get("imports", []):
|
||||
_add_node(imported)
|
||||
edges.append(
|
||||
DependencyEdge(source=mod_name, target=imported, kind="import")
|
||||
)
|
||||
|
||||
return DependencyGraph(
|
||||
root=entrypoint,
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
metadata={"tool": "pydeps", "entrypoint": entrypoint},
|
||||
)
|
||||
|
||||
class JsTsAdapter(LanguageAdapter):
|
||||
def __init__(self, language: Language) -> None:
|
||||
self._language = language
|
||||
|
||||
@property
|
||||
def language(self) -> Language:
|
||||
return self._language
|
||||
|
||||
@property
|
||||
def required_tools(self) -> list[ToolRequirement]:
|
||||
return [TOOL_REGISTRY[self._language]]
|
||||
|
||||
def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph:
|
||||
entry_abs = repo_path / entrypoint
|
||||
|
||||
cmd = ["madge", "--json", str(entry_abs), "--basedir", str(repo_path)]
|
||||
if self._language == Language.TYPESCRIPT:
|
||||
cmd.append("--ts-config")
|
||||
cmd.append(str(repo_path / "tsconfig.json"))
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_path),
|
||||
timeout=180,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"madge failed (rc={result.returncode}): {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
raw: dict[str, list[str]] = json.loads(result.stdout)
|
||||
nodes: list[DependencyNode] = []
|
||||
edges: list[DependencyEdge] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add_node(name: str) -> None:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
nodes.append(
|
||||
DependencyNode(
|
||||
id=name,
|
||||
label=Path(name).name,
|
||||
language=self._language,
|
||||
is_external=not name.startswith("."),
|
||||
)
|
||||
)
|
||||
|
||||
_add_node(entrypoint)
|
||||
for src, deps in raw.items():
|
||||
_add_node(src)
|
||||
for dep in deps:
|
||||
_add_node(dep)
|
||||
edges.append(
|
||||
DependencyEdge(source=src, target=dep, kind="import")
|
||||
)
|
||||
|
||||
return DependencyGraph(
|
||||
root=entrypoint,
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
metadata={"tool": "madge", "entrypoint": entrypoint},
|
||||
)
|
||||
|
||||
class GoAdapter(LanguageAdapter):
|
||||
@property
|
||||
def language(self) -> Language:
|
||||
return Language.GO
|
||||
|
||||
@property
|
||||
def required_tools(self) -> list[ToolRequirement]:
|
||||
return [TOOL_REGISTRY[Language.GO]]
|
||||
|
||||
def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"go", "list",
|
||||
"-f", "{{.ImportPath}} {{join .Imports \" \"}}",
|
||||
"./...",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_path),
|
||||
timeout=180,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"go list failed (rc={result.returncode}): {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
nodes: list[DependencyNode] = []
|
||||
edges: list[DependencyEdge] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add_node(name: str) -> None:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
nodes.append(
|
||||
DependencyNode(
|
||||
id=name,
|
||||
label=name.split("/")[-1],
|
||||
language=Language.GO,
|
||||
is_external="/" in name and not name.startswith("."),
|
||||
)
|
||||
)
|
||||
|
||||
for line in result.stdout.strip().splitlines():
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
pkg = parts[0]
|
||||
_add_node(pkg)
|
||||
for imp in parts[1:]:
|
||||
_add_node(imp)
|
||||
edges.append(
|
||||
DependencyEdge(source=pkg, target=imp, kind="import")
|
||||
)
|
||||
|
||||
return DependencyGraph(
|
||||
root=entrypoint,
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
metadata={"tool": "go list", "entrypoint": entrypoint},
|
||||
)
|
||||
|
||||
|
||||
class RustAdapter(LanguageAdapter):
|
||||
@property
|
||||
def language(self) -> Language:
|
||||
return Language.RUST
|
||||
|
||||
@property
|
||||
def required_tools(self) -> list[ToolRequirement]:
|
||||
return [TOOL_REGISTRY[Language.RUST]]
|
||||
|
||||
def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph:
|
||||
result = subprocess.run(
|
||||
["cargo", "tree", "--prefix", "none", "--format", "{p} {f}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_path),
|
||||
timeout=180,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"cargo tree failed (rc={result.returncode}): {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
nodes: list[DependencyNode] = []
|
||||
edges: list[DependencyEdge] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add_node(name: str) -> None:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
nodes.append(
|
||||
DependencyNode(
|
||||
id=name,
|
||||
label=name,
|
||||
language=Language.RUST,
|
||||
is_external=False, # refined below
|
||||
)
|
||||
)
|
||||
|
||||
# cargo tree --prefix none outputs one package per line,
|
||||
# with indentation indicating depth. We parse the flat form
|
||||
# and reconstruct edges from indentation.
|
||||
lines = result.stdout.strip().splitlines()
|
||||
stack: list[str] = []
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip("│ ├─└ "))
|
||||
name = line.strip().split(" ")[0]
|
||||
_add_node(name)
|
||||
while len(stack) > indent:
|
||||
stack.pop()
|
||||
if stack:
|
||||
edges.append(
|
||||
DependencyEdge(source=stack[-1], target=name, kind="depends_on")
|
||||
)
|
||||
stack.append(name)
|
||||
|
||||
return DependencyGraph(
|
||||
root=entrypoint,
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
metadata={"tool": "cargo tree", "entrypoint": entrypoint},
|
||||
)
|
||||
|
||||
|
||||
class JavaAdapter(LanguageAdapter):
|
||||
@property
|
||||
def language(self) -> Language:
|
||||
return Language.JAVA
|
||||
|
||||
@property
|
||||
def required_tools(self) -> list[ToolRequirement]:
|
||||
return [TOOL_REGISTRY[Language.JAVA]]
|
||||
|
||||
def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph:
|
||||
if (repo_path / "pom.xml").exists():
|
||||
return self._maven_graph(repo_path, entrypoint)
|
||||
if (repo_path / "build.gradle").exists() or (repo_path / "build.gradle.kts").exists():
|
||||
return self._gradle_graph(repo_path, entrypoint)
|
||||
raise RuntimeError("No pom.xml or build.gradle found in repository root")
|
||||
|
||||
def _maven_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph:
|
||||
result = subprocess.run(
|
||||
["mvn", "dependency:tree", "-DoutputType=text"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_path),
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"mvn dependency:tree failed: {result.stderr.strip()}")
|
||||
return self._parse_tree_text(result.stdout, entrypoint)
|
||||
|
||||
def _gradle_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph:
|
||||
result = subprocess.run(
|
||||
["gradle", "dependencies", "--configuration", "runtimeClasspath"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_path),
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"gradle dependencies failed: {result.stderr.strip()}")
|
||||
return self._parse_tree_text(result.stdout, entrypoint)
|
||||
|
||||
def _parse_tree_text(self, text: str, entrypoint: str) -> DependencyGraph:
|
||||
# Simplified parser for the textual tree format.
|
||||
# In production, use the JSON output of the Maven/Gradle plugin.
|
||||
nodes: list[DependencyNode] = []
|
||||
edges: list[DependencyEdge] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add_node(name: str) -> None:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
nodes.append(
|
||||
DependencyNode(
|
||||
id=name,
|
||||
label=name.split(":")[-1] if ":" in name else name,
|
||||
language=Language.JAVA,
|
||||
is_external=True,
|
||||
)
|
||||
)
|
||||
|
||||
_add_node(entrypoint)
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("+-") or stripped.startswith("\\-"):
|
||||
continue
|
||||
# crude: treat every artifact line as a dependency of the root
|
||||
name = stripped.split(":")[0]
|
||||
if name and name != entrypoint:
|
||||
_add_node(name)
|
||||
edges.append(
|
||||
DependencyEdge(source=entrypoint, target=name, kind="depends_on")
|
||||
)
|
||||
|
||||
return DependencyGraph(
|
||||
root=entrypoint,
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
metadata={"tool": "maven/gradle", "entrypoint": entrypoint},
|
||||
)
|
||||
|
||||
|
||||
class CppAdapter(LanguageAdapter):
|
||||
@property
|
||||
def language(self) -> Language:
|
||||
return Language.CPP
|
||||
|
||||
@property
|
||||
def required_tools(self) -> list[ToolRequirement]:
|
||||
return [TOOL_REGISTRY[Language.CPP]]
|
||||
|
||||
def build_graph(self, repo_path: Path, entrypoint: str) -> DependencyGraph:
|
||||
entry_abs = repo_path / entrypoint
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"include-what-you-use",
|
||||
"-Xiwyu", "--no_fwd_decls",
|
||||
str(entry_abs),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_path),
|
||||
timeout=180,
|
||||
)
|
||||
# IWYU returns non-zero when it recommends changes; parse stdout anyway
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
nodes: list[DependencyNode] = []
|
||||
edges: list[DependencyEdge] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add_node(name: str) -> None:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
nodes.append(
|
||||
DependencyNode(
|
||||
id=name,
|
||||
label=Path(name).name,
|
||||
language=Language.CPP,
|
||||
is_external=not name.startswith("."),
|
||||
)
|
||||
)
|
||||
|
||||
_add_node(entrypoint)
|
||||
# IWYU output contains lines like:
|
||||
# #include <vector> // for ...
|
||||
# #include "foo.h" // for ...
|
||||
import re
|
||||
pattern = re.compile(r'#include\s+[<"]([^>"]+)[>"]')
|
||||
for match in pattern.finditer(output):
|
||||
header = match.group(1)
|
||||
_add_node(header)
|
||||
edges.append(
|
||||
DependencyEdge(source=entrypoint, target=header, kind="include")
|
||||
)
|
||||
|
||||
return DependencyGraph(
|
||||
root=entrypoint,
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
metadata={"tool": "include-what-you-use", "entrypoint": entrypoint},
|
||||
)
|
||||
|
||||
class LLMDependencyInference(BaseModel):
|
||||
"""Schema the LLM must fill to infer dependencies."""
|
||||
nodes: list[dict[str, str]]
|
||||
edges: list[dict[str, str]]
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a static-analysis assistant. Given the source code of a file, "
|
||||
"extract all direct dependencies (imports, includes, requires). "
|
||||
"Return ONLY valid JSON matching the provided schema."
|
||||
)
|
||||
|
||||
|
||||
def infer_dependencies_with_llm(
|
||||
llm: LLMClient,
|
||||
file_content: str,
|
||||
language: str,
|
||||
) -> LLMDependencyInference:
|
||||
prompt = (
|
||||
f"Language: {language}\n"
|
||||
f"Source code:\n```\n{file_content}\n```\n"
|
||||
"Extract all dependencies as a list of nodes and edges."
|
||||
)
|
||||
|
||||
schema = LLMDependencyInference.model_json_schema()
|
||||
raw = llm.chat_with_schema(
|
||||
prompt,
|
||||
{
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "LLMDependencyInference",
|
||||
"schema": schema,
|
||||
"strict": True,
|
||||
},
|
||||
},
|
||||
system=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(raw, str):
|
||||
raw = json.loads(raw)
|
||||
return LLMDependencyInference.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
# Hard failure — do not build downstream steps on malformed data.
|
||||
raise RuntimeError(f"LLM returned invalid dependency schema: {exc}") from exc
|
||||
|
||||
|
||||
def emit_youtrack_comment(result: AnalysisResult, issue_id: str) -> None:
|
||||
"""Post a single comment to Youtrack describing the analysis outcome."""
|
||||
if result.success:
|
||||
# Optionally post a summary; by convention, avoid duplicating comments.
|
||||
return
|
||||
|
||||
err = result.error
|
||||
body = f"""
|
||||
**Dependency analysis failed** (`{err.error_code}`)
|
||||
|
||||
{err.message}
|
||||
|
||||
"""
|
||||
if err.missing_tools:
|
||||
for tool in err.missing_tools:
|
||||
body += f"\n\n### {tool.tool_name}\n\n{tool.install_guide}"
|
||||
|
||||
print(body)
|
||||
|
||||
|
||||
AdapterFactory = Callable[[], LanguageAdapter]
|
||||
|
||||
ADAPTER_REGISTRY: dict[Language, AdapterFactory] = {
|
||||
Language.PYTHON: PythonAdapter, # type[LanguageAdapter] is Callable[[], LanguageAdapter]
|
||||
Language.JAVASCRIPT: partial(JsTsAdapter, Language.JAVASCRIPT), # -> JsTsAdapter
|
||||
Language.TYPESCRIPT: partial(JsTsAdapter, Language.TYPESCRIPT), # -> JsTsAdapter
|
||||
Language.JAVA: JavaAdapter,
|
||||
Language.GO: GoAdapter,
|
||||
Language.RUST: RustAdapter,
|
||||
Language.CPP: partial(CppAdapter, Language.CPP), # if CppAdapter takes a language
|
||||
Language.C: partial(CppAdapter, Language.C),
|
||||
}
|
||||
|
||||
|
||||
def analyze_repository(inp: AnalysisInput) -> AnalysisResult:
|
||||
"""
|
||||
Idempotent entry point.
|
||||
|
||||
Given the same AnalysisInput, always returns the same AnalysisResult
|
||||
(either a graph or a structured error). No files are written to the
|
||||
repository under analysis.
|
||||
"""
|
||||
adapter_factory = ADAPTER_REGISTRY.get(inp.language)
|
||||
if adapter_factory is None:
|
||||
return AnalysisResult(
|
||||
success=False,
|
||||
error=AnalysisError(
|
||||
error_code="UNSUPPORTED_LANGUAGE",
|
||||
message=f"No adapter registered for language '{inp.language}'",
|
||||
language=inp.language,
|
||||
entrypoint=inp.entrypoint,
|
||||
),
|
||||
)
|
||||
|
||||
adapter: LanguageAdapter = adapter_factory()
|
||||
|
||||
# --- Tool detection ---
|
||||
missing: list[ToolRequirement] = []
|
||||
for req in adapter.required_tools:
|
||||
if find_tool(req.command) is None:
|
||||
missing.append(req)
|
||||
|
||||
if missing:
|
||||
return AnalysisResult(
|
||||
success=False,
|
||||
error=AnalysisError(
|
||||
error_code="MISSING_TOOL",
|
||||
message=(
|
||||
f"Cannot build dependency graph for '{inp.language.value}': "
|
||||
f"required tool(s) not found on PATH: "
|
||||
f"{', '.join(r.tool_name for r in missing)}"
|
||||
),
|
||||
missing_tools=missing,
|
||||
language=inp.language,
|
||||
entrypoint=inp.entrypoint,
|
||||
),
|
||||
)
|
||||
|
||||
# --- Build graph ---
|
||||
try:
|
||||
graph = adapter.build_graph(inp.repo_path, inp.entrypoint)
|
||||
except subprocess.TimeoutExpired:
|
||||
return AnalysisResult(
|
||||
success=False,
|
||||
error=AnalysisError(
|
||||
error_code="TOOL_TIMEOUT",
|
||||
message=f"Tool timed out while analyzing {inp.entrypoint}",
|
||||
language=inp.language,
|
||||
entrypoint=inp.entrypoint,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
return AnalysisResult(
|
||||
success=False,
|
||||
error=AnalysisError(
|
||||
error_code="ANALYSIS_FAILED",
|
||||
message=str(exc),
|
||||
language=inp.language,
|
||||
entrypoint=inp.entrypoint,
|
||||
),
|
||||
)
|
||||
|
||||
G_all = to_networkx(graph, include_external=True)
|
||||
G = to_networkx(graph, include_external=False) # internal-only analysis
|
||||
res_internal = analyze(G, graph, label="_internal")
|
||||
res_all = analyze(G_all, graph, label="_all")
|
||||
leaf_clusters = cluster_leaves(G, res_internal["leaf_no_deps"])
|
||||
|
||||
return AnalysisResult(success=True, graph=graph, res_all=res_all, leaf_clusters=leaf_clusters)
|
||||
|
||||
|
||||
def to_networkx(dep_graph: DependencyGraph, include_external=False):
|
||||
G = nx.DiGraph()
|
||||
|
||||
# Nodes
|
||||
for n in dep_graph.nodes:
|
||||
if not include_external and n.is_external:
|
||||
continue
|
||||
G.add_node(n.id, label=n.label, language=n.language.value, is_external=n.is_external)
|
||||
|
||||
# Edges (source depends on target)
|
||||
for e in dep_graph.edges:
|
||||
if e.source in G and e.target in G:
|
||||
G.add_edge(e.source, e.target, kind=e.kind)
|
||||
|
||||
# Add isolated internal files that never appear in edges
|
||||
if not include_external:
|
||||
for n in dep_graph.nodes:
|
||||
if not n.is_external and n.id not in G:
|
||||
G.add_node(n.id, label=n.label, language=n.language.value, is_external=False)
|
||||
|
||||
return G
|
||||
|
||||
def package_of(path: str) -> str:
|
||||
parts = path.split("/")
|
||||
return "/".join(parts[:-1]) or "."
|
||||
|
||||
|
||||
COLS = [
|
||||
"file", "language", "in_degree", "out_degree",
|
||||
"isolated", "leaf_no_deps", "entry_no_dependents",
|
||||
"used_by_files", "used_by_packages", "community",
|
||||
]
|
||||
|
||||
|
||||
def analyze(G, graph, label=""):
|
||||
in_deg = dict(G.in_degree())
|
||||
out_deg = dict(G.out_degree())
|
||||
|
||||
# 1. Truly isolated: no in and no out edges
|
||||
isolated = [n for n in G if G.degree(n) == 0]
|
||||
|
||||
# 2. Leaf: depends on nothing
|
||||
leaf_no_deps = [n for n in G if G.out_degree(n) == 0]
|
||||
|
||||
# 3. Entrypoint: nothing depends on it
|
||||
entry_no_dependents = [n for n in G if G.in_degree(n) == 0]
|
||||
|
||||
# 4. Widely used: how many distinct files depend on it
|
||||
widely_used = sorted(in_deg.items(), key=lambda kv: kv[1], reverse=True)
|
||||
|
||||
# 5. How many distinct packages depend on it
|
||||
pkg = {n: package_of(n) for n in G}
|
||||
used_by_packages = {
|
||||
n: len({pkg[p] for p in G.predecessors(n)}) for n in G
|
||||
}
|
||||
|
||||
# 6. Weakly connected components = isolated groups
|
||||
wccs = sorted(nx.weakly_connected_components(G), key=len, reverse=True)
|
||||
|
||||
# 7. Strongly connected components = cycles / tightly coupled groups
|
||||
sccs = sorted(
|
||||
(c for c in nx.strongly_connected_components(G) if len(c) > 1),
|
||||
key=len, reverse=True,
|
||||
)
|
||||
|
||||
# 8. Communities via Louvain
|
||||
U = G.to_undirected()
|
||||
comms = louvain_communities(U, seed=42) if U.number_of_edges() else []
|
||||
node2comm = {n: i for i, c in enumerate(comms) for n in c}
|
||||
|
||||
# Per-file table
|
||||
rows = []
|
||||
for n, data in G.nodes(data=True):
|
||||
rows.append({
|
||||
"file": n,
|
||||
"language": data.get("language"),
|
||||
"in_degree": in_deg[n],
|
||||
"out_degree": out_deg[n],
|
||||
"isolated": G.degree(n) == 0,
|
||||
"leaf_no_deps": out_deg[n] == 0,
|
||||
"entry_no_dependents": in_deg[n] == 0,
|
||||
"used_by_files": in_deg[n],
|
||||
"used_by_packages": used_by_packages[n],
|
||||
"community": node2comm.get(n, -1),
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows, columns=COLS)
|
||||
if not df.empty:
|
||||
df = df.sort_values(
|
||||
["used_by_packages", "used_by_files"],
|
||||
ascending=False,
|
||||
)
|
||||
df.to_csv(f"file_metrics{label}.csv", index=False)
|
||||
|
||||
pd.DataFrame([
|
||||
{"group_id": i, "size": len(c), "members": ";".join(sorted(c))}
|
||||
for i, c in enumerate(wccs)
|
||||
]).to_csv(f"isolated_groups{label}.csv", index=False)
|
||||
|
||||
pd.DataFrame([
|
||||
{"scc_id": i, "size": len(c), "members": ";".join(sorted(c))}
|
||||
for i, c in enumerate(sccs)
|
||||
]).to_csv(f"cycles{label}.csv", index=False)
|
||||
|
||||
return {
|
||||
"df": df,
|
||||
"isolated": isolated,
|
||||
"leaf_no_deps": leaf_no_deps,
|
||||
"entry_no_dependents": entry_no_dependents,
|
||||
"widely_used": widely_used,
|
||||
"used_by_packages": used_by_packages,
|
||||
"wccs": wccs,
|
||||
"sccs": sccs,
|
||||
"node2comm": node2comm,
|
||||
"comms": comms,
|
||||
}
|
||||
|
||||
|
||||
def cluster_leaves(G, leaves):
|
||||
L = nx.Graph()
|
||||
L.add_nodes_from(leaves)
|
||||
|
||||
parent_to_leaves = {}
|
||||
for leaf in leaves:
|
||||
for parent in G.predecessors(leaf):
|
||||
parent_to_leaves.setdefault(parent, []).append(leaf)
|
||||
|
||||
# Leaves used by the same parent get connected
|
||||
for parent, ls in parent_to_leaves.items():
|
||||
for i in range(len(ls)):
|
||||
for j in range(i + 1, len(ls)):
|
||||
L.add_edge(ls[i], ls[j])
|
||||
|
||||
clusters = sorted(nx.connected_components(L), key=len, reverse=True)
|
||||
return clusters
|
||||
Reference in New Issue
Block a user