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