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.
- stackthread/__init__.py +6 -0
- stackthread/__main__.py +3 -0
- stackthread/_vendor/THIRD_PARTY_NOTICES.txt +23 -0
- stackthread/_vendor/extract.mjs +15 -0
- stackthread/adapters.py +130 -0
- stackthread/cli.py +342 -0
- stackthread/config.py +409 -0
- stackthread/detect.py +150 -0
- stackthread/lang/__init__.py +1 -0
- stackthread/lang/js/__init__.py +1 -0
- stackthread/lang/js/aliases.py +133 -0
- stackthread/lang/js/build.py +455 -0
- stackthread/lang/js/worker.py +186 -0
- stackthread/lang/python/__init__.py +1 -0
- stackthread/lang/python/build.py +249 -0
- stackthread/lang/python/extract.py +494 -0
- stackthread/lang/python/frameworks.py +700 -0
- stackthread/lang/python/orm.py +300 -0
- stackthread/lang/python/resolve.py +448 -0
- stackthread/lineage.py +965 -0
- stackthread/link.py +130 -0
- stackthread/logs.py +314 -0
- stackthread/model.py +161 -0
- stackthread/paths.py +173 -0
- stackthread/project.py +414 -0
- stackthread/report.py +203 -0
- stackthread/rules.py +48 -0
- stackthread/server/__init__.py +5 -0
- stackthread/server/app.py +696 -0
- stackthread/server/edit.py +108 -0
- stackthread/server/security.py +73 -0
- stackthread/static/THIRD_PARTY_NOTICES.txt +379 -0
- stackthread/static/app.css +1 -0
- stackthread/static/app.js +29 -0
- stackthread/static/index.html +16 -0
- stackthread-0.1.0.dev2.dist-info/METADATA +51 -0
- stackthread-0.1.0.dev2.dist-info/RECORD +40 -0
- stackthread-0.1.0.dev2.dist-info/WHEEL +4 -0
- stackthread-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- 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
|