| """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 |
|
|
|
|
| |
| 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__")] |
| 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("."): |
| |
| 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 |
| |
| 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]: |
| 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 |
|
|
|
|
| |
| 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] |
|
|
|
|
| |
| 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.") |
|
|