storycode / analyzer /graph.py
Codex (via opencode)
Codex: prompt tuning, _loads fix, GitHub ingestion, lint cleanup
7603aa2
Raw
History Blame Contribute Delete
6.73 kB
"""Build the project graph and the facts every story is grounded in.
Given the per-file symbols + raw imports, this module:
* resolves imports to **intra-project edges** (file β†’ file it depends on),
* computes **fan-in / fan-out** for each file,
* classifies each file's **role** (front door, what-you-see, engine, …),
* detects **entry points**,
* derives the **safe-to-edit** verdict (🟒/🟠/πŸ”΄) β€” a fact from the graph,
never a guess by the model.
Nothing here calls a language model. These are the numbers the narration must
stay true to, and the numbers the tests assert.
"""
from __future__ import annotations
import os
import posixpath
import config
from schema import FileInfo
# --- import resolution ------------------------------------------------------
def _py_module_index(paths: list[str]) -> dict[str, str]:
"""Map dotted Python module names -> file path, for absolute-import resolution."""
index: dict[str, str] = {}
for p in paths:
if not p.endswith(".py"):
continue
no_ext = p[:-3]
dotted = no_ext.replace("/", ".")
if dotted.endswith(".__init__"):
dotted = dotted[: -len(".__init__")] # package import
index[dotted] = p
return index
def _resolve_python(src: str, target: str, index: dict[str, str],
pathset: set[str]) -> str | None:
"""Resolve one Python import target (possibly relative) to a project file."""
if target.startswith("."):
# Relative import: count leading dots, walk up from the source's package.
level = len(target) - len(target.lstrip("."))
rest = target[level:]
base_dir = posixpath.dirname(src)
for _ in range(level - 1):
base_dir = posixpath.dirname(base_dir)
parts = [p for p in rest.split(".") if p]
cand_base = posixpath.normpath(posixpath.join(base_dir, *parts)) if parts else base_dir
for cand in (f"{cand_base}.py", posixpath.join(cand_base, "__init__.py")):
cand = cand.lstrip("./")
if cand in pathset:
return cand
return None
# Absolute: try the longest dotted prefix that maps to a project module.
parts = target.split(".")
for i in range(len(parts), 0, -1):
hit = index.get(".".join(parts[:i]))
if hit:
return hit
return None
def _resolve_relative_path(src: str, target: str, pathset: set[str]) -> str | None:
"""Resolve a JS/HTML relative reference like './utils/foo' to a project file."""
if target.startswith(("http://", "https://", "//")):
return None
base_dir = posixpath.dirname(src)
raw = posixpath.normpath(posixpath.join(base_dir, target)).lstrip("./")
candidates = [raw]
if not os.path.splitext(raw)[1]: # no extension β€” try common ones + index files
for ext in (".js", ".ts", ".jsx", ".tsx", ".mjs", ".css", ".json"):
candidates.append(raw + ext)
for ext in (".js", ".ts", ".jsx", ".tsx"):
candidates.append(posixpath.join(raw, "index" + ext))
for cand in candidates:
if cand in pathset:
return cand
return None
def resolve_edges(files: list[FileInfo]) -> None:
"""Fill each FileInfo.depends_on with resolved intra-project paths (in place)."""
paths = [f.path for f in files]
pathset = set(paths)
py_index = _py_module_index(paths)
for f in files:
deps: list[str] = []
for target in f.imports:
if f.lang == "python":
hit = _resolve_python(f.path, target, py_index, pathset)
else:
hit = _resolve_relative_path(f.path, target, pathset)
if hit and hit != f.path and hit not in deps:
deps.append(hit)
f.depends_on = deps
# --- roles ------------------------------------------------------------------
def classify_role(path: str, lang: str) -> str:
base = posixpath.basename(path).lower()
low = path.lower()
if any(h in low for h in config.TEST_HINTS) or base.startswith("test_"):
return "test"
if base in config.ENTRY_NAMES:
return "entry"
if base in config.DEP_MANIFESTS or any(h in base for h in config.CONFIG_HINTS):
return "config"
if lang in ("html", "css") or lang in ("javascript", "typescript") and (
"component" in low or "page" in low or "/ui" in low or low.startswith("ui/")):
return "frontend"
if any(h in base for h in config.DATA_HINTS):
return "data"
if any(h in low for h in config.UTIL_HINTS):
return "util"
if lang in ("javascript", "typescript", "html", "css"):
return "frontend"
if lang == "python":
return "backend"
return "other"
def detect_entry_points(files: list[FileInfo]) -> list[str]:
return [f.path for f in files if f.is_entry]
# --- fan-in / fan-out + safety ---------------------------------------------
def compute_metrics(files: list[FileInfo]) -> None:
"""Fill fan_in / fan_out / is_entry / role / safety for every file (in place)."""
fan_in: dict[str, int] = {f.path: 0 for f in files}
for f in files:
f.fan_out = len(f.depends_on)
for dep in f.depends_on:
if dep in fan_in:
fan_in[dep] += 1
for f in files:
f.fan_in = fan_in[f.path]
if not f.role or f.role == "other":
f.role = classify_role(f.path, f.lang)
f.is_entry = posixpath.basename(f.path).lower() in config.ENTRY_NAMES
f.safety, f.safety_reason = _safety(f)
def _safety(f: FileInfo) -> tuple[str, str]:
"""Traffic-light verdict from graph facts. Higher fan-in = more dangerous."""
if f.is_entry:
return config.DANGER, ("This is the front door β€” the app starts here, so a "
"mistake stops everything from running.")
if f.role == "config":
return config.DANGER, ("Settings file β€” many parts read from it, so changes "
"ripple across the whole project.")
if f.fan_in >= 3:
return config.DANGER, (f"{f.fan_in} other files depend on this one β€” changing it "
"can break them all.")
if f.fan_in == 2:
return config.CAREFUL, "A couple of files depend on this β€” change it carefully."
if f.fan_in == 1:
return config.CAREFUL, "One other file depends on this β€” small changes are usually fine."
if f.role == "test":
return config.SAFE, "A test file β€” editing it can't break the app itself."
return config.SAFE, ("Nothing else depends on this file, so you can change it without "
"breaking the rest.")