File size: 6,731 Bytes
71d239c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | """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.")
|