codetrail-cli 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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