codetrail-cli 1.4.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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""The typescript extractor: TypeScript, JavaScript and Astro modules, npm projects, Astro routes and Workers (17.1).
|
|
2
|
+
|
|
3
|
+
Every `package.json` marks a project; a source file belongs to the nearest project above it. Imports are read with
|
|
4
|
+
tree-sitter (the TypeScript, TSX and JavaScript grammars), which tolerates syntax errors; `.astro` files contribute the
|
|
5
|
+
code between their `---` fences and in their `<script>` elements. Relative imports resolve the way TypeScript does,
|
|
6
|
+
`tsconfig.json` `paths` included; a bare name resolves to a workspace project, else to a declared npm package.
|
|
7
|
+
Wrangler configs become Workers with the names of their bindings; `vars` values are never read.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import tomllib
|
|
15
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
16
|
+
from pathlib import PurePosixPath
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import tree_sitter_javascript
|
|
20
|
+
import tree_sitter_typescript
|
|
21
|
+
from tree_sitter import Language, Node, Parser
|
|
22
|
+
|
|
23
|
+
from codetrail.extract import FileFacts, Reference, Resolution, without_credentials
|
|
24
|
+
from codetrail.facts import Entity, EntityKind, Relation, RelationKind, Source
|
|
25
|
+
|
|
26
|
+
TYPESCRIPT = Language(tree_sitter_typescript.language_typescript())
|
|
27
|
+
TSX = Language(tree_sitter_typescript.language_tsx())
|
|
28
|
+
JAVASCRIPT = Language(tree_sitter_javascript.language())
|
|
29
|
+
SOURCE_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
|
|
30
|
+
RESOLVE_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".astro")
|
|
31
|
+
ASSET_SUFFIXES = {
|
|
32
|
+
".json",
|
|
33
|
+
".css",
|
|
34
|
+
".scss",
|
|
35
|
+
".svg",
|
|
36
|
+
".png",
|
|
37
|
+
".jpg",
|
|
38
|
+
".jpeg",
|
|
39
|
+
".webp",
|
|
40
|
+
".gif",
|
|
41
|
+
".woff2",
|
|
42
|
+
".md",
|
|
43
|
+
".mdx",
|
|
44
|
+
".txt",
|
|
45
|
+
}
|
|
46
|
+
SKIPPED_SUFFIXES = (".d.ts", ".d.mts", ".d.cts", ".min.js", ".min.mjs")
|
|
47
|
+
SKIPPED_FOLDERS = {"dist", "build", ".astro", "node_modules"}
|
|
48
|
+
WRANGLER = {"wrangler.toml", "wrangler.json", "wrangler.jsonc"}
|
|
49
|
+
ASTRO_CONFIG = re.compile(r"astro\.config\.(mjs|js|ts|mts|cjs)")
|
|
50
|
+
PAGE_SUFFIXES = (".astro", ".md", ".mdx")
|
|
51
|
+
METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "ALL")
|
|
52
|
+
FENCE = re.compile(rb"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(\r?\n|\Z)", re.S)
|
|
53
|
+
GROUPS = {"dependencies": "main", "devDependencies": "dev", "peerDependencies": "peer"}
|
|
54
|
+
# Wrangler binding tables, and the key that names each binding.
|
|
55
|
+
BINDINGS = {
|
|
56
|
+
"d1_databases": ("d1", "binding"), "kv_namespaces": ("kv", "binding"), "r2_buckets": ("r2", "binding"),
|
|
57
|
+
"queues.producers": ("queue", "binding"), "services": ("service", "binding"),
|
|
58
|
+
"durable_objects.bindings": ("durable_object", "name"), "analytics_engine_datasets": ("analytics", "binding"),
|
|
59
|
+
} # fmt: skip
|
|
60
|
+
NODE_BUILTINS = {
|
|
61
|
+
"assert", "buffer", "child_process", "crypto", "dns", "events", "fs", "http", "https", "net", "os", "path",
|
|
62
|
+
"process", "querystring", "readline", "stream", "string_decoder", "timers", "tls", "url", "util", "worker_threads",
|
|
63
|
+
"zlib",
|
|
64
|
+
} # fmt: skip
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def project_id(folder: str) -> str:
|
|
68
|
+
return f"project:{folder or '.'}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def strip_json_comments(text: str) -> str:
|
|
72
|
+
"""JSON with comments (`//`, `/* */`) and trailing commas, as Wrangler and tsconfig allow, made plain JSON."""
|
|
73
|
+
out: list[str] = []
|
|
74
|
+
index, in_string = 0, False
|
|
75
|
+
while index < len(text):
|
|
76
|
+
character = text[index]
|
|
77
|
+
if in_string:
|
|
78
|
+
out.append(character)
|
|
79
|
+
if character == "\\" and index + 1 < len(text):
|
|
80
|
+
out.append(text[index + 1])
|
|
81
|
+
index += 1
|
|
82
|
+
elif character == '"':
|
|
83
|
+
in_string = False
|
|
84
|
+
elif character == '"':
|
|
85
|
+
in_string = True
|
|
86
|
+
out.append(character)
|
|
87
|
+
elif text.startswith("//", index):
|
|
88
|
+
end = text.find("\n", index)
|
|
89
|
+
index = len(text) if end == -1 else end
|
|
90
|
+
continue
|
|
91
|
+
elif text.startswith("/*", index):
|
|
92
|
+
end = text.find("*/", index + 2)
|
|
93
|
+
index = len(text) if end == -1 else end + 2
|
|
94
|
+
continue
|
|
95
|
+
else:
|
|
96
|
+
out.append(character)
|
|
97
|
+
index += 1
|
|
98
|
+
return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _skipped(path: str) -> bool:
|
|
102
|
+
parts = PurePosixPath(path).parts
|
|
103
|
+
return path.endswith(SKIPPED_SUFFIXES) or any(part in SKIPPED_FOLDERS for part in parts[:-1])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _folder(path: str) -> str:
|
|
107
|
+
parent = str(PurePosixPath(path).parent)
|
|
108
|
+
return "" if parent == "." else parent
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _join(folder: str, relative: str) -> str | None:
|
|
112
|
+
"""`folder/relative` normalized, or None when it is absolute or climbs above the repository."""
|
|
113
|
+
if relative.startswith("/"):
|
|
114
|
+
return None
|
|
115
|
+
parts: list[str] = [part for part in folder.split("/") if part]
|
|
116
|
+
for part in relative.split("/"):
|
|
117
|
+
if part in ("", "."):
|
|
118
|
+
continue
|
|
119
|
+
if part == "..":
|
|
120
|
+
if not parts:
|
|
121
|
+
return None
|
|
122
|
+
parts.pop()
|
|
123
|
+
else:
|
|
124
|
+
parts.append(part)
|
|
125
|
+
return "/".join(parts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TypeScriptExtractor:
|
|
129
|
+
name = "typescript"
|
|
130
|
+
version = 1
|
|
131
|
+
|
|
132
|
+
def __init__(self, max_tsconfig_paths: int = 100) -> None:
|
|
133
|
+
self._max_paths = max_tsconfig_paths
|
|
134
|
+
self._roots: list[str] = []
|
|
135
|
+
self._astro_roots: set[str] = set()
|
|
136
|
+
self._tsconfigs: dict[str, tuple[str, dict[str, list[str]]]] = {} # folder -> (base folder, paths)
|
|
137
|
+
self._parsers = {language: Parser(language) for language in (TYPESCRIPT, TSX, JAVASCRIPT)}
|
|
138
|
+
|
|
139
|
+
def handles(self, path: str) -> bool:
|
|
140
|
+
if _skipped(path):
|
|
141
|
+
return False
|
|
142
|
+
name = PurePosixPath(path).name
|
|
143
|
+
if name in ("package.json", "tsconfig.json") or name in WRANGLER:
|
|
144
|
+
return True
|
|
145
|
+
if path.endswith(SOURCE_SUFFIXES) or path.endswith(".astro"):
|
|
146
|
+
return True
|
|
147
|
+
return path.endswith((".md", ".mdx")) and "/src/pages/" in f"/{path}"
|
|
148
|
+
|
|
149
|
+
def prepare(self, paths: Sequence[str]) -> None:
|
|
150
|
+
roots = {_folder(path) for path in paths if PurePosixPath(path).name == "package.json"}
|
|
151
|
+
self._roots = sorted(roots, key=len, reverse=True) # deepest first: a nested project wins
|
|
152
|
+
self._astro_roots = {_folder(path) for path in paths if ASTRO_CONFIG.fullmatch(PurePosixPath(path).name)}
|
|
153
|
+
self._tsconfigs = {}
|
|
154
|
+
|
|
155
|
+
def _root_of(self, path: str) -> str | None:
|
|
156
|
+
for root in self._roots:
|
|
157
|
+
if not root or path.startswith(root + "/"):
|
|
158
|
+
return root
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def extract(self, path: str, content: bytes) -> FileFacts:
|
|
162
|
+
name = PurePosixPath(path).name
|
|
163
|
+
if name == "package.json":
|
|
164
|
+
return self._extract_project(path, content)
|
|
165
|
+
if name == "tsconfig.json":
|
|
166
|
+
self._read_tsconfig(path, content)
|
|
167
|
+
return FileFacts(path)
|
|
168
|
+
if name in WRANGLER:
|
|
169
|
+
return self._extract_worker(path, content)
|
|
170
|
+
if path.endswith((".md", ".mdx")):
|
|
171
|
+
return self._extract_page(path)
|
|
172
|
+
return self._extract_module(path, content)
|
|
173
|
+
|
|
174
|
+
def _extract_project(self, path: str, content: bytes) -> FileFacts:
|
|
175
|
+
data = json.loads(content.decode("utf-8"))
|
|
176
|
+
if not isinstance(data, dict):
|
|
177
|
+
return FileFacts(path)
|
|
178
|
+
project = project_id(_folder(path))
|
|
179
|
+
attributes = {"name": data["name"]} if isinstance(data.get("name"), str) and data["name"] else {}
|
|
180
|
+
entities = [Entity(project, EntityKind.PROJECT, attributes, (Source(path),))]
|
|
181
|
+
references = []
|
|
182
|
+
for field, group in GROUPS.items():
|
|
183
|
+
table = data.get(field)
|
|
184
|
+
if not isinstance(table, dict):
|
|
185
|
+
continue
|
|
186
|
+
for package_name, specifier in sorted(table.items()):
|
|
187
|
+
if not isinstance(package_name, str) or not package_name:
|
|
188
|
+
continue
|
|
189
|
+
package = f"package:npm/{package_name}"
|
|
190
|
+
entities.append(Entity(package, EntityKind.PACKAGE, {}, (Source(path),)))
|
|
191
|
+
dependency = {"specifier": without_credentials(str(specifier)), "group": group}
|
|
192
|
+
references.append(Reference(project, RelationKind.DEPENDS_ON, package, (Source(path),), dependency))
|
|
193
|
+
return FileFacts(path, tuple(entities), tuple(references))
|
|
194
|
+
|
|
195
|
+
def _read_tsconfig(self, path: str, content: bytes) -> None:
|
|
196
|
+
data = json.loads(strip_json_comments(content.decode("utf-8")))
|
|
197
|
+
options = data.get("compilerOptions", {}) if isinstance(data, dict) else {}
|
|
198
|
+
if not isinstance(options, dict):
|
|
199
|
+
return
|
|
200
|
+
folder = _folder(path)
|
|
201
|
+
base = _join(folder, str(options.get("baseUrl", "."))) or folder
|
|
202
|
+
paths = options.get("paths", {})
|
|
203
|
+
if (
|
|
204
|
+
isinstance(paths, dict)
|
|
205
|
+
and len(paths) + sum(len(v) for v in paths.values() if isinstance(v, list)) > self._max_paths
|
|
206
|
+
):
|
|
207
|
+
raise ValueError("too many paths") # patterns and targets both count; real configs have a handful
|
|
208
|
+
if isinstance(paths, dict):
|
|
209
|
+
self._tsconfigs[folder] = (base, {str(key): [str(value) for value in values] for key, values in
|
|
210
|
+
paths.items() if isinstance(values, list)}) # fmt: skip
|
|
211
|
+
|
|
212
|
+
def _extract_worker(self, path: str, content: bytes) -> FileFacts:
|
|
213
|
+
text = content.decode("utf-8")
|
|
214
|
+
data = tomllib.loads(text) if path.endswith(".toml") else json.loads(strip_json_comments(text))
|
|
215
|
+
if not isinstance(data, dict):
|
|
216
|
+
return FileFacts(path)
|
|
217
|
+
folder = _folder(path)
|
|
218
|
+
worker = f"worker:{folder or '.'}"
|
|
219
|
+
bindings: list[str] = [] # flat "type:name" strings, so the attribute length cut applies
|
|
220
|
+
for key, (kind, field) in BINDINGS.items():
|
|
221
|
+
table: Any = data
|
|
222
|
+
for part in key.split("."):
|
|
223
|
+
table = table.get(part, []) if isinstance(table, dict) else []
|
|
224
|
+
for entry in table if isinstance(table, list) else []:
|
|
225
|
+
if isinstance(entry, dict) and isinstance(entry.get(field), str):
|
|
226
|
+
bindings.append(f"{kind}:{entry[field]}")
|
|
227
|
+
routes = data.get("routes") or ([data["route"]] if data.get("route") else [])
|
|
228
|
+
for route in routes if isinstance(routes, list) else []:
|
|
229
|
+
pattern = route.get("pattern") if isinstance(route, dict) else route
|
|
230
|
+
if isinstance(pattern, str):
|
|
231
|
+
bindings.append(f"route:{pattern}")
|
|
232
|
+
attributes: dict[str, Any] = {"bindings": bindings} # binding names only; `vars` values are never read
|
|
233
|
+
if isinstance(data.get("name"), str):
|
|
234
|
+
attributes["name"] = data["name"]
|
|
235
|
+
references = []
|
|
236
|
+
main = data.get("main")
|
|
237
|
+
if isinstance(main, str) and (entry := _join(folder, main)):
|
|
238
|
+
attributes["main"] = entry
|
|
239
|
+
references.append(Reference(worker, RelationKind.CONTAINS, f"module:{entry}", (Source(path),)))
|
|
240
|
+
return FileFacts(path, (Entity(worker, EntityKind.WORKER, attributes, (Source(path),)),), tuple(references))
|
|
241
|
+
|
|
242
|
+
def _route(self, path: str, method: str, line: int | None = None) -> tuple[Entity, Reference] | None:
|
|
243
|
+
root = next((root for root in sorted(self._astro_roots, key=len, reverse=True)
|
|
244
|
+
if path.startswith(f"{root}/src/pages/" if root else "src/pages/")), None) # fmt: skip
|
|
245
|
+
if root is None:
|
|
246
|
+
return None
|
|
247
|
+
relative = path.removeprefix(f"{root}/" if root else "").removeprefix("src/pages/")
|
|
248
|
+
segments = []
|
|
249
|
+
for segment in PurePosixPath(relative).with_suffix("").parts:
|
|
250
|
+
segment = re.sub(r"\[\.\.\.(\w+)\]", r"{\1}", segment)
|
|
251
|
+
segment = re.sub(r"\[(\w+)\]", r"{\1}", segment)
|
|
252
|
+
segments.append(segment)
|
|
253
|
+
if segments and segments[-1] == "index":
|
|
254
|
+
segments.pop()
|
|
255
|
+
route_path = "/" + "/".join(segments)
|
|
256
|
+
route = f"route:{root or '.'} {method} {route_path}"
|
|
257
|
+
source = Source(path, line, line) if line else Source(path)
|
|
258
|
+
entity = Entity(route, EntityKind.ROUTE, {"method": method, "path": route_path, "project": project_id(root)},
|
|
259
|
+
(source,)) # fmt: skip
|
|
260
|
+
return entity, Reference(project_id(root), RelationKind.CONTAINS, route, (source,))
|
|
261
|
+
|
|
262
|
+
def _extract_page(self, path: str) -> FileFacts:
|
|
263
|
+
found = self._route(path, "GET")
|
|
264
|
+
return FileFacts(path, (found[0],), (found[1],)) if found else FileFacts(path)
|
|
265
|
+
|
|
266
|
+
def _extract_module(self, path: str, content: bytes) -> FileFacts:
|
|
267
|
+
root = self._root_of(path)
|
|
268
|
+
module_id = f"module:{path}"
|
|
269
|
+
attributes = {"name": path, "language": "astro" if path.endswith(".astro") else "typescript"}
|
|
270
|
+
if root is not None:
|
|
271
|
+
attributes["project"] = project_id(root)
|
|
272
|
+
entities = [Entity(module_id, EntityKind.MODULE, attributes, (Source(path),))]
|
|
273
|
+
references = (
|
|
274
|
+
[Reference(project_id(root), RelationKind.CONTAINS, module_id, (Source(path),))] if root is not None else []
|
|
275
|
+
)
|
|
276
|
+
methods: list[tuple[str, int]] = []
|
|
277
|
+
for tree, offset in self._trees(path, content):
|
|
278
|
+
for specifier, line in _imports(tree.root_node):
|
|
279
|
+
source = Source(path, line + offset, line + offset)
|
|
280
|
+
references.append(
|
|
281
|
+
Reference(module_id, RelationKind.IMPORTS, specifier, (source,), {"specifier": specifier})
|
|
282
|
+
)
|
|
283
|
+
if not path.endswith(".astro"):
|
|
284
|
+
methods += [(method, line + offset) for method, line in _exported_methods(tree.root_node)]
|
|
285
|
+
if path.endswith(".astro"):
|
|
286
|
+
methods = [("GET", 1)]
|
|
287
|
+
for method, line in methods:
|
|
288
|
+
found = self._route(path, method, line)
|
|
289
|
+
if found:
|
|
290
|
+
entities.append(found[0])
|
|
291
|
+
references.append(found[1])
|
|
292
|
+
return FileFacts(path, tuple(entities), tuple(references))
|
|
293
|
+
|
|
294
|
+
def _trees(self, path: str, content: bytes) -> Iterator[tuple[Any, int]]:
|
|
295
|
+
"""Each parsed code block of the file, with the number of lines before it."""
|
|
296
|
+
if not path.endswith(".astro"):
|
|
297
|
+
language = (
|
|
298
|
+
TSX
|
|
299
|
+
if path.endswith((".tsx", ".jsx"))
|
|
300
|
+
else JAVASCRIPT
|
|
301
|
+
if path.endswith((".js", ".mjs", ".cjs"))
|
|
302
|
+
else TYPESCRIPT
|
|
303
|
+
)
|
|
304
|
+
yield self._parsers[language].parse(content), 0
|
|
305
|
+
return
|
|
306
|
+
blocks = []
|
|
307
|
+
fence = FENCE.match(content)
|
|
308
|
+
if fence:
|
|
309
|
+
blocks.append((fence.group(1), content[: fence.start(1)].count(b"\n")))
|
|
310
|
+
blocks += _scripts(content)
|
|
311
|
+
for block, offset in blocks:
|
|
312
|
+
yield self._parsers[TYPESCRIPT].parse(block), offset
|
|
313
|
+
|
|
314
|
+
def resolve(self, files: Sequence[FileFacts], known: Mapping[str, Entity]) -> Resolution:
|
|
315
|
+
ours = self._project_ids()
|
|
316
|
+
names = {
|
|
317
|
+
str(entity.attributes["name"]): entity.id
|
|
318
|
+
for entity in known.values()
|
|
319
|
+
if entity.kind is EntityKind.PROJECT and entity.id in ours and "name" in entity.attributes
|
|
320
|
+
}
|
|
321
|
+
relations: list[Relation] = []
|
|
322
|
+
unresolved = 0
|
|
323
|
+
for file in files:
|
|
324
|
+
for reference in file.references:
|
|
325
|
+
if reference.kind is RelationKind.DEPENDS_ON:
|
|
326
|
+
name = reference.target.removeprefix("package:npm/")
|
|
327
|
+
target = names.get(name, reference.target) # a workspace sibling: a project, not a package
|
|
328
|
+
relations.append(Relation(reference.source_id, reference.kind, target, reference.attributes,
|
|
329
|
+
reference.sources)) # fmt: skip
|
|
330
|
+
continue
|
|
331
|
+
if reference.kind is not RelationKind.IMPORTS:
|
|
332
|
+
relations.append(Relation(reference.source_id, reference.kind, reference.target,
|
|
333
|
+
reference.attributes, reference.sources)) # fmt: skip
|
|
334
|
+
continue
|
|
335
|
+
resolved = self._resolve_import(file.path, reference.target, known, names)
|
|
336
|
+
if resolved is None:
|
|
337
|
+
unresolved += 1
|
|
338
|
+
elif resolved and resolved != reference.source_id:
|
|
339
|
+
relations.append(
|
|
340
|
+
Relation(reference.source_id, RelationKind.IMPORTS, resolved, {}, reference.sources)
|
|
341
|
+
)
|
|
342
|
+
return Resolution(relations, unresolved)
|
|
343
|
+
|
|
344
|
+
def _project_ids(self) -> set[str]:
|
|
345
|
+
return {project_id(root) for root in self._roots}
|
|
346
|
+
|
|
347
|
+
def _resolve_import(
|
|
348
|
+
self, path: str, specifier: str, known: Mapping[str, Entity], names: Mapping[str, str]
|
|
349
|
+
) -> str | None:
|
|
350
|
+
"""The module, project or package an import names; "" for a Node built-in (not counted); None if unknown."""
|
|
351
|
+
if PurePosixPath(specifier.split("?", 1)[0]).suffix.lower() in ASSET_SUFFIXES:
|
|
352
|
+
return "" # an asset (JSON, CSS, images): not code, so neither a module nor unresolved
|
|
353
|
+
if specifier.startswith("/"):
|
|
354
|
+
return None # absolute: never resolved
|
|
355
|
+
if specifier.startswith("."):
|
|
356
|
+
return self._module(_join(_folder(path), specifier), known)
|
|
357
|
+
mapped = self._mapped(path, specifier, known)
|
|
358
|
+
if mapped:
|
|
359
|
+
return mapped
|
|
360
|
+
bare = specifier.removeprefix("node:")
|
|
361
|
+
if specifier.startswith("node:") or bare.split("/")[0] in NODE_BUILTINS:
|
|
362
|
+
return ""
|
|
363
|
+
parts = bare.split("/")
|
|
364
|
+
name = "/".join(parts[:2]) if bare.startswith("@") else parts[0]
|
|
365
|
+
if name in names:
|
|
366
|
+
return names[name]
|
|
367
|
+
package = f"package:npm/{name}"
|
|
368
|
+
return package if package in known else None
|
|
369
|
+
|
|
370
|
+
def _mapped(self, path: str, specifier: str, known: Mapping[str, Entity]) -> str | None:
|
|
371
|
+
folder = _folder(path)
|
|
372
|
+
while True:
|
|
373
|
+
if folder in self._tsconfigs:
|
|
374
|
+
base, patterns = self._tsconfigs[folder]
|
|
375
|
+
for pattern, targets in patterns.items():
|
|
376
|
+
prefix, star, suffix = pattern.partition("*")
|
|
377
|
+
if star and specifier.startswith(prefix) and specifier.endswith(suffix):
|
|
378
|
+
middle = specifier[len(prefix) : len(specifier) - len(suffix)]
|
|
379
|
+
elif not star and specifier == pattern:
|
|
380
|
+
middle = ""
|
|
381
|
+
else:
|
|
382
|
+
continue
|
|
383
|
+
for target in targets:
|
|
384
|
+
found = self._module(_join(base, target.replace("*", middle)), known)
|
|
385
|
+
if found:
|
|
386
|
+
return found
|
|
387
|
+
return None
|
|
388
|
+
if not folder:
|
|
389
|
+
return None
|
|
390
|
+
folder = _folder(folder)
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _module(candidate: str | None, known: Mapping[str, Entity]) -> str | None:
|
|
394
|
+
if candidate is None:
|
|
395
|
+
return None
|
|
396
|
+
stem = re.sub(r"\.(js|mjs|cjs|jsx)$", "", candidate) # TypeScript's ESM imports name the emitted .js file
|
|
397
|
+
options = [candidate, *(stem + suffix for suffix in RESOLVE_SUFFIXES),
|
|
398
|
+
*(f"{candidate}/index{suffix}" for suffix in RESOLVE_SUFFIXES)] # fmt: skip
|
|
399
|
+
return next((f"module:{option}" for option in options if f"module:{option}" in known), None)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _scripts(content: bytes) -> list[tuple[bytes, int]]:
|
|
403
|
+
"""The code inside each <script> element and the lines before it, found in one pass (no backtracking regex)."""
|
|
404
|
+
lower = content.lower()
|
|
405
|
+
found: list[tuple[bytes, int]] = []
|
|
406
|
+
position = lines = counted = 0
|
|
407
|
+
while (opening := lower.find(b"<script", position)) != -1:
|
|
408
|
+
start = lower.find(b">", opening)
|
|
409
|
+
end = lower.find(b"</script>", start) if start != -1 else -1
|
|
410
|
+
if end == -1:
|
|
411
|
+
break # no closing tag after this one, so none after any later opening either
|
|
412
|
+
lines += content.count(b"\n", counted, start + 1) # a running count: each byte counted once
|
|
413
|
+
counted = start + 1
|
|
414
|
+
found.append((content[start + 1 : end], lines))
|
|
415
|
+
position = end + len(b"</script>")
|
|
416
|
+
return found
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _string_value(node: Node | None) -> str | None:
|
|
420
|
+
if node is None or node.type != "string":
|
|
421
|
+
return None
|
|
422
|
+
return "".join(
|
|
423
|
+
(child.text or b"").decode("utf-8", "replace") for child in node.children if child.type == "string_fragment"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _imports(root: Node) -> Iterator[tuple[str, int]]:
|
|
428
|
+
stack = [root]
|
|
429
|
+
while stack:
|
|
430
|
+
node = stack.pop()
|
|
431
|
+
if node.type in ("import_statement", "export_statement"):
|
|
432
|
+
specifier = _string_value(node.child_by_field_name("source"))
|
|
433
|
+
if specifier:
|
|
434
|
+
yield specifier, node.start_point[0] + 1
|
|
435
|
+
elif node.type == "call_expression":
|
|
436
|
+
function = node.child_by_field_name("function")
|
|
437
|
+
arguments = node.child_by_field_name("arguments")
|
|
438
|
+
if (
|
|
439
|
+
function is not None
|
|
440
|
+
and arguments is not None
|
|
441
|
+
and (function.type == "import" or (function.type == "identifier" and function.text == b"require"))
|
|
442
|
+
):
|
|
443
|
+
first = next((child for child in arguments.children if child.type == "string"), None)
|
|
444
|
+
specifier = _string_value(first)
|
|
445
|
+
if specifier:
|
|
446
|
+
yield specifier, node.start_point[0] + 1
|
|
447
|
+
stack.extend(reversed(node.children))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _exported_methods(root: Node) -> Iterator[tuple[str, int]]:
|
|
451
|
+
for node in root.children:
|
|
452
|
+
if node.type != "export_statement":
|
|
453
|
+
continue
|
|
454
|
+
for child in node.children:
|
|
455
|
+
names = []
|
|
456
|
+
if child.type == "function_declaration":
|
|
457
|
+
names = [child.child_by_field_name("name")]
|
|
458
|
+
elif child.type == "lexical_declaration":
|
|
459
|
+
names = [declarator.child_by_field_name("name") for declarator in child.children
|
|
460
|
+
if declarator.type == "variable_declarator"] # fmt: skip
|
|
461
|
+
for name in names:
|
|
462
|
+
text = (name.text or b"").decode() if name is not None else ""
|
|
463
|
+
if text in METHODS:
|
|
464
|
+
yield text, node.start_point[0] + 1
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Facts: deterministic statements about a target, produced only by extractors (design section 4.1).
|
|
2
|
+
|
|
3
|
+
An entity is one thing in the repository; a relation is a typed edge between two entities. Ids are a kind plus a
|
|
4
|
+
natural key, never a line number. A fact's hash covers its kind, key and attributes, not where it was found.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EntityKind(StrEnum):
|
|
18
|
+
PROJECT = "project"
|
|
19
|
+
MODULE = "module"
|
|
20
|
+
PACKAGE = "package"
|
|
21
|
+
DECISION = "decision"
|
|
22
|
+
ROUTE = "route"
|
|
23
|
+
SCHEMA = "schema"
|
|
24
|
+
TERRAFORM_MODULE = "terraform_module"
|
|
25
|
+
RESOURCE = "resource"
|
|
26
|
+
SWIFT_TARGET = "swift_target"
|
|
27
|
+
WORKER = "worker"
|
|
28
|
+
DEPLOYMENT = "deployment"
|
|
29
|
+
PART = "part"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RelationKind(StrEnum):
|
|
33
|
+
CONTAINS = "contains"
|
|
34
|
+
IMPORTS = "imports"
|
|
35
|
+
DEPENDS_ON = "depends_on"
|
|
36
|
+
SUPERSEDES = "supersedes"
|
|
37
|
+
USES_SCHEMA = "uses_schema"
|
|
38
|
+
REFERENCES = "references"
|
|
39
|
+
IMPLEMENTS = "implements"
|
|
40
|
+
CALLS_VIA = "calls_via"
|
|
41
|
+
DEPLOYED_ON = "deployed_on"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Source:
|
|
46
|
+
path: str
|
|
47
|
+
start_line: int | None = None
|
|
48
|
+
end_line: int | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _hash(*parts: Any) -> str:
|
|
52
|
+
return hashlib.sha256(json.dumps(parts, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Entity:
|
|
57
|
+
id: str
|
|
58
|
+
kind: EntityKind
|
|
59
|
+
attributes: Mapping[str, Any] = field(default_factory=dict)
|
|
60
|
+
sources: tuple[Source, ...] = ()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def hash(self) -> str:
|
|
64
|
+
return _hash(str(self.kind), self.id, dict(self.attributes))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Relation:
|
|
69
|
+
source_id: str
|
|
70
|
+
kind: RelationKind
|
|
71
|
+
target_id: str
|
|
72
|
+
attributes: Mapping[str, Any] = field(default_factory=dict)
|
|
73
|
+
sources: tuple[Source, ...] = ()
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def key(self) -> tuple[str, str, str]:
|
|
77
|
+
return (self.source_id, str(self.kind), self.target_id)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def hash(self) -> str:
|
|
81
|
+
return _hash(*self.key, dict(self.attributes))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Snapshot:
|
|
86
|
+
id: int
|
|
87
|
+
commit: str
|
|
88
|
+
taken_at: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class FactDiff:
|
|
93
|
+
added_entities: list[str] = field(default_factory=list)
|
|
94
|
+
changed_entities: list[str] = field(default_factory=list)
|
|
95
|
+
removed_entities: list[str] = field(default_factory=list)
|
|
96
|
+
added_relations: list[tuple[str, str, str]] = field(default_factory=list)
|
|
97
|
+
changed_relations: list[tuple[str, str, str]] = field(default_factory=list)
|
|
98
|
+
removed_relations: list[tuple[str, str, str]] = field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def is_empty(self) -> bool:
|
|
102
|
+
return not any(
|
|
103
|
+
(self.added_entities, self.changed_entities, self.removed_entities, self.added_relations,
|
|
104
|
+
self.changed_relations, self.removed_relations)
|
|
105
|
+
) # fmt: skip
|
|
106
|
+
|
|
107
|
+
def changed_ids(self) -> set[str]:
|
|
108
|
+
"""Every entity id this diff touches, including both ends of changed relations."""
|
|
109
|
+
ids = set(self.added_entities) | set(self.changed_entities) | set(self.removed_entities)
|
|
110
|
+
for source_id, _kind, target_id in self.added_relations + self.changed_relations + self.removed_relations:
|
|
111
|
+
ids |= {source_id, target_id}
|
|
112
|
+
return ids
|