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
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()
|