cg-code-graph 0.10.1__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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""TypeScript / Vue language plugin.
|
|
2
|
+
|
|
3
|
+
Runs extractor/extract.mjs (TypeScript compiler API + @vue/compiler-sfc) over the project and
|
|
4
|
+
maps its facts into the graph:
|
|
5
|
+
|
|
6
|
+
nodes module / function / method / class / type / composable / store / <file-kind> for .vue
|
|
7
|
+
(component, page, layout, ... as named by the framework plugin), http (client endpoint)
|
|
8
|
+
edges IMPORTS, CALLS, USES_COMPOSABLE, USES_STORE, RENDERS, INSTANTIATES, REFERENCES_TYPE,
|
|
9
|
+
HTTP_CALLS (function -> http:<METHOD> <path template>)
|
|
10
|
+
SUBSCRIBES_CHANNEL (function -> channel_sub:<name>: laravel-echo Echo.private/channel/join, pusher-js
|
|
11
|
+
subscribe, useEcho hooks; attrs.events = .listen('X') names)
|
|
12
|
+
tests spec / test files (Vitest, Jest, Playwright, Cypress) are indexed with attrs.test and one `test` node per
|
|
13
|
+
it() / test(); their HTTP calls become TEST_HTTP edges, page.goto / cy.visit become TEST_VISITS to the page
|
|
14
|
+
(resolved after the framework plugin set page routes, see codegraph/tests_index.py)
|
|
15
|
+
|
|
16
|
+
Framework plugins (Nuxt) configure the extractor through `TsContext.extractor_cfg` in
|
|
17
|
+
register_hooks(): tsconfig (e.g. .nuxt/tsconfig.app.json for auto-import globals), the
|
|
18
|
+
generated global-components map, and file-kind rules.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import hashlib
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import subprocess
|
|
30
|
+
import tempfile
|
|
31
|
+
import time
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from ... import presets
|
|
37
|
+
from ...core.model import CONFIDENCE_RANK
|
|
38
|
+
from ...core.paths import names_regex, rules as path_rules
|
|
39
|
+
from ...coverage import SUPPORTED
|
|
40
|
+
from ...core import cache, extractors, fsutil
|
|
41
|
+
from ...core.plugin import FrameworkPlugin, GraphBuilder, LanguagePlugin, Project
|
|
42
|
+
|
|
43
|
+
EXTRACTOR_DIR = Path(__file__).parent / "extractor"
|
|
44
|
+
EXTRACTOR = EXTRACTOR_DIR / "extract.mjs"
|
|
45
|
+
PH = re.compile(r"\{[^{}]*\}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class TsContext:
|
|
50
|
+
project: Project
|
|
51
|
+
extractor_cfg: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
facts: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
http_nodes: dict[str, dict] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def min_conf(*cs: str) -> str:
|
|
57
|
+
return min(cs, key=lambda c: CONFIDENCE_RANK[c])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def normalize_client_url(url: str) -> tuple[str, dict]:
|
|
61
|
+
"""Client URL template -> path template used as the http node key.
|
|
62
|
+
Strips scheme://host, a leading origin placeholder (the configured server URL, unknown at
|
|
63
|
+
analysis time) and the query string. Placeholders keep their names: {storeSlug}."""
|
|
64
|
+
info = {}
|
|
65
|
+
u = url.strip()
|
|
66
|
+
m = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]*", u)
|
|
67
|
+
if m:
|
|
68
|
+
info["origin"] = m.group(0)
|
|
69
|
+
u = u[m.end():]
|
|
70
|
+
m = re.match(r"^\{[^{}/]*\}(?=/|$)", u)
|
|
71
|
+
if m:
|
|
72
|
+
info["origin"] = m.group(0)
|
|
73
|
+
u = u[m.end():]
|
|
74
|
+
q = re.search(r"\?(?![^{]*\})", u)
|
|
75
|
+
if q:
|
|
76
|
+
info["query"] = u[q.end():]
|
|
77
|
+
u = u[:q.start()]
|
|
78
|
+
u = re.sub(r"/{2,}", "/", u)
|
|
79
|
+
if not u.startswith("/"):
|
|
80
|
+
info["relative"] = True
|
|
81
|
+
u = "/" + u if not u.startswith("{") else u
|
|
82
|
+
if len(u) > 1:
|
|
83
|
+
u = u.rstrip("/")
|
|
84
|
+
return u, info
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def join_url(base: str | None, url: str) -> str:
|
|
88
|
+
if not base or re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url) or url.startswith("{"):
|
|
89
|
+
return url
|
|
90
|
+
return base.rstrip("/") + "/" + url.lstrip("/")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
LARAVEL_ASSET_DIRS = ("resources/js", "resources/ts", "resources/assets/js", "resources/scripts", "app/javascript")
|
|
94
|
+
PKG_SKIP = {"node_modules", "dist", "build", "out", "coverage", "vendor", "Pods", "DerivedData", "example", "examples",
|
|
95
|
+
"e2e", "test", "tests", "__tests__", "fixtures", "templates", "android-template", "ios-pods-template",
|
|
96
|
+
"ios-spm-template"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# a PHP / Python backend at the root: its frontend directory is indexed on its own and combined with `cg link`
|
|
100
|
+
BACKEND_MARKERS = ("composer.json", "artisan", "pyproject.toml", "setup.py", "setup.cfg", "manage.py", "requirements.txt")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sub_tsconfigs(root: Path) -> list[str]:
|
|
104
|
+
"""The sub-project tsconfigs indexed as one program when the root has none: per-package configs of a JS monorepo
|
|
105
|
+
(root package.json), or, in a repo whose root is not a JS project at all (a Swift / Kotlin / Rust / Dart app with
|
|
106
|
+
a web/ directory), every tsconfig.json one or two levels down, package.json or not (#75). A PHP / Python
|
|
107
|
+
backend root keeps its frontend separate (index it on its own; `cg link` combines the graphs)."""
|
|
108
|
+
root = Path(root)
|
|
109
|
+
if (root / "package.json").is_file():
|
|
110
|
+
return package_tsconfigs(root)
|
|
111
|
+
if any((root / m).exists() for m in BACKEND_MARKERS):
|
|
112
|
+
return []
|
|
113
|
+
return package_tsconfigs(root, need_package_json=False)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def package_tsconfigs(root: Path, need_package_json: bool = True) -> list[str]:
|
|
117
|
+
"""A monorepo without a root tsconfig.json / jsconfig.json (lerna / nx / npm workspaces with per-package configs,
|
|
118
|
+
e.g. Capacitor and its plugins): the tsconfig.json of each package (a directory holding package.json and
|
|
119
|
+
tsconfig.json) one or two levels down. The extractor indexes them as one program, like a solution config."""
|
|
120
|
+
root = Path(root)
|
|
121
|
+
if (root / "tsconfig.json").is_file() or (root / "jsconfig.json").is_file():
|
|
122
|
+
return []
|
|
123
|
+
out = []
|
|
124
|
+
|
|
125
|
+
def pkg(d: Path) -> bool:
|
|
126
|
+
return (d / "tsconfig.json").is_file() and (not need_package_json or (d / "package.json").is_file())
|
|
127
|
+
|
|
128
|
+
def subdirs(d: Path):
|
|
129
|
+
try:
|
|
130
|
+
return sorted(x for x in d.iterdir() if x.is_dir() and not x.name.startswith(".") and x.name not in PKG_SKIP
|
|
131
|
+
and not x.is_symlink())
|
|
132
|
+
except OSError:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
for d in subdirs(root):
|
|
136
|
+
if pkg(d):
|
|
137
|
+
out.append(d)
|
|
138
|
+
elif not (d / "package.json").is_file(): # packages/<name>, libs/<name>
|
|
139
|
+
out += [x for x in subdirs(d) if pkg(x)]
|
|
140
|
+
return [str((d / "tsconfig.json").relative_to(root)) for d in out][:200]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cordova_www_dirs(root: Path) -> list[str]:
|
|
144
|
+
"""Cordova plugin JS: www/ next to a plugin.xml (the repo root or a plugin folder up to two levels down) and a
|
|
145
|
+
Cordova app's www/ next to its config.xml (#61)."""
|
|
146
|
+
out = []
|
|
147
|
+
for xml in ("plugin.xml", "config.xml"):
|
|
148
|
+
for pat in (xml, f"*/{xml}", f"*/*/{xml}"):
|
|
149
|
+
for f in sorted(root.glob(pat))[:50]:
|
|
150
|
+
if any(part in PKG_SKIP or part.startswith(".") for part in f.relative_to(root).parts[:-1]):
|
|
151
|
+
continue
|
|
152
|
+
w = f.parent / "www"
|
|
153
|
+
if w.is_dir():
|
|
154
|
+
r = str(w.relative_to(root))
|
|
155
|
+
if r not in out:
|
|
156
|
+
out.append(r)
|
|
157
|
+
return out[:50]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TypeScriptPlugin(LanguagePlugin):
|
|
161
|
+
name = "typescript"
|
|
162
|
+
|
|
163
|
+
def __init__(self):
|
|
164
|
+
self.program: TsContext | None = None
|
|
165
|
+
|
|
166
|
+
def detect(self, project: Project) -> bool:
|
|
167
|
+
if project.exists("tsconfig.json") or project.exists("jsconfig.json") or "typescript" in (project.detected.get("languages") or {}):
|
|
168
|
+
return True
|
|
169
|
+
if project.exists("package.json") and cordova_www_dirs(project.root):
|
|
170
|
+
return True # a Cordova plugin / app: plain JS under www/ calling cordova.exec (#61)
|
|
171
|
+
if sub_tsconfigs(project.root):
|
|
172
|
+
return True # a monorepo with per-package tsconfigs only, or a non-JS repo with web/tsconfig.json
|
|
173
|
+
if project.exists("package.json") and any((project.root / d).is_dir() for d in LARAVEL_ASSET_DIRS):
|
|
174
|
+
return True # Laravel / Rails-style app with a plain-JS frontend under resources/js (allowJs)
|
|
175
|
+
from ..tsweb.common import has_server_framework # plain-JS server projects (Express, Koa, ...): allowJs
|
|
176
|
+
return project.exists("package.json") and has_server_framework(project)
|
|
177
|
+
|
|
178
|
+
def prerequisite_problem(self, project: Project) -> str | None:
|
|
179
|
+
if not shutil.which("node"):
|
|
180
|
+
return "node not installed (Node.js 20+ is needed for the TypeScript extractor)"
|
|
181
|
+
if not extractors.status("typescript")["installed"] and not shutil.which("npm"):
|
|
182
|
+
return "TypeScript extractor dependencies missing and npm not installed: install npm, then run `cg setup typescript`"
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def ensure_extractor(self) -> Path:
|
|
186
|
+
"""Directory the extractor runs from, with its npm dependencies (codegraph/core/extractors.py)."""
|
|
187
|
+
return extractors.ensure("typescript")
|
|
188
|
+
|
|
189
|
+
def index(self, project: Project, builder: GraphBuilder, frameworks: list[FrameworkPlugin]) -> dict:
|
|
190
|
+
if not shutil.which("node"):
|
|
191
|
+
return {"status": "skipped", "reason": "node not installed"}
|
|
192
|
+
exdir = self.ensure_extractor()
|
|
193
|
+
ctx = TsContext(project=project)
|
|
194
|
+
# src/ and app/ (SPA / Next / Nuxt 4), and the Laravel + Vite asset dirs; the extractor falls back to the
|
|
195
|
+
# tsconfig's own files when none of these hold any
|
|
196
|
+
ctx.extractor_cfg = {"root": str(project.root), "tsconfig": "tsconfig.json", "kinds": [],
|
|
197
|
+
"src_dirs": ["src", "app"] + [d for d in LARAVEL_ASSET_DIRS if (project.root / d).is_dir()]
|
|
198
|
+
+ cordova_www_dirs(project.root)}
|
|
199
|
+
pkg_cfgs = sub_tsconfigs(project.root)
|
|
200
|
+
if pkg_cfgs:
|
|
201
|
+
ctx.extractor_cfg["package_tsconfigs"] = pkg_cfgs
|
|
202
|
+
for fw in frameworks:
|
|
203
|
+
fw.register_hooks(ctx)
|
|
204
|
+
# the walks' directory rules (codegraph/presets: common + typescript skip_dirs, the test walk's
|
|
205
|
+
# test_walk_skip_dirs, the tsconfig files that are resolution input only), adjusted by .cg.yaml skip_dirs.add /
|
|
206
|
+
# keep and include; exclude globs and skip_dirs.add names also drop files the tsconfig itself lists
|
|
207
|
+
rules = path_rules(project, "typescript")
|
|
208
|
+
ctx.extractor_cfg.update(rules.extractor_cfg())
|
|
209
|
+
keep = set(rules.keep)
|
|
210
|
+
ctx.extractor_cfg["test_skip_names"] = sorted(set(presets.values("typescript", "test_walk_skip_dirs", default=[])) - keep)
|
|
211
|
+
ctx.extractor_cfg["source_skip_names"] = sorted(set(presets.values("typescript", "source_skip_dirs", default=[])) - keep)
|
|
212
|
+
added = set((project.options.get("config") or {}).get("skip_dirs", {}).get("add") or [])
|
|
213
|
+
ex = [x for x in (rules.user_exclude_regex(), names_regex(added)) if x]
|
|
214
|
+
if ex:
|
|
215
|
+
ctx.extractor_cfg["exclude_re"] = "|".join(f"(?:{x})" for x in ex)
|
|
216
|
+
if rules.generated_regex():
|
|
217
|
+
ctx.extractor_cfg["generated_re"] = rules.generated_regex()
|
|
218
|
+
from ...platforms import uses_react_native
|
|
219
|
+
pcfg = (project.options.get("config") or {}).get("platforms") or {}
|
|
220
|
+
if pcfg.get("file_suffixes", True) and uses_react_native(project.root):
|
|
221
|
+
ctx.extractor_cfg["platform_suffixes"] = [".ios", ".android", ".native", ".web", ""]
|
|
222
|
+
gen_files = rules.excluded_files(SUPPORTED["typescript"]) # generated / copied / vendored files the scan found
|
|
223
|
+
if gen_files:
|
|
224
|
+
ctx.extractor_cfg["exclude_files"] = gen_files
|
|
225
|
+
self.program = ctx
|
|
226
|
+
t0 = time.time()
|
|
227
|
+
cache_file, cache_status = None, "disabled"
|
|
228
|
+
if not os.environ.get("CODEGRAPH_NO_CACHE"):
|
|
229
|
+
fp = facts_fingerprint(project.root, ctx.extractor_cfg)
|
|
230
|
+
cdir = cache.subdir("ts")
|
|
231
|
+
rkey = cache.root_key(project.root)
|
|
232
|
+
cache_file = cdir / f"{rkey}-v{fsutil.CACHE_VERSION}-{fp}.json"
|
|
233
|
+
cache_status = "miss"
|
|
234
|
+
if cache_file and cache_file.exists():
|
|
235
|
+
facts = json.loads(cache_file.read_text())
|
|
236
|
+
cache_status = "hit"
|
|
237
|
+
else:
|
|
238
|
+
with tempfile.TemporaryDirectory() as td:
|
|
239
|
+
cfgp = Path(td) / "cfg.json"
|
|
240
|
+
out = Path(td) / "facts.json"
|
|
241
|
+
cfg = {**ctx.extractor_cfg, "out": str(out)}
|
|
242
|
+
cfgp.write_text(json.dumps(cfg))
|
|
243
|
+
proc = subprocess.run(["node", "--max-old-space-size=6144", str(exdir / EXTRACTOR.name), "--config", str(cfgp)],
|
|
244
|
+
capture_output=True, text=True)
|
|
245
|
+
if proc.returncode != 0:
|
|
246
|
+
raise RuntimeError(f"ts extractor failed: {proc.stderr[-2000:]}")
|
|
247
|
+
facts = json.loads(out.read_text())
|
|
248
|
+
if cache_file:
|
|
249
|
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
for old in cache_file.parent.glob(f"{rkey}-*.json"):
|
|
251
|
+
old.unlink(missing_ok=True) # keep one entry per project root
|
|
252
|
+
cache_file.write_text(json.dumps(facts))
|
|
253
|
+
cache.note_project(project.root)
|
|
254
|
+
ctx.facts = facts
|
|
255
|
+
facts.setdefault("stats", {})["facts_cache"] = cache_status
|
|
256
|
+
t_extract = time.time() - t0
|
|
257
|
+
for n in facts["nodes"]:
|
|
258
|
+
kind, key = n["id"].split(":", 1)
|
|
259
|
+
attrs = dict(n.get("attrs") or {})
|
|
260
|
+
if n.get("parent"):
|
|
261
|
+
attrs["parent"] = n["parent"]
|
|
262
|
+
builder.add_node(kind, key, n["name"], fqn=n["name"] if kind not in ("module",) else None, file=n["file"],
|
|
263
|
+
line=n["line"], end_line=n.get("end_line"), module=module_of(n["file"]), doc=n.get("doc"),
|
|
264
|
+
lang="ts", attrs=attrs)
|
|
265
|
+
if n.get("parent"):
|
|
266
|
+
builder.add_edge(n["parent"], n["id"], "CONTAINS", file=n["file"], line=n["line"])
|
|
267
|
+
for e in facts["edges"]:
|
|
268
|
+
a = {k: v for k, v in (e.get("attrs") or {}).items() if v is not None}
|
|
269
|
+
builder.add_edge(e["src"], e["dst"], e["kind"], file=e["file"], line=e["line"], confidence=e["confidence"], **a)
|
|
270
|
+
# HTTP calls -> client endpoint nodes. "API origins" = origins of the configured API clients
|
|
271
|
+
# (axios instances with a resolved baseURL); other origins stay in the endpoint key.
|
|
272
|
+
api_origins = set()
|
|
273
|
+
for a in facts["api_calls"]:
|
|
274
|
+
if a["client"] == "axios-instance":
|
|
275
|
+
for b in a.get("base") or []:
|
|
276
|
+
o = normalize_client_url(b)[1].get("origin")
|
|
277
|
+
if o:
|
|
278
|
+
api_origins.add(o)
|
|
279
|
+
self.api_origins = sorted(api_origins)
|
|
280
|
+
expanded_helpers = {(a["via_helper"]["at"]) for a in facts["api_calls"] if a.get("via_helper")}
|
|
281
|
+
# configured base URLs ({runtimeConfig.X} / {env.X}): their path part prefixes the client path
|
|
282
|
+
from .baseurl import ConfigValues, base_path, is_config_ph, API_NAME
|
|
283
|
+
cv = ConfigValues(project.root, facts.get("config_defaults") or {})
|
|
284
|
+
base_hits, base_unresolved = {}, set()
|
|
285
|
+
|
|
286
|
+
def prefer_config(vals):
|
|
287
|
+
"""A value built from a configured URL or an opaque override (remote config, a parameter) -> the
|
|
288
|
+
configured one: `remote || config.public.apiBase` is read as the configured API."""
|
|
289
|
+
if not vals or len(vals) < 2:
|
|
290
|
+
return vals
|
|
291
|
+
orig = [normalize_client_url(v or "")[1].get("origin") if v else None for v in vals]
|
|
292
|
+
# a config key whose configured value is empty is unset at run time: the other branch is what runs
|
|
293
|
+
unset = [is_config_ph(o) and (cv.lookup(o[1:-1]) or ("x",))[0] == "" for o in orig]
|
|
294
|
+
if any(unset) and not all(unset):
|
|
295
|
+
vals = [v for v, u in zip(vals, unset) if not u]
|
|
296
|
+
orig = [o for o, u in zip(orig, unset) if not u]
|
|
297
|
+
if len(vals) < 2:
|
|
298
|
+
return vals
|
|
299
|
+
if any(is_config_ph(o) for o in orig):
|
|
300
|
+
keep = [v for v, o in zip(vals, orig) if is_config_ph(o) or not (o and o.startswith("{"))]
|
|
301
|
+
return keep or vals
|
|
302
|
+
return vals
|
|
303
|
+
n_http = n_url_unknown = 0
|
|
304
|
+
for a in facts["api_calls"]:
|
|
305
|
+
at = f"{a['file']}:{a['line']}"
|
|
306
|
+
if not a.get("via_helper") and at in expanded_helpers:
|
|
307
|
+
a["expanded_at_callsites"] = True
|
|
308
|
+
continue # helper whose URL is a parameter: represented by its call-site expansions
|
|
309
|
+
bases = prefer_config(a.get("base")) or [None]
|
|
310
|
+
for base in bases:
|
|
311
|
+
for url in prefer_config(a["urls"]):
|
|
312
|
+
path, info = normalize_client_url(join_url(base, url))
|
|
313
|
+
origin = info.get("origin")
|
|
314
|
+
if path == "/" and not base and origin and origin.startswith("{") and not is_config_ph(origin):
|
|
315
|
+
# the whole URL is an opaque value (a wrapper's parameter without callers): no endpoint to name
|
|
316
|
+
n_url_unknown += 1
|
|
317
|
+
continue
|
|
318
|
+
resolved_base = None
|
|
319
|
+
if is_config_ph(origin):
|
|
320
|
+
hit = cv.lookup(origin[1:-1])
|
|
321
|
+
bp = base_path(hit[0]) if hit else None
|
|
322
|
+
if bp is not None:
|
|
323
|
+
resolved_base = {"placeholder": origin, "value": hit[0], "from": hit[1]}
|
|
324
|
+
path = (bp + path) if path != "/" else (bp or "/")
|
|
325
|
+
base_hits[origin] = resolved_base
|
|
326
|
+
else:
|
|
327
|
+
base_unresolved.add(origin)
|
|
328
|
+
if resolved_base:
|
|
329
|
+
okind = "env" # a configured server URL whose value is in the repo
|
|
330
|
+
elif origin and origin in api_origins:
|
|
331
|
+
okind = "api"
|
|
332
|
+
elif is_config_ph(origin) and origin.startswith("{env."):
|
|
333
|
+
okind = "unknown" # env value not in the repo: the base is some configured server
|
|
334
|
+
elif is_config_ph(origin):
|
|
335
|
+
okind = "env" if API_NAME.search(origin) else "other"
|
|
336
|
+
elif origin and "://" in origin:
|
|
337
|
+
okind = "other"
|
|
338
|
+
elif origin:
|
|
339
|
+
okind = "unknown"
|
|
340
|
+
else:
|
|
341
|
+
okind = "same-origin" if not base else "api"
|
|
342
|
+
key = f"{a['method']} {path}" if okind in ("api", "unknown", "env") else f"{a['method']} {origin or ''}{path}"
|
|
343
|
+
nid = builder.add_node("http", key, key, fqn=key, lang="ts", attrs={"method": a["method"], "path": path, "client": a["client"],
|
|
344
|
+
"origin": origin, "origin_kind": okind})
|
|
345
|
+
if resolved_base:
|
|
346
|
+
builder.nodes[nid].attrs["base"] = resolved_base
|
|
347
|
+
ctx.http_nodes.setdefault(nid, {"method": a["method"], "path": path, "calls": []})["calls"].append(a)
|
|
348
|
+
conf = min_conf(a["url_conf"], a.get("base_conf") or "exact", "resolved" if resolved_base else "exact")
|
|
349
|
+
if a.get("test"):
|
|
350
|
+
builder.nodes[nid].attrs.setdefault("test_only", True)
|
|
351
|
+
else:
|
|
352
|
+
builder.nodes[nid].attrs["test_only"] = False
|
|
353
|
+
builder.add_edge(a["src"], nid, "TEST_HTTP" if a.get("test") else "HTTP_CALLS", file=a["file"], line=a["line"], confidence=conf,
|
|
354
|
+
client=a["client"], url=url, base=base, expr=a.get("expr"), origin=info.get("origin"),
|
|
355
|
+
query=info.get("query"), via_helper=a.get("via_helper"),
|
|
356
|
+
query_keys=a.get("query"), body_keys=a.get("body"),
|
|
357
|
+
helper_template=a.get("expanded_at_callsites"))
|
|
358
|
+
n_http += 1
|
|
359
|
+
# realtime subscriptions -> channel_sub:<name> client channel nodes
|
|
360
|
+
n_sub = 0
|
|
361
|
+
for sub in facts.get("subscriptions") or []:
|
|
362
|
+
for name in sub["names"]:
|
|
363
|
+
if not name or not PH.sub("", name).strip(". -_:"):
|
|
364
|
+
continue # fully dynamic channel name
|
|
365
|
+
nid = builder.add_node("channel_sub", name, name, fqn=name, lang="ts", attrs={"name": name})
|
|
366
|
+
n = builder.nodes[nid]
|
|
367
|
+
n.attrs["visibility"] = sub["visibility"] if n.attrs.get("visibility") in (None, sub["visibility"]) else "mixed"
|
|
368
|
+
n.attrs["events"] = sorted(set(n.attrs.get("events") or []) | set(sub.get("events") or []))
|
|
369
|
+
n.attrs["clients"] = sorted(set(n.attrs.get("clients") or []) | {sub["client"]})
|
|
370
|
+
if not sub.get("test"):
|
|
371
|
+
n.attrs["test_only"] = False
|
|
372
|
+
else:
|
|
373
|
+
n.attrs.setdefault("test_only", True)
|
|
374
|
+
builder.add_edge(sub["src"], nid, "TEST_CALLS" if sub.get("test") else "SUBSCRIBES_CHANNEL", file=sub["file"],
|
|
375
|
+
line=sub["line"], confidence=sub["conf"], client=sub["client"], visibility=sub["visibility"],
|
|
376
|
+
events=sub.get("events") or None)
|
|
377
|
+
n_sub += 1
|
|
378
|
+
# web / native bridge sends (Capacitor, React Native, Expo) -> endpoint:<protocol>:<module>#<method>
|
|
379
|
+
# Electron IPC (endpoint:electron-ipc:<channel>), context bridge (endpoint:electron-preload:<key>#<member>),
|
|
380
|
+
# Tauri commands (endpoint:tauri:<command>)
|
|
381
|
+
from ...bridges import endpoint_key, protocol_receive, protocol_send
|
|
382
|
+
n_br = n_brr = 0
|
|
383
|
+
for br in facts.get("bridges") or []:
|
|
384
|
+
if not builder.has(br["src"]):
|
|
385
|
+
continue
|
|
386
|
+
protocol_send(builder, br["protocol"], br["module"], br["method"], br["src"], br["file"], br["line"], br["conf"],
|
|
387
|
+
test=bool(br.get("test")), via=br.get("via"), module_at=br.get("at"), external=br.get("external"),
|
|
388
|
+
api=br.get("api"), process=br.get("process"))
|
|
389
|
+
if br.get("api"):
|
|
390
|
+
builder.nodes["endpoint:" + endpoint_key(br["protocol"], br["module"], br["method"])].attrs["api"] = br["api"]
|
|
391
|
+
n_br += 1
|
|
392
|
+
rvs = facts.get("bridge_receivers") or []
|
|
393
|
+
# a channel variable typed as a union (`ipcMain.on(name, relay)` for every IpcEvents member) is a fallback:
|
|
394
|
+
# dropped where the process also receives the channel by name, kept only for channels another process sends
|
|
395
|
+
named = {(rv["protocol"], rv["module"], rv.get("process")) for rv in rvs if not rv.get("union")}
|
|
396
|
+
# (and from another process: main's webContents.send never reaches an ipcMain listener)
|
|
397
|
+
sent = defaultdict(set)
|
|
398
|
+
for br in facts.get("bridges") or []:
|
|
399
|
+
sent[(br["protocol"], br["module"])].add(br.get("process"))
|
|
400
|
+
for rv in rvs:
|
|
401
|
+
key = (rv["protocol"], rv["module"])
|
|
402
|
+
if not builder.has(rv["handler"]) or (rv.get("union") and (key + (rv.get("process"),) in named
|
|
403
|
+
or not (sent.get(key, set()) - {rv.get("process")}))):
|
|
404
|
+
continue
|
|
405
|
+
protocol_receive(builder, rv["protocol"], rv["module"], rv["method"], rv["handler"], rv["file"], rv["line"],
|
|
406
|
+
rv["conf"], via=rv.get("via"), process=rv.get("process"), external=rv.get("external"),
|
|
407
|
+
emitter_module=rv.get("emitter_module"))
|
|
408
|
+
n_brr += 1
|
|
409
|
+
# bridge calls with a dynamic module / method / event name: listed by cg bridges as unresolved (#61)
|
|
410
|
+
dyn = facts.get("bridge_dynamic") or []
|
|
411
|
+
if dyn:
|
|
412
|
+
builder.__dict__.setdefault("bridge_dynamic", []).extend(dyn)
|
|
413
|
+
# browser tests opening pages: resolved to page nodes once the framework plugin has set page routes
|
|
414
|
+
pv = getattr(builder, "pending_visits", None)
|
|
415
|
+
if pv is None:
|
|
416
|
+
pv = builder.pending_visits = []
|
|
417
|
+
pv += [{"src": v["src"], "url": v["url"], "file": v["file"], "line": v["line"], "via": v.get("via")} for v in facts.get("visits") or []]
|
|
418
|
+
# literal fallbacks (`x ?? y ?? 'UTC'`) -> resolution nodes (concept queries)
|
|
419
|
+
n_fb = 0
|
|
420
|
+
for fb in facts.get("fallbacks") or []:
|
|
421
|
+
if not builder.has(fb["owner"]) or fb["literal"] in ("", 0):
|
|
422
|
+
continue # `?? ''` / `|| 0` are null-guards, not configuration fallbacks
|
|
423
|
+
target = fb["chain"][0]
|
|
424
|
+
chain = [{"kind": "expr", "text": t} for t in fb["chain"][:-1]] + [{"kind": "literal", "value": fb["literal"], "op": fb["op"]}]
|
|
425
|
+
rid = builder.add_node("resolution", f"{fb['owner']}#{target}@{fb['line']}", target, fqn=f"{fb['owner'].split(':', 1)[1]}#{target}",
|
|
426
|
+
file=fb["file"], line=fb["line"], module=module_of(fb["file"]), lang="ts",
|
|
427
|
+
attrs={"fn": fb["owner"], "target": target, "form": "expr", "op": fb["op"], "chain": chain,
|
|
428
|
+
"signature": [c.get("text") if c["kind"] == "expr" else repr(c["value"]) for c in chain],
|
|
429
|
+
"final_literal": fb["literal"], "template": fb.get("template")})
|
|
430
|
+
builder.add_edge(fb["owner"], rid, "HAS_RESOLUTION", file=fb["file"], line=fb["line"], confidence="exact")
|
|
431
|
+
n_fb += 1
|
|
432
|
+
st = dict(facts["stats"])
|
|
433
|
+
# files the parser had to recover (#73): their error lines for coverage (no per-file buckets for TypeScript)
|
|
434
|
+
self.file_report = {"syntax_errors": st.pop("syntax_errors", None) or {}}
|
|
435
|
+
if facts.get("skipped_links"):
|
|
436
|
+
st["skipped_dangling_symlinks"] = facts["skipped_links"]
|
|
437
|
+
print(f"typescript: skipped {len(facts['skipped_links'])} dangling symlink(s): "
|
|
438
|
+
+ ", ".join(facts["skipped_links"][:5]), file=sys.stderr)
|
|
439
|
+
st.update({"literal_fallbacks": n_fb, "channel_subscriptions": n_sub, "bridge_sends": n_br, "bridge_receivers": n_brr,
|
|
440
|
+
"config_base_urls": {k: f"{v['value']} ({v['from']})" for k, v in sorted(base_hits.items())},
|
|
441
|
+
"config_base_urls_unresolved": sorted(base_unresolved), "http_url_unknown": n_url_unknown})
|
|
442
|
+
st.update({"extract_seconds": round(t_extract, 2), "http_edges": n_http, "http_endpoints": len(ctx.http_nodes),
|
|
443
|
+
"api_origins": self.api_origins})
|
|
444
|
+
return st
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# not hashed into the facts cache key: build output, tool caches, test reports (codegraph/presets/typescript.yaml)
|
|
448
|
+
SKIP_DIRS = presets.skip_dirs("typescript")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def facts_fingerprint(root, cfg: dict) -> str:
|
|
452
|
+
"""Cache key for extractor facts: cache version + extractor code + deps lock + config + (path, size, content hash)
|
|
453
|
+
of every project file outside node_modules (.nuxt and the project lockfile included, so `nuxi prepare` or a
|
|
454
|
+
dependency bump invalidates it). Content, not mtime: a same-size edit with a restored mtime is a miss."""
|
|
455
|
+
h = hashlib.sha256(f"cg-cache-v{fsutil.CACHE_VERSION}\n".encode())
|
|
456
|
+
for f in (EXTRACTOR, EXTRACTOR_DIR / "package-lock.json"):
|
|
457
|
+
h.update(f.read_bytes() if f.exists() else b"")
|
|
458
|
+
# generated stand-in types live in a fresh temp dir per run; their content follows from the project files
|
|
459
|
+
c = {k: v for k, v in cfg.items() if not (cfg.get("generated_types") and k in ("tsconfig", "components_dts"))}
|
|
460
|
+
h.update(json.dumps(c, sort_keys=True).encode())
|
|
461
|
+
if cfg.get("generated_types"): # ... and from the code that writes them: hash their content
|
|
462
|
+
gen = Path(cfg["tsconfig"]).parent
|
|
463
|
+
for f in sorted(gen.rglob("*.d.ts")):
|
|
464
|
+
h.update(f.read_bytes().replace(str(gen).encode(), b""))
|
|
465
|
+
root = Path(root)
|
|
466
|
+
for dp, dns, fns in os.walk(root):
|
|
467
|
+
dns[:] = sorted(d for d in dns if d not in SKIP_DIRS)
|
|
468
|
+
for fn in sorted(fns):
|
|
469
|
+
p = os.path.join(dp, fn)
|
|
470
|
+
h.update(f"{os.path.relpath(p, root)}|{fsutil.content_key(p)}\n".encode()) # dangling symlinks hash too
|
|
471
|
+
return h.hexdigest()[:20]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def module_of(path: str | None) -> str | None:
|
|
475
|
+
if not path:
|
|
476
|
+
return None
|
|
477
|
+
parts = path.split("/")
|
|
478
|
+
if parts[0] in ("app", "src") and len(parts) > 2:
|
|
479
|
+
return "/".join(parts[1:-1]) or parts[0]
|
|
480
|
+
return "/".join(parts[:-1]) or None
|
|
File without changes
|