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
codetrail/system.py ADDED
@@ -0,0 +1,484 @@
1
+ """The system pass: a repository's parts and the connections between them, from facts (design section 17.3).
2
+
3
+ It runs after the extractors in every update. It is plain code over the facts and a few allowed files, read through a
4
+ reader that refuses anything outside the allowed-files list; it records each connection with its evidence (explicit,
5
+ with the file and line, or matched, with the rule and both names) and never connects parts any other way. It can't
6
+ fail an update: a rule that meets something unexpected adds a warning and moves on.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections import Counter
13
+ from collections.abc import Callable, Iterable, Mapping
14
+ from dataclasses import dataclass, field
15
+ from pathlib import PurePosixPath
16
+ from typing import Any
17
+
18
+ from codetrail.facts import Entity, EntityKind, Relation, RelationKind, Source
19
+
20
+ SERVER_FRAMEWORKS = {
21
+ "package:pypi/fastapi",
22
+ "package:pypi/starlette",
23
+ "package:pypi/flask",
24
+ "package:pypi/django",
25
+ "package:pypi/litestar",
26
+ "package:pypi/sanic",
27
+ "package:pypi/aiohttp",
28
+ "package:pypi/quart",
29
+ "package:npm/express",
30
+ "package:npm/fastify",
31
+ "package:npm/hono",
32
+ "package:npm/koa",
33
+ "package:npm/@nestjs/core",
34
+ "package:swift/vapor",
35
+ }
36
+ SITE_FRAMEWORKS = {
37
+ "package:npm/astro",
38
+ "package:npm/next",
39
+ "package:npm/vite",
40
+ "package:npm/@sveltejs/kit",
41
+ "package:npm/nuxt",
42
+ "package:npm/gatsby",
43
+ "package:npm/@remix-run/react",
44
+ "package:npm/react-scripts",
45
+ }
46
+ PLATFORMS = {
47
+ "google_cloud": "Google Cloud",
48
+ "aws": "AWS",
49
+ "azure": "Azure",
50
+ "cloudflare": "Cloudflare",
51
+ "fly": "Fly.io",
52
+ }
53
+ RESOURCE_PLATFORMS = {"google_": "google_cloud", "aws_": "aws", "azurerm_": "azure", "cloudflare_": "cloudflare"}
54
+ # Runtime services whose names are matched against part folders (a dashed arrow, never a solid one).
55
+ NAMED_SERVICES = {
56
+ "google_cloud_run_v2_service",
57
+ "google_cloud_run_service",
58
+ "google_cloud_run_v2_job",
59
+ "google_cloudfunctions2_function",
60
+ "cloudflare_workers_script",
61
+ "aws_lambda_function",
62
+ "aws_ecs_service",
63
+ "azurerm_container_app",
64
+ }
65
+ CONFIG_NAMES = {"package.json", "Package.swift", "Makefile", "justfile", "pyproject.toml"}
66
+ # Matched once, after the last "generator" in a name, so a hostile name costs linear time.
67
+ GENERATOR_TAIL = re.compile(r"[-_.a-z]*\.ya?ml|[-_.a-z]*")
68
+ WORD = re.compile(r"[a-z0-9]+")
69
+ TOKEN_SPLIT = re.compile(r"[\s\"'`=,;:()<>\[\]{}]+")
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class SystemFacts:
74
+ entities: list[Entity] = field(default_factory=list)
75
+ relations: list[Relation] = field(default_factory=list)
76
+ warnings: list[str] = field(default_factory=list)
77
+ counts: Counter[str] = field(default_factory=Counter)
78
+
79
+
80
+ @dataclass
81
+ class Part:
82
+ id: str
83
+ kind: str # service, app, library, contract, infrastructure, platform
84
+ name: str
85
+ folder: str # "" for the root; a contract's folder is its document's
86
+ source: Source
87
+ file: str = "" # a contract's document
88
+
89
+
90
+ def _location(fact_id: str) -> str:
91
+ location = fact_id.split(":", 1)[1]
92
+ return "" if location == "." else location
93
+
94
+
95
+ def _normal(folder: str, relative: str) -> str | None:
96
+ """`folder/relative` lexically normalized; None when it is absolute, templated or climbs out of the repository."""
97
+ if relative.startswith("/") or "$" in relative:
98
+ return None
99
+ parts = [part for part in folder.split("/") if part]
100
+ for part in relative.split("/"):
101
+ if part in ("", "."):
102
+ continue
103
+ if part == "..":
104
+ if not parts:
105
+ return None
106
+ parts.pop()
107
+ else:
108
+ parts.append(part)
109
+ return "/".join(parts)
110
+
111
+
112
+ class _System:
113
+ def __init__(
114
+ self,
115
+ entities: Iterable[Entity],
116
+ relations: Iterable[Relation],
117
+ files: Mapping[str, str],
118
+ read: Callable[[str], bytes | None],
119
+ ) -> None:
120
+ self.entities = {entity.id: entity for entity in entities}
121
+ self.relations = list(relations)
122
+ self.files = files
123
+ self.read = read
124
+ self.parts: dict[str, Part] = {}
125
+ self.out: list[Relation] = []
126
+ self.seen: set[tuple[str, str, str]] = set()
127
+ self.warnings: list[str] = []
128
+ self.counts: Counter[str] = Counter()
129
+ self.owners: dict[str, Part | None] = {} # folder -> its owner, once code and infrastructure parts are known
130
+
131
+ # Parts --------------------------------------------------------------------------------------------------------
132
+ def add_part(self, part: Part) -> None:
133
+ self.parts.setdefault(part.id, part)
134
+
135
+ def platform(self, key: str) -> str:
136
+ part_id = f"part:platform:{key}"
137
+ self.add_part(Part(part_id, "platform", PLATFORMS[key], "", Source("")))
138
+ return part_id
139
+
140
+ def find_parts(self) -> None:
141
+ depends: dict[str, set[str]] = {}
142
+ for relation in self.relations:
143
+ if relation.kind is RelationKind.DEPENDS_ON:
144
+ depends.setdefault(relation.source_id, set()).add(relation.target_id)
145
+ for entity in self.entities.values():
146
+ if entity.kind is EntityKind.PROJECT:
147
+ folder = _location(entity.id)
148
+ needs = depends.get(entity.id, set())
149
+ kind = "service" if needs & SERVER_FRAMEWORKS else "app" if needs & SITE_FRAMEWORKS else "library"
150
+ name = str(entity.attributes.get("name") or PurePosixPath(folder).name or "root")
151
+ self.add_part(
152
+ Part(
153
+ f"part:{folder or '.'}", kind, name, folder, entity.sources[0] if entity.sources else Source("")
154
+ )
155
+ )
156
+ elif entity.kind is EntityKind.TERRAFORM_MODULE:
157
+ folder = _location(entity.id)
158
+ self.add_part(
159
+ Part(
160
+ f"part:{folder or '.'}",
161
+ "infrastructure",
162
+ folder,
163
+ folder,
164
+ entity.sources[0] if entity.sources else Source(""),
165
+ )
166
+ )
167
+ for path in self.files:
168
+ if path.endswith(".xcodeproj/project.pbxproj"):
169
+ folder = str(PurePosixPath(path).parent.parent)
170
+ folder = "" if folder == "." else folder
171
+ name = PurePosixPath(path).parent.stem
172
+ self.parts[f"part:{folder or '.'}"] = Part(f"part:{folder or '.'}", "app", name, folder, Source(path))
173
+ for entity in self.entities.values():
174
+ if entity.kind is EntityKind.WORKER:
175
+ folder = _location(entity.id)
176
+ if f"part:{folder or '.'}" not in self.parts:
177
+ name = str(entity.attributes.get("name") or PurePosixPath(folder).name)
178
+ self.add_part(Part(f"part:{folder or '.'}", "service", name, folder, entity.sources[0]))
179
+ documents = sorted(
180
+ {
181
+ source.path
182
+ for entity in self.entities.values()
183
+ if entity.kind in (EntityKind.ROUTE, EntityKind.SCHEMA)
184
+ and not str(entity.attributes.get("project", ""))
185
+ for source in entity.sources
186
+ }
187
+ )
188
+ by_blob: dict[str, list[str]] = {}
189
+ for document in documents:
190
+ by_blob.setdefault(self.files.get(document, document), []).append(document)
191
+ for copies in by_blob.values():
192
+ # Identical documents are one contract: the original is the one no code part holds, else the shortest path;
193
+ # the copies are evidence of who uses it (the contracts rule reads them by blob).
194
+ document = min(copies, key=lambda path: (self.owner(path) is not None, len(path), path))
195
+ folder = str(PurePosixPath(document).parent)
196
+ self.add_part(
197
+ Part(
198
+ f"part:{document}",
199
+ "contract",
200
+ PurePosixPath(document).name,
201
+ "" if folder == "." else folder,
202
+ Source(document),
203
+ file=document,
204
+ )
205
+ )
206
+
207
+ def owner(self, path: str) -> Part | None:
208
+ """The innermost code or infrastructure part whose folder holds `path`."""
209
+ folder = path
210
+ visited: list[str] = []
211
+ while True: # one lookup per folder level, remembered, so neither size nor depth makes this slow
212
+ if folder in self.owners:
213
+ found = self.owners[folder]
214
+ break
215
+ visited.append(folder)
216
+ part = self.parts.get(f"part:{folder or '.'}")
217
+ if part is not None and part.kind not in ("contract", "platform") and part.folder == folder:
218
+ found = part
219
+ break
220
+ if not folder:
221
+ found = None
222
+ break
223
+ folder = folder.rpartition("/")[0]
224
+ for each in visited:
225
+ self.owners[each] = found
226
+ return found
227
+
228
+ # Connections --------------------------------------------------------------------------------------------------
229
+ def connect(
230
+ self,
231
+ source: str,
232
+ kind: RelationKind,
233
+ target: str,
234
+ rule: str,
235
+ attributes: dict[str, Any],
236
+ sources: tuple[Source, ...] = (),
237
+ ) -> None:
238
+ if source == target or (source, str(kind), target) in self.seen:
239
+ return
240
+ self.seen.add((source, str(kind), target))
241
+ self.out.append(Relation(source, kind, target, attributes, sources))
242
+ self.counts[rule] += 1
243
+
244
+ def depends(self) -> None:
245
+ for relation in self.relations:
246
+ if relation.kind not in (RelationKind.DEPENDS_ON, RelationKind.IMPORTS):
247
+ continue
248
+ source, target = self.entities.get(relation.source_id), self.entities.get(relation.target_id)
249
+ if source is None or target is None or target.kind is EntityKind.PACKAGE:
250
+ continue
251
+ from_part, to_part = self.part_of(source), self.part_of(target)
252
+ if from_part and to_part and from_part.id != to_part.id:
253
+ evidence = relation.sources[0] if relation.sources else Source("")
254
+ self.connect(
255
+ from_part.id,
256
+ RelationKind.DEPENDS_ON,
257
+ to_part.id,
258
+ "depends on",
259
+ {"evidence": "explicit", "source": _cite(evidence)},
260
+ relation.sources[:1],
261
+ )
262
+ for path in self.files:
263
+ if not path.endswith(".xcodeproj/project.pbxproj"):
264
+ continue
265
+ app = self.parts.get(f"part:{_folder(str(PurePosixPath(path).parent.parent)) or '.'}")
266
+ content = self.read(path)
267
+ if app is None or content is None:
268
+ continue
269
+ for number, line in enumerate(content.decode("utf-8", "replace").splitlines(), start=1):
270
+ relative = _pbxproj_package(line)
271
+ package_folder = _normal(app.folder, relative) if relative else None
272
+ package_part = self.parts.get(f"part:{package_folder}") if package_folder else None
273
+ if package_part is not None:
274
+ self.connect(
275
+ app.id,
276
+ RelationKind.DEPENDS_ON,
277
+ package_part.id,
278
+ "depends on",
279
+ {"evidence": "explicit", "source": f"{path}#L{number}"},
280
+ (Source(path, number, number),),
281
+ )
282
+
283
+ def part_of(self, entity: Entity) -> Part | None:
284
+ if entity.kind is EntityKind.PROJECT:
285
+ return self.parts.get(f"part:{_location(entity.id) or '.'}")
286
+ path = entity.sources[0].path if entity.sources else _location(entity.id)
287
+ return self.owner(path)
288
+
289
+ def contracts(self) -> None:
290
+ by_blob: dict[str, list[str]] = {}
291
+ for path, blob in self.files.items():
292
+ by_blob.setdefault(blob, []).append(path)
293
+ contracts = [part for part in self.parts.values() if part.kind == "contract"]
294
+ named = self.mentions({contract.file for contract in contracts})
295
+ for contract in contracts:
296
+ evidence: dict[str, tuple[str, int | None]] = {}
297
+ holder = self.owner(contract.file)
298
+ if holder is not None and holder.folder:
299
+ evidence.setdefault(holder.id, (contract.file, None))
300
+ for copy in by_blob.get(self.files.get(contract.file, ""), []):
301
+ if copy != contract.file and (part := self.owner(copy)) is not None:
302
+ evidence.setdefault(part.id, (copy, None))
303
+ for part_id, config, number in named.get(contract.file, []):
304
+ evidence.setdefault(part_id, (config, number))
305
+ for part_id, (path, line) in sorted(evidence.items()):
306
+ part = self.parts[part_id]
307
+ kind = RelationKind.IMPLEMENTS if part.kind == "service" else RelationKind.CALLS_VIA
308
+ self.connect(
309
+ part_id,
310
+ kind,
311
+ contract.id,
312
+ "implements" if part.kind == "service" else "calls via",
313
+ {"evidence": "explicit", "source": f"{path}#L{line}" if line else path},
314
+ (Source(path, line, line) if line else Source(path),),
315
+ )
316
+
317
+ def mentions(self, targets: set[str]) -> dict[str, list[tuple[str, str, int]]]:
318
+ """For each target file, the parts whose config files name it: (part id, config file, first line).
319
+
320
+ Each config file is read and split once, whatever the number of targets, and only a token ending in a
321
+ target's file name is resolved, so the cost grows with the repository's size, not with its contracts.
322
+ """
323
+ names = {PurePosixPath(target).name for target in targets}
324
+ found: dict[str, list[tuple[str, str, int]]] = {}
325
+ for path in sorted(self.files):
326
+ part = self.owner(path) if _config_file(PurePosixPath(path).name) else None
327
+ content = self.read(path) if part is not None else None
328
+ if part is None or content is None:
329
+ continue
330
+ folder = _folder(str(PurePosixPath(path).parent))
331
+ seen: set[str] = set()
332
+ for number, line in enumerate(content.decode("utf-8", "replace").splitlines(), start=1):
333
+ for mention in TOKEN_SPLIT.split(line): # one linear split; no backtracking pattern over long lines
334
+ if not mention or mention.rpartition("/")[2] not in names:
335
+ continue
336
+ resolved = _normal(folder, mention)
337
+ for target in (mention, resolved):
338
+ if target in targets and target not in seen: # set lookups: no work grows with the contracts
339
+ seen.add(target)
340
+ found.setdefault(target, []).append((part.id, path, number))
341
+ return found
342
+
343
+ def deployments(self) -> None:
344
+ for entity in self.entities.values():
345
+ if entity.kind is EntityKind.WORKER:
346
+ part = self.owner(_location(entity.id)) or self.parts.get(f"part:{_location(entity.id) or '.'}")
347
+ if part is not None:
348
+ self.connect(
349
+ part.id,
350
+ RelationKind.DEPLOYED_ON,
351
+ self.platform("cloudflare"),
352
+ "deployed on",
353
+ {"evidence": "explicit", "source": _cite(entity.sources[0])},
354
+ entity.sources[:1],
355
+ )
356
+ elif entity.kind is EntityKind.DEPLOYMENT and "folder" in entity.attributes:
357
+ key = str(entity.attributes.get("kind"))
358
+ part = self.owner(str(entity.attributes["folder"]))
359
+ if part is not None and key in PLATFORMS and part.kind not in ("infrastructure",):
360
+ self.connect(
361
+ part.id,
362
+ RelationKind.DEPLOYED_ON,
363
+ self.platform(key),
364
+ "deployed on",
365
+ {"evidence": "explicit", "source": _cite(entity.sources[0])},
366
+ entity.sources[:1],
367
+ )
368
+ elif entity.kind is EntityKind.RESOURCE:
369
+ kind = str(entity.attributes.get("type", ""))
370
+ module = self.parts.get(f"part:{_folder(str(entity.attributes.get('module', ''))) or '.'}")
371
+ if module is not None and module.kind != "infrastructure":
372
+ module = None # its folder's id went to a code part: no arrow on a guess
373
+ platform_key = next(
374
+ (value for prefix, value in RESOURCE_PLATFORMS.items() if kind.startswith(prefix)), None
375
+ )
376
+ if module is not None and platform_key is not None:
377
+ self.connect(
378
+ module.id,
379
+ RelationKind.DEPLOYED_ON,
380
+ self.platform(platform_key),
381
+ "deployed on",
382
+ {"evidence": "explicit", "source": _cite(entity.sources[0])},
383
+ entity.sources[:1],
384
+ )
385
+ for path in entity.attributes.get("paths", []) or []:
386
+ part = self.owner(str(path))
387
+ if module is not None and part is not None and part.kind not in ("infrastructure",):
388
+ self.connect(
389
+ part.id,
390
+ RelationKind.DEPLOYED_ON,
391
+ module.id,
392
+ "deployed on",
393
+ {"evidence": "explicit", "source": _cite(entity.sources[0])},
394
+ entity.sources[:1],
395
+ )
396
+ self.matched()
397
+
398
+ def matched(self) -> None:
399
+ """Runtime services named after a part's folder: a dashed arrow, with the rule and both names."""
400
+ candidates: list[tuple[str, str, Source]] = [] # (service name, platform key, source)
401
+ for entity in self.entities.values():
402
+ kind = str(entity.attributes.get("type", ""))
403
+ if entity.kind is EntityKind.RESOURCE and kind in NAMED_SERVICES:
404
+ key = next(value for prefix, value in RESOURCE_PLATFORMS.items() if kind.startswith(prefix))
405
+ candidates.append((str(entity.attributes.get("name", "")), key, entity.sources[0]))
406
+ elif entity.kind is EntityKind.DEPLOYMENT and entity.attributes.get("target"):
407
+ key = str(entity.attributes.get("kind"))
408
+ if key in PLATFORMS:
409
+ candidates.append((str(entity.attributes["target"]), key, entity.sources[0]))
410
+ by_word: dict[str, dict[str, tuple[str, Source]]] = {} # word -> platform -> its first name and source
411
+ for name, key, source in candidates: # each name split once, then each part is one lookup per platform
412
+ for word in set(WORD.findall(name.lower())):
413
+ by_word.setdefault(word, {}).setdefault(key, (name, source))
414
+ for part in [part for part in self.parts.values() if part.kind in ("service", "app") and part.folder]:
415
+ for key, (name, source) in by_word.get(PurePosixPath(part.folder).name.lower(), {}).items():
416
+ platform = self.platform(key)
417
+ if (part.id, str(RelationKind.DEPLOYED_ON), platform) in self.seen:
418
+ continue
419
+ self.connect(
420
+ part.id,
421
+ RelationKind.DEPLOYED_ON,
422
+ platform,
423
+ "matched by name",
424
+ {"evidence": "matched", "rule": "name", "names": [name, part.folder], "source": _cite(source)},
425
+ (source,),
426
+ )
427
+
428
+ def facts(self) -> SystemFacts:
429
+ entities = [
430
+ Entity(
431
+ part.id,
432
+ EntityKind.PART,
433
+ {"kind": part.kind, "name": part.name, "folder": part.folder},
434
+ (part.source,) if part.source.path else (),
435
+ )
436
+ for part in sorted(self.parts.values(), key=lambda part: part.id)
437
+ ]
438
+ return SystemFacts(entities, sorted(self.out, key=lambda relation: relation.key), self.warnings, self.counts)
439
+
440
+
441
+ def _config_file(name: str) -> bool:
442
+ """A file that may name a contract: a known build file, any TOML, or an OpenAPI generator's configuration."""
443
+ if name in CONFIG_NAMES or name.endswith(".toml"):
444
+ return True
445
+ head, found, tail = name.rpartition("generator")
446
+ if not found or not (match := GENERATOR_TAIL.fullmatch(tail)):
447
+ return False
448
+ return match.group().endswith((".yml", ".yaml")) or head.endswith(".openapi-")
449
+
450
+
451
+ def _pbxproj_package(line: str) -> str | None:
452
+ """The package path in an Xcode `relativePath = <path>;` line, read without a pattern that could backtrack."""
453
+ key, equals, value = line.strip().partition("=")
454
+ value = value.strip()
455
+ if not equals or key.strip() != "relativePath" or not value.endswith(";"):
456
+ return None
457
+ value = value[:-1].strip()
458
+ if len(value) >= 2 and value[0] == value[-1] == '"':
459
+ value = value[1:-1]
460
+ return value if value and '"' not in value and ";" not in value else None
461
+
462
+
463
+ def _folder(folder: str) -> str:
464
+ return "" if folder in (".", "") else folder
465
+
466
+
467
+ def _cite(source: Source) -> str:
468
+ return f"{source.path}#L{source.start_line}" if source.start_line else source.path
469
+
470
+
471
+ def derive(
472
+ entities: Iterable[Entity],
473
+ relations: Iterable[Relation],
474
+ files: Mapping[str, str],
475
+ read: Callable[[str], bytes | None],
476
+ ) -> SystemFacts:
477
+ """Parts and connections from the extracted facts; `files` maps each allowed path to its git blob."""
478
+ system = _System(entities, relations, files, read)
479
+ for step in (system.find_parts, system.depends, system.contracts, system.deployments):
480
+ try:
481
+ step()
482
+ except Exception as error: # the pass never fails an update
483
+ system.warnings.append(f"system: a rule was skipped ({type(error).__name__})")
484
+ return system.facts()