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,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