"""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.")