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.
Files changed (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. 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