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,221 @@
|
|
|
1
|
+
"""The python extractor: projects, modules, packages and imports (design section 5.2).
|
|
2
|
+
|
|
3
|
+
Every `pyproject.toml` marks a project root. A `.py` file belongs to the nearest root above it, and its dotted name is
|
|
4
|
+
its path from that root, with a leading `src/` dropped and `__init__` naming its package. Imports are read with
|
|
5
|
+
tree-sitter, which tolerates syntax errors, and resolve to the project's own modules first, then to a declared
|
|
6
|
+
package by top-level name. Standard-library imports are dropped; anything else is counted as unresolved.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import tomllib
|
|
14
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
15
|
+
from pathlib import PurePosixPath
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import tree_sitter_python
|
|
19
|
+
from tree_sitter import Language, Node, Parser
|
|
20
|
+
|
|
21
|
+
from codetrail.extract import FileFacts, Reference, Resolution, without_credentials
|
|
22
|
+
from codetrail.facts import Entity, EntityKind, Relation, RelationKind, Source
|
|
23
|
+
|
|
24
|
+
PYTHON = Language(tree_sitter_python.language())
|
|
25
|
+
REQUIREMENT = re.compile(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*([^;]*)")
|
|
26
|
+
# Import names that differ from the distribution that provides them.
|
|
27
|
+
IMPORT_TO_DISTRIBUTION = {
|
|
28
|
+
"yaml": "pyyaml",
|
|
29
|
+
"jwt": "pyjwt",
|
|
30
|
+
"dotenv": "python-dotenv",
|
|
31
|
+
"PIL": "pillow",
|
|
32
|
+
"dateutil": "python-dateutil",
|
|
33
|
+
"multipart": "python-multipart",
|
|
34
|
+
"magic": "python-magic",
|
|
35
|
+
"bs4": "beautifulsoup4",
|
|
36
|
+
"sklearn": "scikit-learn",
|
|
37
|
+
"cv2": "opencv-python",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalise_distribution(name: str) -> str:
|
|
42
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def project_id(root: str) -> str:
|
|
46
|
+
return f"project:{root or '.'}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PythonExtractor:
|
|
50
|
+
name = "python"
|
|
51
|
+
version = 1
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self._roots: list[str] = []
|
|
55
|
+
self._parser = Parser(PYTHON)
|
|
56
|
+
|
|
57
|
+
def handles(self, path: str) -> bool:
|
|
58
|
+
return path.endswith(".py") or PurePosixPath(path).name == "pyproject.toml"
|
|
59
|
+
|
|
60
|
+
def prepare(self, paths: Sequence[str]) -> None:
|
|
61
|
+
roots = [str(PurePosixPath(path).parent) for path in paths if PurePosixPath(path).name == "pyproject.toml"]
|
|
62
|
+
# Deepest first, so a nested project wins over the one around it.
|
|
63
|
+
self._roots = sorted((("" if root == "." else root) for root in roots), key=len, reverse=True)
|
|
64
|
+
|
|
65
|
+
def extract(self, path: str, content: bytes) -> FileFacts:
|
|
66
|
+
if PurePosixPath(path).name == "pyproject.toml":
|
|
67
|
+
return self._extract_project(path, content)
|
|
68
|
+
return self._extract_module(path, content)
|
|
69
|
+
|
|
70
|
+
def _extract_project(self, path: str, content: bytes) -> FileFacts:
|
|
71
|
+
data = tomllib.loads(content.decode("utf-8"))
|
|
72
|
+
root = str(PurePosixPath(path).parent)
|
|
73
|
+
project = project_id("" if root == "." else root)
|
|
74
|
+
table = data.get("project", {})
|
|
75
|
+
attributes = {
|
|
76
|
+
key: value
|
|
77
|
+
for key, value in (("name", table.get("name")), ("requires_python", table.get("requires-python")))
|
|
78
|
+
if isinstance(value, str) and value
|
|
79
|
+
}
|
|
80
|
+
entities = [Entity(project, EntityKind.PROJECT, attributes, (Source(path),))]
|
|
81
|
+
references = []
|
|
82
|
+
groups: dict[str, list[Any]] = {"main": table.get("dependencies", [])}
|
|
83
|
+
groups.update(data.get("dependency-groups", {}))
|
|
84
|
+
for group, requirements in groups.items():
|
|
85
|
+
for requirement in requirements:
|
|
86
|
+
match = REQUIREMENT.match(requirement) if isinstance(requirement, str) else None
|
|
87
|
+
if not match:
|
|
88
|
+
continue
|
|
89
|
+
package = f"package:pypi/{normalise_distribution(match.group(1))}"
|
|
90
|
+
entities.append(Entity(package, EntityKind.PACKAGE, {}, (Source(path),)))
|
|
91
|
+
references.append(
|
|
92
|
+
Reference(
|
|
93
|
+
project,
|
|
94
|
+
RelationKind.DEPENDS_ON,
|
|
95
|
+
package,
|
|
96
|
+
(Source(path),),
|
|
97
|
+
{"specifier": without_credentials(match.group(3).strip()), "group": group},
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return FileFacts(path, tuple(entities), tuple(references)) # fmt: skip
|
|
101
|
+
|
|
102
|
+
def _extract_module(self, path: str, content: bytes) -> FileFacts:
|
|
103
|
+
root = self._root_of(path)
|
|
104
|
+
if root is None:
|
|
105
|
+
return FileFacts(path)
|
|
106
|
+
name = self._module_name(path, root)
|
|
107
|
+
module_id = f"module:{path}"
|
|
108
|
+
module = Entity(module_id, EntityKind.MODULE, {"name": name, "project": project_id(root)}, (Source(path),))
|
|
109
|
+
package = name.split(".") if path.endswith("__init__.py") else name.split(".")[:-1]
|
|
110
|
+
references = [Reference(project_id(root), RelationKind.CONTAINS, module_id, (Source(path),))]
|
|
111
|
+
for target, names, line in self._imports(content, package):
|
|
112
|
+
references.append(
|
|
113
|
+
Reference(module_id, RelationKind.IMPORTS, target, (Source(path, line, line),), {"names": names})
|
|
114
|
+
)
|
|
115
|
+
return FileFacts(path, (module,), tuple(references))
|
|
116
|
+
|
|
117
|
+
def _root_of(self, path: str) -> str | None:
|
|
118
|
+
for root in self._roots:
|
|
119
|
+
if not root or path.startswith(root + "/"):
|
|
120
|
+
return root
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _module_name(path: str, root: str) -> str:
|
|
125
|
+
relative = PurePosixPath(path[len(root) + 1 :] if root else path)
|
|
126
|
+
parts = list(relative.with_suffix("").parts)
|
|
127
|
+
if parts and parts[0] == "src":
|
|
128
|
+
parts = parts[1:]
|
|
129
|
+
if parts and parts[-1] == "__init__":
|
|
130
|
+
parts = parts[:-1]
|
|
131
|
+
return ".".join(parts)
|
|
132
|
+
|
|
133
|
+
def _imports(self, content: bytes, package: list[str]) -> Iterator[tuple[str, list[str], int]]:
|
|
134
|
+
"""(module, imported names, line) for every import statement, relative ones made absolute."""
|
|
135
|
+
for node in _walk(self._parser.parse(content).root_node):
|
|
136
|
+
line = node.start_point[0] + 1
|
|
137
|
+
if node.type == "import_statement":
|
|
138
|
+
for child in node.children_by_field_name("name"):
|
|
139
|
+
yield _dotted(child), [], line
|
|
140
|
+
elif node.type == "import_from_statement":
|
|
141
|
+
module_node = node.child_by_field_name("module_name")
|
|
142
|
+
if module_node is None:
|
|
143
|
+
continue
|
|
144
|
+
names = [_dotted(child) for child in node.children_by_field_name("name")]
|
|
145
|
+
if module_node.type == "relative_import":
|
|
146
|
+
level = sum(
|
|
147
|
+
len(child.text or b"") for child in module_node.children if child.type == "import_prefix"
|
|
148
|
+
)
|
|
149
|
+
rest = [child for child in module_node.children if child.type == "dotted_name"]
|
|
150
|
+
base = package[: len(package) - (level - 1)] if level - 1 <= len(package) else []
|
|
151
|
+
target = ".".join([*base, *([_dotted(rest[0])] if rest else [])])
|
|
152
|
+
else:
|
|
153
|
+
target = _dotted(module_node)
|
|
154
|
+
if target:
|
|
155
|
+
yield target, names, line
|
|
156
|
+
|
|
157
|
+
def resolve(self, files: Sequence[FileFacts], known: Mapping[str, Entity]) -> Resolution:
|
|
158
|
+
modules: dict[str, dict[str, str]] = {} # project -> dotted name -> module id
|
|
159
|
+
for entity in known.values():
|
|
160
|
+
if entity.kind is EntityKind.MODULE:
|
|
161
|
+
modules.setdefault(str(entity.attributes["project"]), {})[str(entity.attributes["name"])] = entity.id
|
|
162
|
+
relations: list[Relation] = []
|
|
163
|
+
unresolved = 0
|
|
164
|
+
for file in files:
|
|
165
|
+
for reference in file.references:
|
|
166
|
+
if reference.kind is not RelationKind.IMPORTS:
|
|
167
|
+
relations.append(Relation(reference.source_id, reference.kind, reference.target,
|
|
168
|
+
reference.attributes, reference.sources)) # fmt: skip
|
|
169
|
+
continue
|
|
170
|
+
source = known.get(reference.source_id)
|
|
171
|
+
project = str(source.attributes["project"]) if source else ""
|
|
172
|
+
names = list(reference.attributes.get("names", []))
|
|
173
|
+
targets: list[str] | None = _local_targets(reference.target, names, modules.get(project, {}))
|
|
174
|
+
if not targets:
|
|
175
|
+
targets = _package_target(reference.target, known)
|
|
176
|
+
if targets is None:
|
|
177
|
+
unresolved += 1
|
|
178
|
+
continue
|
|
179
|
+
relations.extend(
|
|
180
|
+
Relation(reference.source_id, RelationKind.IMPORTS, target, {}, reference.sources)
|
|
181
|
+
for target in targets
|
|
182
|
+
if target != reference.source_id
|
|
183
|
+
)
|
|
184
|
+
return Resolution(relations, unresolved)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _local_targets(target: str, names: list[str], modules: Mapping[str, str]) -> list[str]:
|
|
188
|
+
"""The project's modules an import reaches: submodules named in a from-import, else the longest module prefix."""
|
|
189
|
+
found = [modules[f"{target}.{name}"] for name in names if f"{target}.{name}" in modules]
|
|
190
|
+
if found and len(found) == len(names):
|
|
191
|
+
return found
|
|
192
|
+
parts = target.split(".")
|
|
193
|
+
for end in range(len(parts), 0, -1):
|
|
194
|
+
candidate = ".".join(parts[:end])
|
|
195
|
+
if candidate in modules:
|
|
196
|
+
return [*found, modules[candidate]]
|
|
197
|
+
return found
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _package_target(target: str, known: Mapping[str, Entity]) -> list[str] | None:
|
|
201
|
+
"""A declared package for a third-party import, [] for the standard library, None when unknown."""
|
|
202
|
+
top = target.split(".")[0]
|
|
203
|
+
if top in sys.stdlib_module_names or top == "__future__":
|
|
204
|
+
return []
|
|
205
|
+
package = f"package:pypi/{normalise_distribution(IMPORT_TO_DISTRIBUTION.get(top, top))}"
|
|
206
|
+
return [package] if package in known else None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _dotted(node: Node) -> str:
|
|
210
|
+
if node.type == "aliased_import":
|
|
211
|
+
name = node.child_by_field_name("name")
|
|
212
|
+
node = name if name is not None else node
|
|
213
|
+
return (node.text or b"").decode("utf-8", "replace")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _walk(node: Node) -> Iterator[Node]:
|
|
217
|
+
stack = [node]
|
|
218
|
+
while stack:
|
|
219
|
+
current = stack.pop()
|
|
220
|
+
yield current
|
|
221
|
+
stack.extend(reversed(current.children))
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The swift extractor: Swift packages, their targets, dependencies, and the imports of each target (design 5.2).
|
|
2
|
+
|
|
3
|
+
`Package.swift` is read for the package's name, its targets (with their source folder and dependencies) and the
|
|
4
|
+
packages it fetches by URL (without any credentials in it). The manifest is untrusted, so reading it stays linear:
|
|
5
|
+
brackets are paired in one pass, a target call inside another is skipped, and every pattern's tail is bounded.
|
|
6
|
+
|
|
7
|
+
Each `.swift` file belongs to the target whose folder holds it; its `import` lines, read with tree-sitter, become
|
|
8
|
+
edges from that target to another target or to an external package's product. Apple's frameworks (Foundation,
|
|
9
|
+
SwiftUI...) resolve to nothing and are counted once each.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
16
|
+
from functools import cache
|
|
17
|
+
from pathlib import PurePosixPath
|
|
18
|
+
|
|
19
|
+
import tree_sitter_swift
|
|
20
|
+
from tree_sitter import Language, Parser
|
|
21
|
+
|
|
22
|
+
from codetrail.extract import FileFacts, Reference, Resolution, without_credentials
|
|
23
|
+
from codetrail.facts import Entity, EntityKind, Relation, RelationKind, Source
|
|
24
|
+
|
|
25
|
+
SWIFT = Language(tree_sitter_swift.language())
|
|
26
|
+
TARGET_CALL = re.compile(r"\.(target|testTarget|executableTarget)\s*\(")
|
|
27
|
+
NAME = re.compile(r'\bname\s*:\s*"([^"]+)"')
|
|
28
|
+
PATH = re.compile(r'\bpath\s*:\s*"([^"]+)"')
|
|
29
|
+
PRODUCT = re.compile(r'\.product\s*\(\s*name\s*:\s*"([^"]+)"\s*,\s*package\s*:\s*"([^"]+)"[^)]{0,2000}\)')
|
|
30
|
+
NAMED_TARGET = re.compile(r'\.(?:target|byName)\s*\(\s*name\s*:\s*"([^"]+)"[^)]{0,2000}\)')
|
|
31
|
+
QUOTED = re.compile(r'"([^"]+)"')
|
|
32
|
+
REMOTE_PACKAGE = re.compile(r'\.package\s*\(\s*url\s*:\s*"([^"]+)"([^)]{0,2000})\)')
|
|
33
|
+
VERSION = re.compile(r'"([0-9][^"]*)"')
|
|
34
|
+
IMPORT_KINDS = {"typealias", "struct", "class", "enum", "protocol", "let", "var", "func"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def package_id(name: str) -> str:
|
|
38
|
+
return f"package:swift/{name.removesuffix('.git').lower()}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _balanced(text: str, start: int, opening: str, closing: str) -> str | None:
|
|
42
|
+
"""The text between the bracket at `start` and its match, or None when it never closes."""
|
|
43
|
+
depth = 0
|
|
44
|
+
for index in range(start, len(text)):
|
|
45
|
+
if text[index] == opening:
|
|
46
|
+
depth += 1
|
|
47
|
+
elif text[index] == closing:
|
|
48
|
+
depth -= 1
|
|
49
|
+
if depth == 0:
|
|
50
|
+
return text[start + 1 : index]
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _closing_brackets(text: str) -> dict[int, int]:
|
|
55
|
+
"""Each "(" position paired with its ")", found in one pass; a bracket that never closes has no entry."""
|
|
56
|
+
pairs, open_at = {}, []
|
|
57
|
+
for index, character in enumerate(text):
|
|
58
|
+
if character == "(":
|
|
59
|
+
open_at.append(index)
|
|
60
|
+
elif character == ")" and open_at:
|
|
61
|
+
pairs[open_at.pop()] = index
|
|
62
|
+
return pairs
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _calls(text: str) -> Iterator[tuple[str, str]]:
|
|
66
|
+
"""Each target call and its arguments; one inside another call's arguments is skipped, so no text is read twice."""
|
|
67
|
+
closing = _closing_brackets(text)
|
|
68
|
+
read_to = -1
|
|
69
|
+
for match in TARGET_CALL.finditer(text):
|
|
70
|
+
start = match.end() - 1
|
|
71
|
+
if start > read_to and start in closing:
|
|
72
|
+
read_to = closing[start]
|
|
73
|
+
yield match.group(1), text[start + 1 : read_to]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SwiftExtractor:
|
|
77
|
+
name = "swift"
|
|
78
|
+
version = 1
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self._parser = Parser(SWIFT)
|
|
82
|
+
|
|
83
|
+
def handles(self, path: str) -> bool:
|
|
84
|
+
return path.endswith(".swift")
|
|
85
|
+
|
|
86
|
+
def prepare(self, paths: Sequence[str]) -> None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def extract(self, path: str, content: bytes) -> FileFacts:
|
|
90
|
+
if PurePosixPath(path).name == "Package.swift":
|
|
91
|
+
return self._manifest(path, content.decode("utf-8", "replace"))
|
|
92
|
+
imports = []
|
|
93
|
+
for node in self._parser.parse(content).root_node.children:
|
|
94
|
+
if node.type == "import_declaration":
|
|
95
|
+
words = [word for word in (node.text or b"").decode().split() if not word.startswith("@")]
|
|
96
|
+
words = [word for word in words[1:] if word not in IMPORT_KINDS] # drop "import" and a kind
|
|
97
|
+
if words:
|
|
98
|
+
line = node.start_point[0] + 1
|
|
99
|
+
imports.append(Reference(f"file:{path}", RelationKind.IMPORTS, words[0].split(".")[0],
|
|
100
|
+
(Source(path, line, line),))) # fmt: skip
|
|
101
|
+
return FileFacts(path, (), tuple(imports))
|
|
102
|
+
|
|
103
|
+
def _manifest(self, path: str, text: str) -> FileFacts:
|
|
104
|
+
folder = str(PurePosixPath(path).parent)
|
|
105
|
+
project = f"project:{folder}"
|
|
106
|
+
package_call = text.find("Package(")
|
|
107
|
+
named = NAME.search(text, package_call if package_call != -1 else 0)
|
|
108
|
+
package_name = named.group(1) if named else PurePosixPath(folder).name
|
|
109
|
+
source = (Source(path),)
|
|
110
|
+
entities = [Entity(project, EntityKind.PROJECT, {"name": package_name, "language": "swift"}, source)]
|
|
111
|
+
references: list[Reference] = []
|
|
112
|
+
for url, rest in REMOTE_PACKAGE.findall(text):
|
|
113
|
+
url = without_credentials(url)
|
|
114
|
+
external = package_id(url.rstrip("/").rsplit("/", 1)[-1])
|
|
115
|
+
entities.append(Entity(external, EntityKind.PACKAGE, {"url": url}, source))
|
|
116
|
+
version = VERSION.search(rest)
|
|
117
|
+
references.append(Reference(project, RelationKind.DEPENDS_ON, external, source,
|
|
118
|
+
{"version": version.group(1) if version else ""})) # fmt: skip
|
|
119
|
+
for kind, body in _calls(text):
|
|
120
|
+
name = NAME.search(body)
|
|
121
|
+
if name is None:
|
|
122
|
+
continue
|
|
123
|
+
target_name = name.group(1)
|
|
124
|
+
explicit = PATH.search(body)
|
|
125
|
+
default = ("Tests" if kind == "testTarget" else "Sources") + f"/{target_name}"
|
|
126
|
+
target_path = f"{folder}/{explicit.group(1) if explicit else default}".removeprefix("./")
|
|
127
|
+
target = f"swift_target:{folder}/{target_name}"
|
|
128
|
+
entities.append(Entity(target, EntityKind.SWIFT_TARGET,
|
|
129
|
+
{"name": target_name, "kind": "test" if kind == "testTarget" else "target",
|
|
130
|
+
"package": package_name, "path": target_path}, source)) # fmt: skip
|
|
131
|
+
references.append(Reference(project, RelationKind.CONTAINS, target, source))
|
|
132
|
+
start = body.find("dependencies:")
|
|
133
|
+
bracket = body.find("[", start) if start != -1 else -1
|
|
134
|
+
dependencies = _balanced(body, bracket, "[", "]") if bracket != -1 else None
|
|
135
|
+
if dependencies is None:
|
|
136
|
+
continue
|
|
137
|
+
for product, package in PRODUCT.findall(dependencies):
|
|
138
|
+
references.append(Reference(target, RelationKind.DEPENDS_ON, package_id(package), source,
|
|
139
|
+
{"product": product})) # fmt: skip
|
|
140
|
+
remaining = PRODUCT.sub("", dependencies)
|
|
141
|
+
names = NAMED_TARGET.findall(remaining) + QUOTED.findall(NAMED_TARGET.sub("", remaining))
|
|
142
|
+
for dependency in names:
|
|
143
|
+
references.append(Reference(target, RelationKind.DEPENDS_ON, f"target-name:{dependency}", source))
|
|
144
|
+
return FileFacts(path, tuple(entities), tuple(references))
|
|
145
|
+
|
|
146
|
+
def resolve(self, files: Sequence[FileFacts], known: Mapping[str, Entity]) -> Resolution:
|
|
147
|
+
targets = [entity for entity in known.values() if entity.kind is EntityKind.SWIFT_TARGET]
|
|
148
|
+
products = {
|
|
149
|
+
str(reference.attributes["product"]): reference.target
|
|
150
|
+
for file in files
|
|
151
|
+
for reference in file.references
|
|
152
|
+
if "product" in reference.attributes
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# Indexed once, so each name is a lookup rather than a scan of every target; the first target wins.
|
|
156
|
+
first_named: dict[str, str] = {}
|
|
157
|
+
first_named_in_folder: dict[tuple[str, str], str] = {}
|
|
158
|
+
for swift_target in targets:
|
|
159
|
+
target_name = swift_target.attributes.get("name")
|
|
160
|
+
if not isinstance(target_name, str):
|
|
161
|
+
continue
|
|
162
|
+
first_named.setdefault(target_name, swift_target.id)
|
|
163
|
+
first_named_in_folder.setdefault((swift_target.id.rsplit("/", 1)[0], target_name), swift_target.id)
|
|
164
|
+
|
|
165
|
+
def by_name(name: str, near: str) -> str | None:
|
|
166
|
+
"""The target with that name, preferring one beside `near`."""
|
|
167
|
+
return first_named_in_folder.get((near.rsplit("/", 1)[0], name)) or first_named.get(name)
|
|
168
|
+
|
|
169
|
+
# Each target's folder; when two targets share one, the last wins.
|
|
170
|
+
folders = {str(target.attributes.get("path", "")): target.id for target in targets}
|
|
171
|
+
|
|
172
|
+
@cache # once per file: a deep path's walk would otherwise be repeated for each of its imports
|
|
173
|
+
def owner(path: str) -> str | None:
|
|
174
|
+
"""The target whose folder holds the file; the deepest one when folders nest."""
|
|
175
|
+
end = path.rfind("/")
|
|
176
|
+
while end != -1: # each folder above the file, deepest first
|
|
177
|
+
if path[:end] in folders:
|
|
178
|
+
return folders[path[:end]]
|
|
179
|
+
end = path.rfind("/", 0, end)
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
relations: dict[tuple[str, str, str], Relation] = {}
|
|
183
|
+
unresolved_names: set[str] = set()
|
|
184
|
+
for file in files:
|
|
185
|
+
for reference in file.references:
|
|
186
|
+
source_id = reference.source_id
|
|
187
|
+
if source_id.startswith("file:"):
|
|
188
|
+
holder = owner(source_id.removeprefix("file:"))
|
|
189
|
+
if holder is None:
|
|
190
|
+
continue
|
|
191
|
+
source_id = holder
|
|
192
|
+
if source_id not in known: # e.g. a target dropped for its overlong id: no relation can start there
|
|
193
|
+
continue
|
|
194
|
+
target = reference.target
|
|
195
|
+
if target.startswith("target-name:") or reference.kind is RelationKind.IMPORTS:
|
|
196
|
+
name = target.removeprefix("target-name:")
|
|
197
|
+
target = by_name(name, source_id) or products.get(name) or ""
|
|
198
|
+
if not target:
|
|
199
|
+
unresolved_names.add(name)
|
|
200
|
+
continue
|
|
201
|
+
if target in known and target != source_id:
|
|
202
|
+
relation = Relation(source_id, reference.kind, target, reference.attributes, reference.sources)
|
|
203
|
+
relations.setdefault(relation.key, relation)
|
|
204
|
+
return Resolution(list(relations.values()), len(unresolved_names))
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The terraform extractor: modules, resources, module calls and references (design section 5.2).
|
|
2
|
+
|
|
3
|
+
Every folder holding `.tf` files is a Terraform module. `resource` blocks are resources the module contains; a
|
|
4
|
+
reference such as `google_storage_bucket.audio.name` inside a resource is an edge to that resource in the same module;
|
|
5
|
+
a `module` block whose `source` is a relative path is an edge to the module folder it names, when that folder is in
|
|
6
|
+
the repository. Registry and remote sources are counted as unresolved, never fetched.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import posixpath
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from pathlib import PurePosixPath
|
|
15
|
+
|
|
16
|
+
import tree_sitter_hcl
|
|
17
|
+
from tree_sitter import Language, Node, Parser
|
|
18
|
+
|
|
19
|
+
from codetrail.extract import FileFacts, Reference, Resolution
|
|
20
|
+
from codetrail.facts import Entity, EntityKind, Relation, RelationKind, Source
|
|
21
|
+
|
|
22
|
+
HCL = Language(tree_sitter_hcl.language())
|
|
23
|
+
REFERENCE = re.compile(r"\b([a-z][a-z0-9]*_[a-z0-9_]+)\.([A-Za-z_][A-Za-z0-9_-]*)\b")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def module_id(folder: str) -> str:
|
|
27
|
+
return f"terraform_module:{folder}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
PATH_NAMES = {b"source_dir", b"source", b"context", b"dockerfile", b"path", b"working_dir"}
|
|
31
|
+
PATH_SEGMENT = re.compile(r"[A-Za-z0-9._-]+")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TerraformExtractor:
|
|
35
|
+
name = "terraform"
|
|
36
|
+
version = 2 # resources record their `paths`
|
|
37
|
+
|
|
38
|
+
def __init__(self, max_paths: int = 20) -> None:
|
|
39
|
+
self._parser = Parser(HCL)
|
|
40
|
+
self._max_paths = max_paths
|
|
41
|
+
|
|
42
|
+
def handles(self, path: str) -> bool:
|
|
43
|
+
return path.endswith(".tf")
|
|
44
|
+
|
|
45
|
+
def prepare(self, paths: Sequence[str]) -> None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def extract(self, path: str, content: bytes) -> FileFacts:
|
|
49
|
+
folder = str(PurePosixPath(path).parent)
|
|
50
|
+
module = module_id(folder)
|
|
51
|
+
entities = [Entity(module, EntityKind.TERRAFORM_MODULE, {"name": PurePosixPath(folder).name or folder})]
|
|
52
|
+
references: list[Reference] = []
|
|
53
|
+
root = self._parser.parse(content).root_node
|
|
54
|
+
body = next((child for child in root.children if child.type == "body"), None)
|
|
55
|
+
for block in body.children if body is not None else []:
|
|
56
|
+
if block.type != "block":
|
|
57
|
+
continue
|
|
58
|
+
kind, labels = _block_head(block)
|
|
59
|
+
line = block.start_point[0] + 1
|
|
60
|
+
source = (Source(path, line, block.end_point[0] + 1),)
|
|
61
|
+
if kind == "resource" and len(labels) == 2:
|
|
62
|
+
resource = f"resource:{folder}/{labels[0]}.{labels[1]}"
|
|
63
|
+
attributes: dict[str, object] = {"type": labels[0], "name": labels[1], "module": folder}
|
|
64
|
+
found = _paths(folder, block, self._max_paths)
|
|
65
|
+
if found:
|
|
66
|
+
attributes["paths"] = found
|
|
67
|
+
entities.append(Entity(resource, EntityKind.RESOURCE, attributes, source))
|
|
68
|
+
references.append(Reference(module, RelationKind.CONTAINS, resource, source))
|
|
69
|
+
text = (block.text or b"").decode("utf-8", "replace")
|
|
70
|
+
for match in sorted(set(REFERENCE.findall(text))):
|
|
71
|
+
if match != (labels[0], labels[1]):
|
|
72
|
+
target = f"resource:{folder}/{match[0]}.{match[1]}"
|
|
73
|
+
references.append(Reference(resource, RelationKind.REFERENCES, target, source))
|
|
74
|
+
elif kind == "module" and labels:
|
|
75
|
+
called = _module_source(block)
|
|
76
|
+
if called is not None and called.startswith("."):
|
|
77
|
+
target_folder = posixpath.normpath(posixpath.join(folder, called))
|
|
78
|
+
references.append(Reference(module, RelationKind.REFERENCES, module_id(target_folder), source,
|
|
79
|
+
{"call": labels[0]})) # fmt: skip
|
|
80
|
+
else:
|
|
81
|
+
references.append(Reference(module, RelationKind.REFERENCES, "", source, {"call": labels[0]}))
|
|
82
|
+
return FileFacts(path, tuple(entities), tuple(references))
|
|
83
|
+
|
|
84
|
+
def resolve(self, files: Sequence[FileFacts], known: Mapping[str, Entity]) -> Resolution:
|
|
85
|
+
relations: list[Relation] = []
|
|
86
|
+
unresolved = 0
|
|
87
|
+
for file in files:
|
|
88
|
+
for reference in file.references:
|
|
89
|
+
if reference.target in known:
|
|
90
|
+
relations.append(Relation(reference.source_id, reference.kind, reference.target,
|
|
91
|
+
reference.attributes, reference.sources)) # fmt: skip
|
|
92
|
+
elif reference.kind is RelationKind.REFERENCES and reference.source_id.startswith("terraform_module:"):
|
|
93
|
+
unresolved += 1 # a registry, remote or missing module
|
|
94
|
+
# a resource name that matches no resource (a data source, a provider attribute) is not a reference
|
|
95
|
+
return Resolution(relations, unresolved)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _paths(folder: str, block: Node, limit: int) -> list[str]:
|
|
99
|
+
"""Plain-string path attributes of the block (nested blocks too), resolved lexically against the module's folder.
|
|
100
|
+
|
|
101
|
+
Only an attribute whose value is one plain string counts: no interpolation, heredoc or comment, every part a plain
|
|
102
|
+
path segment (so nothing credential-shaped), and nothing absolute or climbing above the repository.
|
|
103
|
+
"""
|
|
104
|
+
found: set[str] = set()
|
|
105
|
+
stack = [block]
|
|
106
|
+
while stack:
|
|
107
|
+
node = stack.pop()
|
|
108
|
+
for child in node.children:
|
|
109
|
+
if child.type in ("body", "block"):
|
|
110
|
+
stack.append(child)
|
|
111
|
+
elif child.type == "attribute":
|
|
112
|
+
name = next((part for part in child.children if part.type == "identifier"), None)
|
|
113
|
+
value = _plain_string(child) if name is not None and name.text in PATH_NAMES else None
|
|
114
|
+
if value is None or value.startswith("/"):
|
|
115
|
+
continue
|
|
116
|
+
parts = [part for part in value.split("/") if part not in ("", ".")]
|
|
117
|
+
if not parts or any(part != ".." and not PATH_SEGMENT.fullmatch(part) for part in parts):
|
|
118
|
+
continue
|
|
119
|
+
joined = posixpath.normpath(posixpath.join(folder, value))
|
|
120
|
+
if joined != ".." and not joined.startswith("../"):
|
|
121
|
+
found.add("" if joined == "." else joined)
|
|
122
|
+
return sorted(found)[:limit]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _plain_string(attribute: Node) -> str | None:
|
|
126
|
+
"""The attribute's value when it is exactly one quoted string with no interpolation."""
|
|
127
|
+
expression = next((part for part in attribute.children if part.type == "expression"), None)
|
|
128
|
+
literal = expression.children[0] if expression is not None and expression.child_count == 1 else None
|
|
129
|
+
string = (
|
|
130
|
+
literal.children[0]
|
|
131
|
+
if literal is not None and literal.type == "literal_value" and literal.child_count == 1
|
|
132
|
+
else None
|
|
133
|
+
)
|
|
134
|
+
if string is None or string.type != "string_lit":
|
|
135
|
+
return None
|
|
136
|
+
texts = [part for part in string.children if part.type == "template_literal"]
|
|
137
|
+
others = [part for part in string.children if part.type not in ("template_literal", "quoted_template_start",
|
|
138
|
+
"quoted_template_end")] # fmt: skip
|
|
139
|
+
return (texts[0].text or b"").decode("utf-8", "replace") if len(texts) == 1 and not others else None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _block_head(block: Node) -> tuple[str, list[str]]:
|
|
143
|
+
kind = ""
|
|
144
|
+
labels = []
|
|
145
|
+
for child in block.children:
|
|
146
|
+
if child.type == "identifier" and not kind:
|
|
147
|
+
kind = (child.text or b"").decode()
|
|
148
|
+
elif child.type == "string_lit":
|
|
149
|
+
labels.append(
|
|
150
|
+
"".join((part.text or b"").decode() for part in child.children if part.type == "template_literal")
|
|
151
|
+
)
|
|
152
|
+
elif child.type == "block_start":
|
|
153
|
+
break
|
|
154
|
+
return kind, labels
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _module_source(block: Node) -> str | None:
|
|
158
|
+
body = next((child for child in block.children if child.type == "body"), None)
|
|
159
|
+
for attribute in body.children if body is not None else []:
|
|
160
|
+
if attribute.type != "attribute":
|
|
161
|
+
continue
|
|
162
|
+
name = next((child for child in attribute.children if child.type == "identifier"), None)
|
|
163
|
+
if name is not None and name.text == b"source":
|
|
164
|
+
literal = re.search(rb'"([^"]*)"', attribute.text or b"")
|
|
165
|
+
return literal.group(1).decode() if literal else None
|
|
166
|
+
return None
|