stackthread 0.1.0.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of stackthread might be problematic. Click here for more details.

Files changed (40) hide show
  1. stackthread/__init__.py +6 -0
  2. stackthread/__main__.py +3 -0
  3. stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
  4. stackthread/_vendor/extract.mjs +15 -0
  5. stackthread/adapters.py +130 -0
  6. stackthread/cli.py +342 -0
  7. stackthread/config.py +409 -0
  8. stackthread/detect.py +150 -0
  9. stackthread/lang/__init__.py +1 -0
  10. stackthread/lang/js/__init__.py +1 -0
  11. stackthread/lang/js/aliases.py +133 -0
  12. stackthread/lang/js/build.py +455 -0
  13. stackthread/lang/js/worker.py +186 -0
  14. stackthread/lang/python/__init__.py +1 -0
  15. stackthread/lang/python/build.py +249 -0
  16. stackthread/lang/python/extract.py +494 -0
  17. stackthread/lang/python/frameworks.py +700 -0
  18. stackthread/lang/python/orm.py +300 -0
  19. stackthread/lang/python/resolve.py +448 -0
  20. stackthread/lineage.py +965 -0
  21. stackthread/link.py +130 -0
  22. stackthread/logs.py +314 -0
  23. stackthread/model.py +161 -0
  24. stackthread/paths.py +173 -0
  25. stackthread/project.py +414 -0
  26. stackthread/report.py +203 -0
  27. stackthread/rules.py +48 -0
  28. stackthread/server/__init__.py +5 -0
  29. stackthread/server/app.py +696 -0
  30. stackthread/server/edit.py +108 -0
  31. stackthread/server/security.py +73 -0
  32. stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
  33. stackthread/static/app.css +1 -0
  34. stackthread/static/app.js +29 -0
  35. stackthread/static/index.html +16 -0
  36. stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
  37. stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
  38. stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
  39. stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  40. stackthread-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,133 @@
1
+ """Import aliases of a frontend source: from config, tsconfig/jsconfig `paths`, and literal Vite `resolve.alias`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def loads_jsonc(text: str) -> Any:
12
+ """JSON with comments and trailing commas, as tsconfig files allow."""
13
+ out, i, n = [], 0, len(text)
14
+ while i < n:
15
+ c = text[i]
16
+ if c == '"':
17
+ j = i + 1
18
+ while j < n and text[j] != '"':
19
+ j += 2 if text[j] == "\\" else 1
20
+ out.append(text[i : j + 1])
21
+ i = j + 1
22
+ elif text.startswith("//", i):
23
+ j = text.find("\n", i)
24
+ i = n if j == -1 else j
25
+ elif text.startswith("/*", i):
26
+ j = text.find("*/", i + 2)
27
+ i = n if j == -1 else j + 2
28
+ else:
29
+ out.append(c)
30
+ i += 1
31
+ cleaned = re.sub(r",(\s*[}\]])", r"\1", "".join(out))
32
+ return json.loads(cleaned)
33
+
34
+
35
+ def _tsconfig_paths(file: Path, depth: int = 0) -> tuple[dict[str, str], str | None]:
36
+ """`compilerOptions.paths` as {alias prefix: folder relative to the source root}, and baseUrl."""
37
+ try:
38
+ data = loads_jsonc(file.read_text(encoding="utf-8"))
39
+ except (OSError, ValueError):
40
+ return {}, None
41
+ out: dict[str, str] = {}
42
+ base_url: str | None = None
43
+ extends = data.get("extends")
44
+ if isinstance(extends, str) and extends.startswith(".") and depth < 3:
45
+ parent = file.parent / extends
46
+ if parent.suffix != ".json":
47
+ parent = parent.with_suffix(".json")
48
+ out, base_url = _tsconfig_paths(parent, depth + 1)
49
+ for ref in data.get("references", []) or []:
50
+ ref_path = ref.get("path") if isinstance(ref, dict) else None
51
+ if isinstance(ref_path, str) and depth < 3:
52
+ target = file.parent / ref_path
53
+ if target.is_dir():
54
+ target = target / "tsconfig.json"
55
+ more, more_base = _tsconfig_paths(target, depth + 1)
56
+ out.update(more)
57
+ base_url = base_url or more_base
58
+ options = data.get("compilerOptions", {}) or {}
59
+ base = options.get("baseUrl")
60
+ if isinstance(base, str):
61
+ base_url = str((file.parent / base).resolve())
62
+ root = Path(base_url) if base_url else file.parent
63
+ for key, targets in (options.get("paths") or {}).items():
64
+ if not targets or not isinstance(targets, list):
65
+ continue
66
+ prefix = key.removesuffix("/*").removesuffix("*")
67
+ folder = str(targets[0]).removesuffix("/*").removesuffix("*")
68
+ out[prefix] = str((root / folder).resolve())
69
+ return out, base_url
70
+
71
+
72
+ _VITE_ALIAS = re.compile(
73
+ r"""['"]?(?P<key>[@~#$\w/.-]+)['"]?\s*:\s*(?:path\.)?(?:resolve|join)\(\s*__dirname\s*,\s*['"](?P<dir>[^'"]+)['"]\s*\)"""
74
+ r"""|['"]?(?P<key2>[@~#$\w/.-]+)['"]?\s*:\s*fileURLToPath\(\s*new URL\(\s*['"](?P<dir2>[^'"]+)['"]"""
75
+ r"""|find:\s*['"](?P<key3>[^'"]+)['"]\s*,\s*replacement:\s*(?:path\.resolve\(\s*__dirname\s*,\s*)?['"](?P<dir3>[^'"]+)['"]"""
76
+ )
77
+
78
+
79
+ def _vite_aliases(root: Path) -> dict[str, str]:
80
+ for name in ("vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"):
81
+ file = root / name
82
+ if not file.is_file():
83
+ continue
84
+ text = file.read_text(encoding="utf-8", errors="replace")
85
+ out = {}
86
+ for m in _VITE_ALIAS.finditer(text):
87
+ key = m.group("key") or m.group("key2") or m.group("key3")
88
+ folder = m.group("dir") or m.group("dir2") or m.group("dir3")
89
+ if key and folder:
90
+ out[key.rstrip("/")] = str((root / folder).resolve())
91
+ return out
92
+ return {}
93
+
94
+
95
+ def detect(source_root: Path, configured: dict[str, str]) -> tuple[dict[str, Path], Path | None]:
96
+ """Aliases as {prefix: absolute folder}, most specific first, and the baseUrl folder if any."""
97
+ aliases: dict[str, str] = {}
98
+ base_url: str | None = None
99
+ for name in ("tsconfig.json", "jsconfig.json"):
100
+ file = source_root / name
101
+ if file.is_file():
102
+ found, base_url = _tsconfig_paths(file)
103
+ aliases.update(found)
104
+ break
105
+ aliases.update(_vite_aliases(source_root))
106
+ for key, folder in configured.items():
107
+ aliases[key.rstrip("/").removesuffix("/*")] = str((source_root / folder).resolve())
108
+ ordered = sorted(aliases.items(), key=lambda kv: -len(kv[0]))
109
+ return {k: Path(v) for k, v in ordered}, Path(base_url) if base_url else None
110
+
111
+
112
+ def proxy_prefixes(source_root: Path) -> list[tuple[str, str]]:
113
+ """Vite `server.proxy` keys and literal rewrites: [(client prefix, server prefix)]."""
114
+ for name in ("vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"):
115
+ file = source_root / name
116
+ if not file.is_file():
117
+ continue
118
+ text = file.read_text(encoding="utf-8", errors="replace")
119
+ block = re.search(r"proxy\s*:\s*\{", text)
120
+ if not block:
121
+ return []
122
+ out = []
123
+ for m in re.finditer(
124
+ r"""['"](/[^'"]*)['"]\s*:\s*(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}|['"][^'"]+['"])""", text[block.end() :]
125
+ ):
126
+ prefix, body = m.group(1), m.group(2)
127
+ rewrite = re.search(r"replace\(\s*/\^?\\?(/[^/]*?)/\s*,\s*['\"]([^'\"]*)['\"]", body)
128
+ if rewrite:
129
+ out.append((rewrite.group(1).replace("\\/", "/"), rewrite.group(2)))
130
+ else:
131
+ out.append((prefix, prefix))
132
+ return out
133
+ return []
@@ -0,0 +1,455 @@
1
+ """JavaScript/TypeScript facts -> graph: files, functions and components, imports, renders, calls, pages, requests.
2
+
3
+ Resolution follows imports (relative paths, aliases, baseUrl, index files, re-exports, `export *`), module-level
4
+ object literals (`api.getItems`), and objects returned by project functions (`calls = visitCalls(); calls.save()`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import posixpath
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, cast
13
+
14
+ from stackthread.config import SourceConfig
15
+ from stackthread.lang.js import aliases as alias_mod
16
+ from stackthread.model import Edge, Finding, Graph, Node, NodeKind, Site
17
+
18
+ EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts")
19
+
20
+
21
+ def file_id(source: str, file: str) -> str:
22
+ return f"js:{source}:{file}"
23
+
24
+
25
+ def fn_id(source: str, file: str, fn: str) -> str:
26
+ return f"js:{source}:{file}#{fn}"
27
+
28
+
29
+ @dataclass
30
+ class Request:
31
+ """One HTTP call site, ready for linking."""
32
+
33
+ node: str # function or file node making the call
34
+ method: str
35
+ url: str
36
+ file: str
37
+ line: int
38
+ confidence: str
39
+ client: str
40
+ raw: str = ""
41
+ source: str = ""
42
+
43
+
44
+ @dataclass
45
+ class JsBuild:
46
+ graph: Graph
47
+ source: SourceConfig
48
+ project_root: Path
49
+ facts: dict[str, dict[str, Any]] # file -> facts
50
+ aliases: dict[str, str] = field(default_factory=dict) # prefix -> project-relative folder ('' = root)
51
+ base_url: str | None = None
52
+ module_cache: dict[tuple[str, str], str | None] = field(default_factory=dict)
53
+ requests: list[Request] = field(default_factory=list)
54
+ fn_index: dict[str, dict[str, dict[str, Any]]] = field(default_factory=dict) # file -> fn id -> fact
55
+ elements: dict[str, dict[str, Any]] = field(default_factory=dict) # "file:line:col" -> element fact
56
+ exports_cache: dict[tuple[str, str], str | None] = field(default_factory=dict)
57
+
58
+ # ---- module resolution ----
59
+
60
+ def _rel_folder(self, path: Path) -> str | None:
61
+ try:
62
+ rel = path.resolve().relative_to(self.project_root.resolve()).as_posix()
63
+ except ValueError:
64
+ return None
65
+ return "" if rel == "." else rel
66
+
67
+ def _candidates(self, base: str) -> list[str]:
68
+ out = [base] if base.endswith(EXTS) else []
69
+ if base.endswith((".js", ".jsx", ".mjs", ".cjs")):
70
+ stem = base.rsplit(".", 1)[0]
71
+ out += [stem + ext for ext in (".ts", ".tsx", ".mts", ".cts")]
72
+ out += [base + ext for ext in EXTS] + [f"{base}/index{ext}" for ext in EXTS]
73
+ return out
74
+
75
+ def resolve_module(self, spec: str, from_file: str) -> str | None:
76
+ """A project-relative file for an import specifier, or None for packages and misses."""
77
+ key = (spec, posixpath.dirname(from_file) if spec.startswith(".") else "")
78
+ if key in self.module_cache:
79
+ return self.module_cache[key]
80
+ bases: list[str] = []
81
+ if spec.startswith("."):
82
+ bases.append(posixpath.normpath(posixpath.join(posixpath.dirname(from_file), spec)))
83
+ else:
84
+ for prefix, folder in self.aliases.items():
85
+ if (
86
+ spec == prefix
87
+ or spec.startswith(prefix + "/")
88
+ or (prefix.endswith("/") and spec.startswith(prefix))
89
+ ):
90
+ rest = spec[len(prefix) :].lstrip("/")
91
+ bases.append(posixpath.normpath(posixpath.join(folder, rest)) if rest else folder)
92
+ break
93
+ if not bases and self.base_url is not None:
94
+ bases.append(posixpath.normpath(posixpath.join(self.base_url, spec)))
95
+ result = None
96
+ for base in bases:
97
+ result = next((c for c in self._candidates(base) if c in self.facts), None)
98
+ if result:
99
+ break
100
+ self.module_cache[key] = result
101
+ return result
102
+
103
+ # ---- symbols ----
104
+
105
+ def local_fn(self, file: str, name: str) -> str | None:
106
+ fns = self.fn_index.get(file, {})
107
+ return fn_id(self.source.name, file, name) if name in fns else None
108
+
109
+ def resolve_export(self, file: str, name: str, seen: frozenset[tuple[str, str]] = frozenset()) -> str | None:
110
+ """The node a file exports under `name`: a function/component node, or an object prefix `file#obj`."""
111
+ key = (file, name)
112
+ if key in self.exports_cache:
113
+ return self.exports_cache[key]
114
+ if key in seen:
115
+ return None
116
+ seen = seen | {key}
117
+ facts = self.facts.get(file)
118
+ result: str | None = None
119
+ if facts is not None:
120
+ for exp in facts["exports"]:
121
+ if exp["name"] != name:
122
+ continue
123
+ if exp.get("source"):
124
+ target = self.resolve_module(exp["source"], file)
125
+ local = exp.get("local") or name
126
+ if target and local == "*":
127
+ result = file_id(self.source.name, target)
128
+ elif target:
129
+ result = self.resolve_export(target, local, seen)
130
+ else:
131
+ local = exp.get("local") or name
132
+ result = (
133
+ self.local_fn(file, local)
134
+ or self._object_prefix(file, local)
135
+ or self._via_import(file, local, seen)
136
+ )
137
+ if result:
138
+ break
139
+ if result is None and name != "default":
140
+ for exp in facts["exports"]:
141
+ if exp["name"] == "*" and exp.get("source"):
142
+ target = self.resolve_module(exp["source"], file)
143
+ if target and (result := self.resolve_export(target, name, seen)):
144
+ break
145
+ self.exports_cache[key] = result
146
+ return result
147
+
148
+ def _object_prefix(self, file: str, name: str) -> str | None:
149
+ """`export const api = { get() {} }`: the object as a prefix for its methods."""
150
+ if any(fid.startswith(name + ".") for fid in self.fn_index.get(file, {})):
151
+ return fn_id(self.source.name, file, name)
152
+ return None
153
+
154
+ def _via_import(self, file: str, local: str, seen: frozenset[tuple[str, str]]) -> str | None:
155
+ for imp in self.facts[file]["imports"]:
156
+ if imp["local"] == local and not imp["typeOnly"]:
157
+ target = self.resolve_module(imp["source"], file)
158
+ if target is None:
159
+ return None
160
+ if imp["imported"] == "*":
161
+ return file_id(self.source.name, target)
162
+ return self.resolve_export(target, imp["imported"], seen)
163
+ return None
164
+
165
+ def resolve_name(self, file: str, name: str) -> str | None:
166
+ """A top-level name in a file: local function, object, or import."""
167
+ return self.local_fn(file, name) or self._object_prefix(file, name) or self._via_import(file, name, frozenset())
168
+
169
+ def member(self, target: str | None, prop: str) -> str | None:
170
+ """`target.prop` where target is a file node (namespace import) or an object/function prefix."""
171
+ if target is None:
172
+ return None
173
+ if target.startswith("js:") and "#" not in target:
174
+ file = target.split(":", 2)[2]
175
+ return self.resolve_export(file, prop)
176
+ file, _, obj = target.split(":", 2)[2].partition("#")
177
+ candidate = f"{obj}.{prop}"
178
+ return fn_id(self.source.name, file, candidate) if candidate in self.fn_index.get(file, {}) else None
179
+
180
+ def resolve_callee(self, file: str, fn: dict[str, Any] | None, callee: str) -> tuple[str | None, str]:
181
+ """Resolve a call made inside `fn` (None for module level). Returns (node id, confidence)."""
182
+ parts = callee.replace("?.", ".").split(".")
183
+ if any("(" in p or "[" in p for p in parts):
184
+ return None, "exact"
185
+ head = parts[0]
186
+ bindings = fn.get("bindings", {}) if fn else {}
187
+ if head in bindings:
188
+ binding = bindings[head]
189
+ if len(parts) == 1:
190
+ return None, "exact" # a local function or value: shown through the binding, not a node
191
+ # `calls = visitCalls(...)`, then `calls.save(...)`: a member of what a project function returned
192
+ for inner in binding.get("calls", []) + ([binding["init"]] if binding.get("init") else []):
193
+ target, _ = self.resolve_callee(file, None, inner)
194
+ if target:
195
+ found = self.member(target, parts[1])
196
+ if found:
197
+ return found, "inferred"
198
+ return None, "exact"
199
+ if head in (
200
+ "this",
201
+ "super",
202
+ "window",
203
+ "document",
204
+ "console",
205
+ "JSON",
206
+ "Math",
207
+ "Object",
208
+ "Array",
209
+ "Promise",
210
+ "React",
211
+ ):
212
+ if head == "this" and fn and "." in fn["id"] and len(parts) == 2:
213
+ owner = fn["id"].rsplit(".", 1)[0]
214
+ return self.local_fn(file, f"{owner}.{parts[1]}"), "exact"
215
+ return None, "exact"
216
+ target = self.resolve_name(file, head)
217
+ for prop in parts[1:]:
218
+ target = self.member(target, prop)
219
+ if (
220
+ target
221
+ and "#" in target
222
+ and target.split("#", 1)[1] in self.fn_index.get(target.split(":", 2)[2].split("#")[0], {})
223
+ ):
224
+ return target, "exact"
225
+ return None, "exact"
226
+
227
+ def component_for(self, file: str, tag: str) -> str | None:
228
+ parts = tag.split(".")
229
+ target = self.resolve_name(file, parts[0])
230
+ for prop in parts[1:]:
231
+ target = self.member(target, prop)
232
+ if target and "#" in target:
233
+ f, _, fid = target.split(":", 2)[2].partition("#")
234
+ if fid in self.fn_index.get(f, {}):
235
+ return target
236
+ return None
237
+
238
+ # ---- graph ----
239
+
240
+ def run(self) -> None:
241
+ src, g = self.source.name, self.graph
242
+ found, base_url = alias_mod.detect(self.project_root / self.source.root, self.source.alias)
243
+ self.aliases = {k: rel for k, v in found.items() if (rel := self._rel_folder(v)) is not None}
244
+ self.base_url = self._rel_folder(base_url) if base_url else None
245
+ for file, facts in self.facts.items():
246
+ self.fn_index[file] = {f["id"]: f for f in facts["functions"]}
247
+ for el in facts["elements"]:
248
+ self.elements[f"{file}:{el['line']}:{el['col']}"] = dict(el, file=file)
249
+ for file, facts in self.facts.items():
250
+ g.add_node(
251
+ Node(
252
+ id=file_id(src, file),
253
+ kind="file",
254
+ name=file.rsplit("/", 1)[-1],
255
+ qualname=file,
256
+ lang="js",
257
+ source=src,
258
+ file=file,
259
+ line=1,
260
+ end=facts["lines"],
261
+ layer="client",
262
+ attrs={"errors": facts["errors"]} if facts["errors"] else {},
263
+ )
264
+ )
265
+ for err in facts["errors"]:
266
+ g.finding(Finding("parse-error", "error", err["message"], file, err["line"]))
267
+ for f in facts["functions"]:
268
+ if f["kind"] == "module":
269
+ continue
270
+ kind = cast(NodeKind, f["kind"] if f["kind"] in ("component", "hook", "method") else "function")
271
+ g.add_node(
272
+ Node(
273
+ id=fn_id(src, file, f["id"]),
274
+ kind=kind,
275
+ name=f["name"],
276
+ qualname=f["id"],
277
+ lang="js",
278
+ source=src,
279
+ file=file,
280
+ line=f["line"],
281
+ end=f["end"],
282
+ col=f["col"],
283
+ layer="screen" if kind == "component" else "client",
284
+ doc=f.get("doc", ""),
285
+ tags=["exported"] if f["exported"] else [],
286
+ attrs={"params": f["params"]} if f["params"] else {},
287
+ )
288
+ )
289
+ parent = fn_id(src, file, f["parent"]) if f.get("parent") else file_id(src, file)
290
+ if f.get("parent") and f["parent"] not in self.fn_index[file]:
291
+ parent = file_id(src, file)
292
+ g.add_edge(Edge(parent, fn_id(src, file, f["id"]), "contains"))
293
+ for file, facts in self.facts.items():
294
+ self._imports(file, facts)
295
+ self._functions(file, facts)
296
+ self._requests(file, facts)
297
+ self._pages(file, facts)
298
+
299
+ def _imports(self, file: str, facts: dict[str, Any]) -> None:
300
+ packages: list[str] = []
301
+ for imp in facts["imports"]:
302
+ target = self.resolve_module(imp["source"], file)
303
+ if target and target != file:
304
+ self.graph.add_edge(
305
+ Edge(
306
+ file_id(self.source.name, file),
307
+ file_id(self.source.name, target),
308
+ "imports",
309
+ site=Site(file, imp["line"]),
310
+ )
311
+ )
312
+ elif not target and not imp["source"].startswith("."):
313
+ pkg = (
314
+ imp["source"].split("/")[0]
315
+ if not imp["source"].startswith("@")
316
+ else "/".join(imp["source"].split("/")[:2])
317
+ )
318
+ if pkg not in packages:
319
+ packages.append(pkg)
320
+ if packages:
321
+ self.graph.nodes[file_id(self.source.name, file)].attrs["packages"] = packages
322
+
323
+ def _functions(self, file: str, facts: dict[str, Any]) -> None:
324
+ src, g = self.source.name, self.graph
325
+ for f in facts["functions"]:
326
+ node = file_id(src, file) if f["kind"] == "module" else fn_id(src, file, f["id"])
327
+ outside: list[str] = []
328
+ for call in f["calls"]:
329
+ target, confidence = self.resolve_callee(file, f, call["callee"])
330
+ if target and target != node and target in g.nodes:
331
+ g.add_edge(Edge(node, target, "calls", confidence, site=Site(file, call["line"]))) # type: ignore[arg-type]
332
+ self._wrapper_request(node, file, call, target)
333
+ elif (
334
+ not target
335
+ and call["callee"].split(".")[0] not in f.get("bindings", {})
336
+ and call["callee"] not in outside
337
+ ):
338
+ outside.append(call["callee"])
339
+ for r in f["renders"]:
340
+ target = self.component_for(file, r["tag"])
341
+ if target and target in g.nodes:
342
+ g.add_edge(Edge(node, target, "renders", site=Site(file, r["line"])))
343
+ for name, b in f.get("bindings", {}).items():
344
+ if b["kind"] == "lazy" and b.get("source"):
345
+ target_file = self.resolve_module(b["source"], file)
346
+ target = self.resolve_export(target_file, "default") if target_file else None
347
+ if target and target in g.nodes:
348
+ g.add_edge(Edge(node, target, "uses", site=Site(file, b["line"]), label=f"lazy {name}"))
349
+ if outside and node in g.nodes:
350
+ g.nodes[node].attrs["outside"] = outside[:40]
351
+ if f.get("wrapper") and node in g.nodes:
352
+ g.nodes[node].attrs["wrapper"] = f["wrapper"]
353
+
354
+ def _wrapper_request(self, caller: str, file: str, call: dict[str, Any], target: str) -> None:
355
+ """`getJSON('/api/items')` where getJSON forwards its first argument to fetch: a request from the caller."""
356
+ node = self.graph.nodes.get(target)
357
+ wrapper = node.attrs.get("wrapper") if node else None
358
+ if not wrapper:
359
+ tfile, _, tid = target.split(":", 2)[2].partition("#")
360
+ wrapper = self.fn_index.get(tfile, {}).get(tid, {}).get("wrapper")
361
+ if not wrapper or wrapper.get("url") != 0 or not call.get("args"):
362
+ return
363
+ url = call["args"][0]
364
+ if not isinstance(url, str) or not (url.startswith("/") or url.startswith("{}") or "://" in url):
365
+ return
366
+ prefix = wrapper.get("prefix") or ""
367
+ if prefix and not url.startswith(prefix):
368
+ url = prefix.rstrip("/") + "/" + url.lstrip("/")
369
+ method = (call.get("method") if wrapper.get("optionsArg") is not None else None) or wrapper.get("method") or "?"
370
+ self.requests.append(
371
+ Request(
372
+ caller,
373
+ method,
374
+ url.split("?")[0],
375
+ file,
376
+ call["line"],
377
+ "inferred",
378
+ f"via {target.rsplit('#', 1)[-1]}",
379
+ source=self.source.name,
380
+ )
381
+ )
382
+
383
+ def _requests(self, file: str, facts: dict[str, Any]) -> None:
384
+ src = self.source.name
385
+ clients = self._imported_clients(file, facts)
386
+ for h in facts["http"]:
387
+ node = fn_id(src, file, h["fn"]) if h.get("fn") else file_id(src, file)
388
+ if node not in self.graph.nodes:
389
+ node = file_id(src, file)
390
+ url, confidence = h["url"], h["confidence"]
391
+ recv = h.get("recv")
392
+ if recv and recv in clients:
393
+ kind, base = clients[recv]
394
+ if h["client"] in ("unknown", "custom", "openapi") and kind in ("axios", "ky", "openapi", "ofetch"):
395
+ confidence = "exact"
396
+ if base and not url.startswith(base.rstrip("/") + "/"):
397
+ url = base.rstrip("/") + "/" + url.lstrip("/")
398
+ self.requests.append(
399
+ Request(node, h["method"], url, file, h["line"], confidence, h["client"], h["raw"], src)
400
+ )
401
+
402
+ def _imported_clients(self, file: str, facts: dict[str, Any]) -> dict[str, tuple[str, str | None]]:
403
+ """Clients created in other files and imported here: `import { http } from '@/lib/http'`."""
404
+ out: dict[str, tuple[str, str | None]] = {}
405
+ for imp in facts["imports"]:
406
+ target = self.resolve_module(imp["source"], file)
407
+ if not target:
408
+ continue
409
+ exported = imp["imported"]
410
+ target_facts = self.facts[target]
411
+ local = next((e.get("local") or e["name"] for e in target_facts["exports"] if e["name"] == exported), None)
412
+ for client in target_facts.get("clients", []):
413
+ if client["name"] == local:
414
+ out[imp["local"]] = (client["kind"], client.get("base"))
415
+ return out
416
+
417
+ def _pages(self, file: str, facts: dict[str, Any]) -> None:
418
+ src, g = self.source.name, self.graph
419
+ enabled = set(self.source.frameworks)
420
+ for r in facts["routes"]:
421
+ if "auto" not in enabled and r["router"] not in enabled:
422
+ continue
423
+ node_id = f"page:{src}:{r['path']}"
424
+ g.add_node(
425
+ Node(
426
+ id=node_id,
427
+ kind="page",
428
+ name=r["path"],
429
+ qualname=r["path"],
430
+ lang="js",
431
+ source=src,
432
+ file=file,
433
+ line=r["line"],
434
+ end=r["line"],
435
+ col=r["col"],
436
+ layer="screen",
437
+ tags=[r["router"]],
438
+ attrs={"path": r["path"], "router": r["router"]},
439
+ )
440
+ )
441
+ target = None
442
+ if r.get("component"):
443
+ target = self.component_for(file, r["component"])
444
+ elif r.get("lazy"):
445
+ target_file = self.resolve_module(r["lazy"], file)
446
+ target = self.resolve_export(target_file, "default") if target_file else None
447
+ if target and target in g.nodes:
448
+ g.add_edge(Edge(node_id, target, "routes", site=Site(file, r["line"])))
449
+ g.nodes[node_id].attrs["component"] = target
450
+
451
+
452
+ def build(graph: Graph, source: SourceConfig, project_root: Path, facts: dict[str, dict[str, Any]]) -> JsBuild:
453
+ builder = JsBuild(graph, source, project_root, facts)
454
+ builder.run()
455
+ return builder