codetrail-cli 1.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Diagrams drawn from facts only, as D2 text (design section 4.4, ADR 0016).
|
|
2
|
+
|
|
3
|
+
Every node and edge is a fact, so a diagram can't show a connection the code doesn't have. Node ids are generated and
|
|
4
|
+
labels are written as D2 double-quoted strings, so a name from the target can't inject D2's syntax. D2 keys are only
|
|
5
|
+
ever generated ids: D2's dagre layout writes them into JavaScript it evaluates, escaping too little for a name. Above
|
|
6
|
+
the node limit, modules roll up to their folders, one level at a time, with the number of imports on each edge.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import PurePosixPath
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
from codetrail.facts import Entity, EntityKind, RelationKind
|
|
19
|
+
from codetrail.facts.store import FactStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def d2_label(text: str, fallback: str = "?") -> str:
|
|
23
|
+
"""A label D2 reads as plain text inside double quotes: only a quote, a backslash or a `${}` substitution could
|
|
24
|
+
end or change it, so each is escaped, and a line break, which would end the string, is dropped. An empty label
|
|
25
|
+
becomes the fallback."""
|
|
26
|
+
return _flattened(text, fallback).replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _flattened(text: str, fallback: str) -> str:
|
|
30
|
+
"""One line of printable text; an empty one becomes the fallback."""
|
|
31
|
+
return " ".join(_printable(text).split()) or " ".join(_printable(fallback).split()) or "?"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _printable(text: str) -> str:
|
|
35
|
+
"""Without control, format and surrogate characters or noncharacters (U+FDD0-U+FDEF, U+xFFFE and U+xFFFF): none is
|
|
36
|
+
text a reader can see, and a line break would end D2's quoted string."""
|
|
37
|
+
return "".join(
|
|
38
|
+
character
|
|
39
|
+
for character in text
|
|
40
|
+
if unicodedata.category(character) not in ("Cc", "Cf", "Cs")
|
|
41
|
+
and ord(character) & 0xFFFE != 0xFFFE
|
|
42
|
+
and not 0xFDD0 <= ord(character) <= 0xFDEF
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _node(node_id: str, label: str, key: str) -> str:
|
|
47
|
+
return f'{node_id}: "{d2_label(label, fallback=key)}"'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _edge(source: str, target: str, label: str) -> str:
|
|
51
|
+
"""An arrow, labelled only when there's a label."""
|
|
52
|
+
text = " ".join(label.split())
|
|
53
|
+
return f'{source} -> {target}: "{d2_label(text)}"' if text else f"{source} -> {target}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class DiagramNode:
|
|
58
|
+
id: str
|
|
59
|
+
label: str
|
|
60
|
+
link: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class DiagramArrow:
|
|
65
|
+
"""One connection in the system diagram and why it's there: its source file and line, or the rule's two names."""
|
|
66
|
+
|
|
67
|
+
source: str
|
|
68
|
+
kind: str
|
|
69
|
+
target: str
|
|
70
|
+
evidence: str
|
|
71
|
+
link: str | None
|
|
72
|
+
names: tuple[str, ...] = ()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Diagram:
|
|
77
|
+
d2: str
|
|
78
|
+
nodes: list[DiagramNode] = field(default_factory=list)
|
|
79
|
+
rolled_up: bool = False
|
|
80
|
+
arrows: list[DiagramArrow] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def imports_diagram(store: FactStore, scope: str, max_nodes: int) -> Diagram:
|
|
84
|
+
"""The imports between modules under the folder `scope`, rolled up to folders above `max_nodes`."""
|
|
85
|
+
prefix = scope.rstrip("/") + "/"
|
|
86
|
+
modules = {
|
|
87
|
+
entity.id: entity
|
|
88
|
+
for kind in (EntityKind.MODULE, EntityKind.SWIFT_TARGET)
|
|
89
|
+
for entity in store.entities(kind)
|
|
90
|
+
if _location(entity.id).startswith(prefix)
|
|
91
|
+
}
|
|
92
|
+
edges = [
|
|
93
|
+
(relation.source_id, relation.target_id)
|
|
94
|
+
for kind in (RelationKind.IMPORTS, RelationKind.DEPENDS_ON)
|
|
95
|
+
for relation in store.relations(kind)
|
|
96
|
+
if relation.source_id in modules and relation.target_id in modules
|
|
97
|
+
]
|
|
98
|
+
if len(modules) <= max_nodes:
|
|
99
|
+
group = {module_id: module_id for module_id in modules}
|
|
100
|
+
labels = {module_id: str(entity.attributes.get("name", module_id)) for module_id, entity in modules.items()}
|
|
101
|
+
links = {module_id: f"/facts/{quote(module_id, safe='')}" for module_id in modules}
|
|
102
|
+
return _render(group, labels, links, Counter(edges), rolled_up=False, counted=False)
|
|
103
|
+
paths = {module_id: PurePosixPath(_location(module_id)).parent for module_id in modules}
|
|
104
|
+
depth = max(len(path.parts) for path in paths.values())
|
|
105
|
+
while depth > 1 and len({_truncate(path, depth) for path in paths.values()}) > max_nodes:
|
|
106
|
+
depth -= 1
|
|
107
|
+
group = {module_id: str(_truncate(path, depth)) for module_id, path in paths.items()}
|
|
108
|
+
labels = {folder: f"{folder}/" for folder in group.values()}
|
|
109
|
+
links = {folder: f"/areas/{quote(folder)}" for folder in group.values()}
|
|
110
|
+
folder_edges = Counter((group[source], group[target]) for source, target in edges if group[source] != group[target])
|
|
111
|
+
return _render(group, labels, links, folder_edges, rolled_up=True, counted=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def dependencies_diagram(store: FactStore, project_id: str) -> Diagram:
|
|
115
|
+
"""A project and the packages it depends on, each edge labelled with its dependency group."""
|
|
116
|
+
project = store.entity(project_id)
|
|
117
|
+
if project is None:
|
|
118
|
+
return Diagram("direction: right\n")
|
|
119
|
+
relations = [r for r in store.relations(RelationKind.DEPENDS_ON) if r.source_id == project_id]
|
|
120
|
+
labels = {project_id: str(project.attributes.get("name", project_id))}
|
|
121
|
+
for relation in relations:
|
|
122
|
+
labels[relation.target_id] = _package_name(relation.target_id)
|
|
123
|
+
ids = {key: f"n{index}" for index, key in enumerate(sorted(labels), start=1)}
|
|
124
|
+
lines = ["direction: right"]
|
|
125
|
+
lines += [_node(ids[key], label, key) for key, label in sorted(labels.items())]
|
|
126
|
+
for relation in relations:
|
|
127
|
+
lines.append(_edge(ids[project_id], ids[relation.target_id], str(relation.attributes.get("group") or "")))
|
|
128
|
+
nodes = [DiagramNode(ids[key], label, f"/facts/{quote(key, safe='')}") for key, label in sorted(labels.items())]
|
|
129
|
+
return Diagram("\n".join(lines) + "\n", nodes)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def resources_diagram(store: FactStore, scope: str, max_nodes: int) -> Diagram:
|
|
133
|
+
"""Terraform modules under the folder `scope`, the modules they call and, when they fit, their resources."""
|
|
134
|
+
prefix = scope.rstrip("/") + "/"
|
|
135
|
+
modules = {
|
|
136
|
+
entity.id: entity for entity in store.entities(EntityKind.TERRAFORM_MODULE)
|
|
137
|
+
if (_location(entity.id) + "/").startswith(prefix) or prefix.startswith(_location(entity.id) + "/")
|
|
138
|
+
} # fmt: skip
|
|
139
|
+
resources = {
|
|
140
|
+
entity.id: entity for entity in store.entities(EntityKind.RESOURCE)
|
|
141
|
+
if f"terraform_module:{entity.attributes.get('module')}" in modules
|
|
142
|
+
} # fmt: skip
|
|
143
|
+
nodes = dict(modules) if len(modules) + len(resources) > max_nodes else {**modules, **resources}
|
|
144
|
+
labels = {key: (f"{_location(key)}/" if key in modules else _location(key).rsplit("/", 1)[-1]) for key in nodes}
|
|
145
|
+
edges: Counter[tuple[str, str]] = Counter()
|
|
146
|
+
for relation in store.relations(RelationKind.REFERENCES) + store.relations(RelationKind.CONTAINS):
|
|
147
|
+
if relation.source_id in nodes and relation.target_id in nodes:
|
|
148
|
+
edges[(relation.source_id, relation.target_id)] += 1
|
|
149
|
+
return _render({key: key for key in nodes}, labels, {key: f"/facts/{quote(key, safe='')}" for key in nodes}, edges,
|
|
150
|
+
rolled_up=len(nodes) < len(modules) + len(resources), counted=False) # fmt: skip
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Each kind of part has its own shape: D2's syntax, after the quoted label.
|
|
154
|
+
SHAPES = {
|
|
155
|
+
"service": " {style.border-radius: 8}",
|
|
156
|
+
"app": " {style.border-radius: 8}",
|
|
157
|
+
"library": "",
|
|
158
|
+
"contract": " {shape: page}",
|
|
159
|
+
"infrastructure": " {style.double-border: true}",
|
|
160
|
+
"platform": " {style.border-radius: 999}", # a stadium
|
|
161
|
+
}
|
|
162
|
+
SYSTEM_RELATIONS = (RelationKind.DEPENDS_ON, RelationKind.IMPLEMENTS, RelationKind.CALLS_VIA, RelationKind.DEPLOYED_ON)
|
|
163
|
+
ARROW_LABELS = {RelationKind.IMPLEMENTS: "implements", RelationKind.CALLS_VIA: "calls via",
|
|
164
|
+
RelationKind.DEPLOYED_ON: "deployed on"} # fmt: skip
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def system_diagram(store: FactStore, focus: str | None, max_nodes: int) -> Diagram:
|
|
168
|
+
"""How the repository's parts connect (design 17.4), or, with `focus`, the parts in a folder and their neighbours.
|
|
169
|
+
|
|
170
|
+
A client that calls through a contract that one service implements gets an arrow to that service, labelled
|
|
171
|
+
with the contract. Matched connections are dashed. Above `max_nodes`, libraries roll up into their parent
|
|
172
|
+
folders, then every part but the platforms into its top-level folder, then the smallest groups into one.
|
|
173
|
+
"""
|
|
174
|
+
parts = {entity.id: entity for entity in store.entities(EntityKind.PART)}
|
|
175
|
+
relations = [
|
|
176
|
+
relation for kind in SYSTEM_RELATIONS for relation in store.relations(kind)
|
|
177
|
+
if relation.source_id in parts and relation.target_id in parts
|
|
178
|
+
] # fmt: skip
|
|
179
|
+
implementers: dict[str, list[str]] = {}
|
|
180
|
+
for relation in relations:
|
|
181
|
+
if relation.kind is RelationKind.IMPLEMENTS:
|
|
182
|
+
implementers.setdefault(relation.target_id, []).append(relation.source_id)
|
|
183
|
+
edges: dict[tuple[str, str], tuple[str, bool]] = {} # (source, target) -> (label, matched)
|
|
184
|
+
for relation in relations:
|
|
185
|
+
matched = relation.attributes.get("evidence") == "matched"
|
|
186
|
+
if relation.kind is RelationKind.CALLS_VIA and len(implementers.get(relation.target_id, [])) == 1:
|
|
187
|
+
# One service behind the contract: the call is drawn to it. With several, it stays on the contract, so
|
|
188
|
+
# arrows never grow as callers times services.
|
|
189
|
+
contract = str(parts[relation.target_id].attributes.get("name", ""))
|
|
190
|
+
edges.setdefault((relation.source_id, implementers[relation.target_id][0]), (contract, matched))
|
|
191
|
+
continue
|
|
192
|
+
label = "matched by name" if matched else ARROW_LABELS.get(relation.kind, "")
|
|
193
|
+
edges.setdefault((relation.source_id, relation.target_id), (label, matched))
|
|
194
|
+
shown = set(parts)
|
|
195
|
+
if focus is not None:
|
|
196
|
+
inside = {key for key, entity in parts.items() if _within(_part_folder(entity), focus.strip("/"))
|
|
197
|
+
and entity.attributes.get("kind") != "platform"} # fmt: skip
|
|
198
|
+
pairs = [*edges, *((relation.source_id, relation.target_id) for relation in relations)]
|
|
199
|
+
shown = inside | {other for pair in pairs if set(pair) & inside for other in pair}
|
|
200
|
+
edges = {pair: value for pair, value in edges.items() if set(pair) <= shown}
|
|
201
|
+
relations = [relation for relation in relations if {relation.source_id, relation.target_id} <= shown]
|
|
202
|
+
group = {key: key for key in shown}
|
|
203
|
+
labels = {key: str(parts[key].attributes.get("name") or "") for key in shown}
|
|
204
|
+
kinds = {key: str(parts[key].attributes.get("kind", "library")) for key in shown}
|
|
205
|
+
links = {key: f"/facts/{quote(key, safe='')}" for key in shown}
|
|
206
|
+
rolled_up = False
|
|
207
|
+
for level in ("libraries", "parts"):
|
|
208
|
+
if len(set(group.values())) <= max_nodes:
|
|
209
|
+
break
|
|
210
|
+
rolled_up = True
|
|
211
|
+
members: dict[str, list[str]] = {}
|
|
212
|
+
for key in shown:
|
|
213
|
+
if level == "libraries" and kinds[key] == "library":
|
|
214
|
+
folder = str(PurePosixPath(_part_folder(parts[key])).parent)
|
|
215
|
+
elif level == "parts" and kinds[key] != "platform":
|
|
216
|
+
folder = _part_folder(parts[key]).split("/", 1)[0] or "."
|
|
217
|
+
else:
|
|
218
|
+
continue
|
|
219
|
+
members.setdefault(f"group:{folder}", []).append(key)
|
|
220
|
+
for folder_key, keys in members.items():
|
|
221
|
+
folder = folder_key.removeprefix("group:")
|
|
222
|
+
noun = "libraries" if level == "libraries" else "parts"
|
|
223
|
+
labels[folder_key] = f"{folder}/ ({len(keys)} {noun})"
|
|
224
|
+
kinds[folder_key] = "library"
|
|
225
|
+
links[folder_key] = f"/areas/{quote(folder)}"
|
|
226
|
+
for key in keys:
|
|
227
|
+
group[key] = folder_key
|
|
228
|
+
sizes = Counter(group.values())
|
|
229
|
+
if len(sizes) > max_nodes: # still too many: the largest groups stay, the rest fold into one node
|
|
230
|
+
kept = {key for key, _ in sizes.most_common(max_nodes - 1)}
|
|
231
|
+
folded = [key for key in shown if group[key] not in kept]
|
|
232
|
+
labels["group:other"] = f"other parts ({len(folded)})"
|
|
233
|
+
kinds["group:other"] = "library"
|
|
234
|
+
links["group:other"] = "/system"
|
|
235
|
+
for key in folded:
|
|
236
|
+
group[key] = "group:other"
|
|
237
|
+
counted: Counter[tuple[str, str]] = Counter()
|
|
238
|
+
merged: dict[tuple[str, str], tuple[str, bool]] = {}
|
|
239
|
+
for (source, target), (label, matched) in edges.items():
|
|
240
|
+
pair = (group[source], group[target])
|
|
241
|
+
if pair[0] == pair[1]:
|
|
242
|
+
continue
|
|
243
|
+
counted[pair] += 1
|
|
244
|
+
before = merged.get(pair)
|
|
245
|
+
if before is not None: # merged arrows keep a label they share, and are dashed only if all were matched
|
|
246
|
+
label, matched = (label if before[0] == label else ""), before[1] and matched
|
|
247
|
+
merged[pair] = (label, matched)
|
|
248
|
+
keys = sorted(set(group.values()))
|
|
249
|
+
ids = {key: f"n{index}" for index, key in enumerate(keys, start=1)}
|
|
250
|
+
lines = ["direction: right"]
|
|
251
|
+
for key in keys:
|
|
252
|
+
lines.append(_node(ids[key], labels[key], key) + SHAPES.get(kinds[key], ""))
|
|
253
|
+
for (source, target), (label, matched) in sorted(merged.items()):
|
|
254
|
+
text = str(counted[(source, target)]) if counted[(source, target)] > 1 else label
|
|
255
|
+
arrow = _edge(ids[source], ids[target], text)
|
|
256
|
+
if matched: # dashed
|
|
257
|
+
arrow += " {style.stroke-dash: 3}" if text.strip() else ": {style.stroke-dash: 3}"
|
|
258
|
+
lines.append(arrow)
|
|
259
|
+
nodes = [DiagramNode(ids[key], labels[key] or key, links[key]) for key in keys]
|
|
260
|
+
arrows = [
|
|
261
|
+
DiagramArrow(
|
|
262
|
+
labels[relation.source_id] or relation.source_id, str(relation.kind),
|
|
263
|
+
labels[relation.target_id] or relation.target_id, str(relation.attributes.get("evidence", "explicit")),
|
|
264
|
+
_source_link(str(relation.attributes.get("source", ""))),
|
|
265
|
+
tuple(str(name) for name in relation.attributes.get("names") or ()),
|
|
266
|
+
)
|
|
267
|
+
for relation in relations
|
|
268
|
+
] # fmt: skip
|
|
269
|
+
return Diagram("\n".join(lines) + "\n", nodes, rolled_up, arrows)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
PART_KINDS = ("service", "app", "library", "contract", "infrastructure", "platform")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True)
|
|
276
|
+
class SystemPart:
|
|
277
|
+
"""A part as the system page lists it, with the names of the parts it connects to."""
|
|
278
|
+
|
|
279
|
+
id: str
|
|
280
|
+
name: str
|
|
281
|
+
folder: str
|
|
282
|
+
depends_on: list[str]
|
|
283
|
+
called_by: list[str]
|
|
284
|
+
runs_on: list[str]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def system_parts(store: FactStore) -> list[tuple[str, list[SystemPart]]]:
|
|
288
|
+
"""Every part, grouped by kind in PART_KINDS order: what it depends on, what calls it and where it runs.
|
|
289
|
+
|
|
290
|
+
A part that calls through a contract counts as calling the contract, and the service behind it when there's one.
|
|
291
|
+
"""
|
|
292
|
+
parts = {entity.id: entity for entity in store.entities(EntityKind.PART)}
|
|
293
|
+
names = {key: str(entity.attributes.get("name") or key) for key, entity in parts.items()}
|
|
294
|
+
relations = [
|
|
295
|
+
relation for kind in SYSTEM_RELATIONS for relation in store.relations(kind)
|
|
296
|
+
if relation.source_id in parts and relation.target_id in parts
|
|
297
|
+
] # fmt: skip
|
|
298
|
+
implementers: dict[str, set[str]] = {}
|
|
299
|
+
for relation in relations:
|
|
300
|
+
if relation.kind is RelationKind.IMPLEMENTS:
|
|
301
|
+
implementers.setdefault(relation.target_id, set()).add(relation.source_id)
|
|
302
|
+
depends_on: dict[str, set[str]] = {}
|
|
303
|
+
called_by: dict[str, set[str]] = {}
|
|
304
|
+
runs_on: dict[str, set[str]] = {}
|
|
305
|
+
for relation in relations:
|
|
306
|
+
source, target = relation.source_id, relation.target_id
|
|
307
|
+
if relation.kind is RelationKind.DEPENDS_ON:
|
|
308
|
+
depends_on.setdefault(source, set()).add(names[target])
|
|
309
|
+
elif relation.kind is RelationKind.CALLS_VIA: # a lone implementer is called too; several stay on the contract
|
|
310
|
+
services = implementers.get(target, set())
|
|
311
|
+
for called in {target, *services} if len(services) == 1 else {target}:
|
|
312
|
+
called_by.setdefault(called, set()).add(names[source])
|
|
313
|
+
elif relation.kind is RelationKind.DEPLOYED_ON:
|
|
314
|
+
runs_on.setdefault(source, set()).add(names[target])
|
|
315
|
+
grouped: dict[str, list[SystemPart]] = {}
|
|
316
|
+
for key, entity in sorted(parts.items(), key=lambda item: names[item[0]].lower()):
|
|
317
|
+
kind = str(entity.attributes.get("kind"))
|
|
318
|
+
grouped.setdefault(kind if kind in PART_KINDS else "library", []).append(
|
|
319
|
+
SystemPart(key, names[key], _part_folder(entity), sorted(depends_on.get(key, set())),
|
|
320
|
+
sorted(called_by.get(key, set())), sorted(runs_on.get(key, set())))
|
|
321
|
+
) # fmt: skip
|
|
322
|
+
return [(kind, grouped[kind]) for kind in PART_KINDS if kind in grouped]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _within(folder: str, scope: str) -> bool:
|
|
326
|
+
return folder == scope or folder.startswith(scope + "/")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _part_folder(entity: Entity) -> str:
|
|
330
|
+
return str(entity.attributes.get("folder") or "")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _source_link(cited: str) -> str | None:
|
|
334
|
+
path, _, line = cited.partition("#L")
|
|
335
|
+
if not path or not LISTABLE.fullmatch(path):
|
|
336
|
+
return None
|
|
337
|
+
return f"/source/{path}#L{line}" if line.isdigit() else f"/source/{path}"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
MAX_AVAILABLE = 15 # of each kind, so the list stays a short part of a question's prompt
|
|
341
|
+
# A folder or project id listed for an answer: plain path characters only, so no repository name can carry
|
|
342
|
+
# whitespace, braces or instructions into the prompt (a name with spaces couldn't be drawn anyway).
|
|
343
|
+
LISTABLE = re.compile(r"[A-Za-z0-9._@+/:-]+")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def available_diagrams(store: FactStore) -> list[str]:
|
|
347
|
+
"""The diagram placeholders that would draw something from these facts, for an answer to use (design 7.3).
|
|
348
|
+
|
|
349
|
+
The system first, when there are parts; imports for every top-level folder, project folder and folder of
|
|
350
|
+
projects that holds at least two modules or Swift targets; dependencies for every project that has any;
|
|
351
|
+
resources for every top-level Terraform folder.
|
|
352
|
+
"""
|
|
353
|
+
located = [_location(entity.id) for kind in (EntityKind.MODULE, EntityKind.SWIFT_TARGET)
|
|
354
|
+
for entity in store.entities(kind)] # fmt: skip
|
|
355
|
+
projects = [entity.id for entity in store.entities(EntityKind.PROJECT)]
|
|
356
|
+
folders = {path.split("/", 1)[0] for path in located if "/" in path}
|
|
357
|
+
for project in projects:
|
|
358
|
+
folder = _location(project)
|
|
359
|
+
if folder not in ("", "."):
|
|
360
|
+
folders |= {folder, str(PurePosixPath(folder).parent)}
|
|
361
|
+
scopes = sorted(
|
|
362
|
+
folder
|
|
363
|
+
for folder in folders
|
|
364
|
+
if folder not in ("", ".") and sum(p.startswith(folder + "/") for p in located) >= 2
|
|
365
|
+
)
|
|
366
|
+
depending = {relation.source_id for relation in store.relations(RelationKind.DEPENDS_ON)}
|
|
367
|
+
terraform = sorted(
|
|
368
|
+
{_location(entity.id).split("/", 1)[0] for entity in store.entities(EntityKind.TERRAFORM_MODULE)}
|
|
369
|
+
)
|
|
370
|
+
scopes = [scope for scope in scopes if LISTABLE.fullmatch(scope)]
|
|
371
|
+
depending = {project for project in depending if LISTABLE.fullmatch(project)}
|
|
372
|
+
terraform = [scope for scope in terraform if LISTABLE.fullmatch(scope)]
|
|
373
|
+
system = ["{{diagram system}}"] if store.entities(EntityKind.PART) else []
|
|
374
|
+
return (
|
|
375
|
+
system
|
|
376
|
+
+ [f"{{{{diagram imports scope={scope}}}}}" for scope in scopes[:MAX_AVAILABLE]]
|
|
377
|
+
+ [f"{{{{diagram dependencies project={project}}}}}" for project in sorted(depending & set(projects))][
|
|
378
|
+
:MAX_AVAILABLE
|
|
379
|
+
]
|
|
380
|
+
+ [f"{{{{diagram resources scope={scope}}}}}" for scope in terraform[:MAX_AVAILABLE]]
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _package_name(package_id: str) -> str:
|
|
385
|
+
"""package:<ecosystem>/<name> -> <name>, for every ecosystem (pypi, swift, ...)."""
|
|
386
|
+
return package_id.split(":", 1)[-1].split("/", 1)[-1]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _location(fact_id: str) -> str:
|
|
390
|
+
"""A path-based fact id's path: module:<path>, swift_target:<folder>/<name>, terraform_module:<folder>..."""
|
|
391
|
+
return fact_id.split(":", 1)[1]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _truncate(path: PurePosixPath, depth: int) -> PurePosixPath:
|
|
395
|
+
return PurePosixPath(*path.parts[:depth]) if path.parts else path
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _render(
|
|
399
|
+
group: dict[str, str],
|
|
400
|
+
labels: dict[str, str],
|
|
401
|
+
links: dict[str, str],
|
|
402
|
+
edges: Counter[tuple[str, str]],
|
|
403
|
+
*,
|
|
404
|
+
rolled_up: bool,
|
|
405
|
+
counted: bool,
|
|
406
|
+
) -> Diagram:
|
|
407
|
+
keys = sorted(set(group.values()))
|
|
408
|
+
ids = {key: f"n{index}" for index, key in enumerate(keys, start=1)}
|
|
409
|
+
lines = ["direction: right"]
|
|
410
|
+
lines += [_node(ids[key], labels[key], key) for key in keys]
|
|
411
|
+
for (source, target), count in sorted(edges.items()):
|
|
412
|
+
lines.append(_edge(ids[source], ids[target], str(count) if counted else ""))
|
|
413
|
+
nodes = [DiagramNode(ids[key], labels[key], links[key]) for key in keys]
|
|
414
|
+
return Diagram("\n".join(lines) + "\n", nodes, rolled_up)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The Documentation page's tiles, its sections' helpers, and the report kept in memory between updates.
|
|
2
|
+
|
|
3
|
+
Design sections 18.4 and 21.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import date
|
|
13
|
+
|
|
14
|
+
from codetrail.facts import Entity
|
|
15
|
+
from codetrail.metrics.decisions import written_on
|
|
16
|
+
from codetrail.metrics.report import Metric, Value
|
|
17
|
+
from codetrail.repo.history import Commit
|
|
18
|
+
from codetrail.web.trend import trend_points
|
|
19
|
+
|
|
20
|
+
COUNTS = {Metric.ADR_ATTENTION, Metric.COMMITS, Metric.CODE_LINES, Metric.SOURCE_FILES}
|
|
21
|
+
# The sections a share can open, in their order on the page (design section 21.1).
|
|
22
|
+
SECTIONS = (
|
|
23
|
+
("rationale", Metric.DOCUMENTED_SHARE),
|
|
24
|
+
("commits", Metric.COMMIT_WHY_SHARE),
|
|
25
|
+
("coverage", Metric.MENTIONED_SHARE),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def section_to_open(values: Mapping[Metric, Value]) -> str | None:
|
|
30
|
+
"""The decisions when an ADR needs attention; else the lowest measured share below 1, the first on a tie."""
|
|
31
|
+
attention = values.get(Metric.ADR_ATTENTION)
|
|
32
|
+
if attention is not None and attention.numerator:
|
|
33
|
+
return "decisions"
|
|
34
|
+
shares = []
|
|
35
|
+
for order, (section, metric) in enumerate(SECTIONS):
|
|
36
|
+
value = values.get(metric)
|
|
37
|
+
if value is not None and value.share is not None and value.share < 1:
|
|
38
|
+
shares.append((value.share, order, section))
|
|
39
|
+
return min(shares)[2] if shares else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def commit_kind(commit: Commit) -> str:
|
|
43
|
+
"""A commit without a why: it has a body that doesn't say why, or only a subject."""
|
|
44
|
+
return "body-only" if commit.body else "subject-only"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Filter:
|
|
49
|
+
kinds: list[str] # each item's kind, in the list's order
|
|
50
|
+
totals: dict[str, int] # items per kind, "all" first
|
|
51
|
+
folded: dict[str, int] # items past the first `listed`, per kind, "all" first
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def filtered(kinds: Sequence[str], listed: int) -> Filter:
|
|
55
|
+
"""What a list's filter chips need: each item's kind, and the items of each kind in all and in the folded rest."""
|
|
56
|
+
rest = Counter(kinds[listed:])
|
|
57
|
+
totals = {"all": len(kinds), **Counter(kinds)}
|
|
58
|
+
folded = {kind: len(kinds[listed:]) if kind == "all" else rest.get(kind, 0) for kind in totals}
|
|
59
|
+
return Filter(list(kinds), totals, folded)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def segments(parts: Sequence[tuple[str, int, str]], width: int = 1000) -> list[tuple[str, float, float]]:
|
|
63
|
+
"""A stacked bar's parts as tone, x and width; a part with no tone takes its room and isn't drawn."""
|
|
64
|
+
total = sum(count for _, count, _ in parts)
|
|
65
|
+
drawn, x = [], 0.0
|
|
66
|
+
for tone, count, _ in parts:
|
|
67
|
+
if count <= 0 or not total:
|
|
68
|
+
continue
|
|
69
|
+
span = count / total * width
|
|
70
|
+
if tone:
|
|
71
|
+
drawn.append((tone, round(x, 1), round(span, 1)))
|
|
72
|
+
x += span
|
|
73
|
+
return drawn
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def days_proposed(decision: Entity, today: date) -> int | None:
|
|
77
|
+
written = written_on(decision)
|
|
78
|
+
return (today - written).days if written is not None else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Tile:
|
|
83
|
+
metric: Metric
|
|
84
|
+
value: Value | None # None when it couldn't be measured
|
|
85
|
+
percent: int | None # a share's rounded percent
|
|
86
|
+
change: int | None # since the previous update: percentage points for a share, the difference for a count
|
|
87
|
+
trend_text: str # each recorded value, oldest first, as the page writes it
|
|
88
|
+
points: str # the trend line's SVG points
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def percent_of(value: Value) -> int | None:
|
|
92
|
+
"""A share's percent, rounded, but 100 only when it is all and 0 only when it is none."""
|
|
93
|
+
if value.share is None:
|
|
94
|
+
return None
|
|
95
|
+
percent = round(value.share * 100)
|
|
96
|
+
if value.numerator < (value.denominator or 0):
|
|
97
|
+
percent = min(percent, 99)
|
|
98
|
+
return max(percent, 1) if value.numerator > 0 else percent
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def age(first: date, today: date) -> tuple[int, int]:
|
|
102
|
+
"""Whole months from `first` to `today`, as years and months; a day of the month not yet reached doesn't count."""
|
|
103
|
+
months = (today.year - first.year) * 12 + today.month - first.month - (1 if today.day < first.day else 0)
|
|
104
|
+
return divmod(max(months, 0), 12)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _number(metric: Metric, value: Value) -> float | None:
|
|
108
|
+
if metric in COUNTS:
|
|
109
|
+
return value.numerator
|
|
110
|
+
return None if value.share is None else value.share * 100
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_tiles(
|
|
114
|
+
values: Mapping[Metric, Value], before: Mapping[Metric, Value], history: Mapping[Metric, Sequence[Value]]
|
|
115
|
+
) -> dict[Metric, Tile]:
|
|
116
|
+
tiles = {}
|
|
117
|
+
for metric in Metric:
|
|
118
|
+
value, earlier = values.get(metric), before.get(metric)
|
|
119
|
+
now = _number(metric, value) if value else None
|
|
120
|
+
then = _number(metric, earlier) if earlier else None
|
|
121
|
+
change = round(now - then) if now is not None and then is not None else None
|
|
122
|
+
past = [recorded for recorded in history.get(metric, []) if _number(metric, recorded) is not None]
|
|
123
|
+
trend = [number for number in (_number(metric, recorded) for recorded in past) if number is not None]
|
|
124
|
+
if metric in COUNTS:
|
|
125
|
+
text = ", ".join(str(recorded.numerator) for recorded in past)
|
|
126
|
+
else:
|
|
127
|
+
text = ", ".join(f"{percent_of(recorded)}%" for recorded in past)
|
|
128
|
+
percent = None if value is None or metric in COUNTS else percent_of(value)
|
|
129
|
+
tiles[metric] = Tile(metric, value, percent, change, text, trend_points(trend))
|
|
130
|
+
return tiles
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ReportCache[T]:
|
|
134
|
+
"""One report, rebuilt only when its key changes: the latest snapshot, the guide's state and the date."""
|
|
135
|
+
|
|
136
|
+
def __init__(self) -> None:
|
|
137
|
+
self._lock = threading.Lock()
|
|
138
|
+
self._key: object = None
|
|
139
|
+
self._report: T | None = None
|
|
140
|
+
|
|
141
|
+
def get(self, key: object, build: Callable[[], T]) -> T:
|
|
142
|
+
with self._lock:
|
|
143
|
+
if self._report is None or self._key != key:
|
|
144
|
+
self._report = build()
|
|
145
|
+
self._key = key
|
|
146
|
+
return self._report
|
codetrail/web/i18n.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Interface languages: one gettext catalog per language, English first and as the fallback (design section 7.2).
|
|
2
|
+
|
|
3
|
+
A catalog names itself and its writing direction by translating two messages with a context: "English" in the
|
|
4
|
+
context "language name", and "ltr" in the context "text direction" (as `ltr` or `rtl`). The installed languages are
|
|
5
|
+
English plus every compiled catalog found.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import gettext
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
LANGUAGE_CODE = re.compile(r"[a-z]{2,3}(_[A-Z]{2})?")
|
|
17
|
+
DOMAIN = "codetrail"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Language:
|
|
22
|
+
code: str
|
|
23
|
+
name: str
|
|
24
|
+
direction: str
|
|
25
|
+
translations: gettext.NullTranslations
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_locales() -> Path:
|
|
29
|
+
return Path(str(files("codetrail").joinpath("locales")))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def installed_languages(locales: Path | None = None) -> dict[str, Language]:
|
|
33
|
+
languages = {"en": Language("en", "English", "ltr", gettext.NullTranslations())}
|
|
34
|
+
folder = locales or default_locales()
|
|
35
|
+
for catalog in sorted(folder.glob(f"*/LC_MESSAGES/{DOMAIN}.mo")):
|
|
36
|
+
code = catalog.parent.parent.name
|
|
37
|
+
if not LANGUAGE_CODE.fullmatch(code) or code == "en":
|
|
38
|
+
continue
|
|
39
|
+
with catalog.open("rb") as handle:
|
|
40
|
+
translations = gettext.GNUTranslations(handle)
|
|
41
|
+
direction = translations.pgettext("text direction", "ltr")
|
|
42
|
+
name = translations.pgettext("language name", "English")
|
|
43
|
+
languages[code] = Language(code, name, direction if direction in ("ltr", "rtl") else "ltr", translations)
|
|
44
|
+
return languages
|