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,127 @@
1
+ """What the page's shell shows around every page (design section 16.1): the sidebar, a page's neighbours in its path,
2
+ and where to continue reading. Built from the guide's pages and the reader's learning states; malformed front matter
3
+ never breaks it.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from collections.abc import Iterable, Mapping, Sequence
11
+ from dataclasses import dataclass, field
12
+
13
+ from codetrail.guide import Page
14
+
15
+ SIDEBAR_ANSWERS = 5
16
+ PINNABLE = ("path", "area", "concept", "answer") # the guide pages the sidebar lists, which can be pinned
17
+ WORD = re.compile(r"[^\W_]+")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Navigation:
22
+ paths: list[tuple[Page, int, int]] = field(default_factory=list) # path, steps learned, steps
23
+ areas: list[Page] = field(default_factory=list)
24
+ concepts: list[Page] = field(default_factory=list)
25
+ answers: list[Page] = field(default_factory=list) # the newest few
26
+ answer_count: int = 0
27
+ statuses: Mapping[str, str] = field(default_factory=dict) # page id -> learning state
28
+ unread_digests: bool = False
29
+ branch: str = ""
30
+ commit: str = ""
31
+ pins: list[Page] = field(default_factory=list) # pinned pages, in the order they were pinned
32
+
33
+
34
+ def initials(title: str) -> str:
35
+ """A pinned tile's letters: the first letters of the title's first two words, or one word's first two."""
36
+ words: list[str] = WORD.findall(title)
37
+ if not words:
38
+ return "·"
39
+ if len(words) == 1:
40
+ return words[0][:1].upper() + words[0][1:2]
41
+ return (words[0][:1] + words[1][:1]).upper()
42
+
43
+
44
+ def read_pins(value: str | None) -> list[str]:
45
+ """The pinned page ids as stored, a JSON list of strings; anything else reads as no pins."""
46
+ try:
47
+ loaded = json.loads(value) if value else []
48
+ except ValueError:
49
+ return []
50
+ return [item for item in loaded if isinstance(item, str)] if isinstance(loaded, list) else []
51
+
52
+
53
+ def steps_of(path: Page) -> list[str]:
54
+ steps = path.meta.get("steps")
55
+ return [str(step) for step in steps] if isinstance(steps, list) else []
56
+
57
+
58
+ def _by_title(pages: Iterable[Page]) -> list[Page]:
59
+ return sorted(pages, key=lambda page: (page.title.casefold(), page.id))
60
+
61
+
62
+ def answers_newest_first(pages: Iterable[Page]) -> list[Page]:
63
+ """Saved answers, newest first by when they were asked; one with no usable date sorts last."""
64
+
65
+ def asked(page: Page) -> str:
66
+ value = page.meta.get("asked_at")
67
+ return value if isinstance(value, str) else ""
68
+
69
+ listed = list(pages) # read once: callers may pass a generator
70
+ dated = sorted((page for page in listed if asked(page)), key=asked, reverse=True)
71
+ return dated + sorted((page for page in listed if not asked(page)), key=lambda page: page.id)
72
+
73
+
74
+ def build_navigation(
75
+ pages: Sequence[Page], statuses: Mapping[str, str], unread_digests: Sequence[str], branch: str = "",
76
+ commit: str = "", pinned: Sequence[str] = (),
77
+ ) -> Navigation: # fmt: skip
78
+ pinnable = {page.id: page for page in pages if page.kind in PINNABLE}
79
+ paths = [
80
+ (path, sum(1 for step in steps_of(path) if statuses.get(step) == "learned"), len(steps_of(path)))
81
+ for path in _by_title(page for page in pages if page.kind == "path")
82
+ ]
83
+ answers = answers_newest_first(page for page in pages if page.kind == "answer")
84
+ return Navigation(
85
+ paths=paths,
86
+ areas=_by_title(page for page in pages if page.kind == "area"),
87
+ concepts=_by_title(page for page in pages if page.kind == "concept"),
88
+ answers=answers[:SIDEBAR_ANSWERS],
89
+ answer_count=len(answers),
90
+ statuses=dict(statuses),
91
+ unread_digests=bool(unread_digests),
92
+ branch=branch,
93
+ commit=commit,
94
+ pins=[pinnable[page_id] for page_id in pinned if page_id in pinnable],
95
+ )
96
+
97
+
98
+ def path_neighbours(
99
+ pages: Sequence[Page], page_id: str, path_hint: str | None
100
+ ) -> tuple[Page | None, Page | None, Page | None]:
101
+ """The path a page is read in, and its previous and next steps there.
102
+
103
+ The path is the one the reader came from when it really holds the page, otherwise the first one that does (by
104
+ title); a page in no path has none.
105
+ """
106
+ by_id = {page.id: page for page in pages}
107
+ holding = [path for path in _by_title(page for page in pages if page.kind == "path") if page_id in steps_of(path)]
108
+ if not holding:
109
+ return None, None, None
110
+ path = next((candidate for candidate in holding if candidate.id == path_hint), holding[0])
111
+ steps = steps_of(path)
112
+ position = steps.index(page_id)
113
+ previous = by_id.get(steps[position - 1]) if position > 0 else None
114
+ following = by_id.get(steps[position + 1]) if position + 1 < len(steps) else None
115
+ return path, previous, following
116
+
117
+
118
+ def continue_reading(pages: Sequence[Page], statuses: Mapping[str, str]) -> tuple[Page, Page, int, int] | None:
119
+ """The first path (by title) with a step not yet learned: the path, that step, steps learned, and steps."""
120
+ by_id = {page.id: page for page in pages}
121
+ for path in _by_title(page for page in pages if page.kind == "path"):
122
+ steps = [step for step in steps_of(path) if step in by_id]
123
+ learned = sum(1 for step in steps if statuses.get(step) == "learned")
124
+ for step in steps:
125
+ if statuses.get(step) != "learned":
126
+ return path, by_id[step], learned, len(steps)
127
+ return None
@@ -0,0 +1,126 @@
1
+ """Rendering a guide page (design sections 6.4 and 7.4).
2
+
3
+ Markdown is rendered by markdown-it-py with raw HTML disabled and its link validation on (no `javascript:`,
4
+ `vbscript:`, `file:` or `data:` links). Rationale blocks become labelled boxes, `[[fact]]` links point at the fact's
5
+ view, and diagram placeholders are replaced by diagrams drawn from facts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass, field
13
+ from urllib.parse import quote
14
+
15
+ from markdown_it import MarkdownIt
16
+ from markdown_it.renderer import RendererHTML
17
+ from markdown_it.token import Token
18
+ from markdown_it.utils import EnvType, OptionsDict
19
+ from markupsafe import Markup
20
+
21
+ from codetrail.facts.store import FactStore
22
+ from codetrail.generate.validate import DIAGRAM, FILE_CITATION, MARKER
23
+ from codetrail.web.diagrams import (
24
+ Diagram,
25
+ dependencies_diagram,
26
+ imports_diagram,
27
+ resources_diagram,
28
+ system_diagram,
29
+ )
30
+
31
+ FACT_LINK = re.compile(r"\[\[([a-z_]+:[^\]\s]+)\]\]")
32
+ MARKDOWN = MarkdownIt("commonmark", {"html": False, "linkify": False}).enable("table")
33
+
34
+
35
+ def _aligned_cell(
36
+ renderer: RendererHTML, tokens: Sequence[Token], index: int, options: OptionsDict, env: EnvType
37
+ ) -> str:
38
+ """A table column's alignment as a class, where markdown-it writes an inline style: the page's policy allows no
39
+ inline styles (ADR 0016)."""
40
+ token = tokens[index]
41
+ style = token.attrs.pop("style", None)
42
+ if isinstance(style, str) and style.startswith("text-align:"):
43
+ token.attrSet("class", "align-" + style.removeprefix("text-align:"))
44
+ return renderer.renderToken(tokens, index, options, env)
45
+
46
+
47
+ for cell in ("th_open", "td_open"):
48
+ MARKDOWN.add_render_rule(cell, _aligned_cell)
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Segment:
53
+ kind: str # "html", "rationale" or "diagram"
54
+ html: Markup = field(default_factory=Markup)
55
+ label: str = ""
56
+ citation: str | None = None
57
+ link: str | None = None
58
+ diagram: Diagram | None = None
59
+
60
+
61
+ def render_markdown(text: str) -> Markup:
62
+ linked = FACT_LINK.sub(lambda match: f"[{match.group(1)}](</facts/{quote(match.group(1), safe='')}>)", text)
63
+ # Raw HTML is off and links are validated, so the output holds only markdown-it's own markup.
64
+ return Markup(MARKDOWN.render(linked)) # noqa: S704
65
+
66
+
67
+ def render_body(body: str, store: FactStore, max_nodes: int) -> list[Segment]:
68
+ segments: list[Segment] = []
69
+ pending: list[str] = []
70
+
71
+ def flush() -> None:
72
+ if "".join(pending).strip():
73
+ segments.append(Segment("html", render_markdown("\n".join(pending))))
74
+ pending.clear()
75
+
76
+ lines = body.splitlines()
77
+ index = 0
78
+ while index < len(lines):
79
+ line = lines[index]
80
+ marker = MARKER.match(line)
81
+ diagram = DIAGRAM.match(line.strip())
82
+ if marker:
83
+ flush()
84
+ index += 1
85
+ content = []
86
+ while index < len(lines) and lines[index].startswith(">") and not MARKER.match(lines[index]):
87
+ content.append(lines[index][1:].removeprefix(" "))
88
+ index += 1
89
+ citation = marker.group(2) or None
90
+ segments.append(
91
+ Segment("rationale", render_markdown("\n".join(content)), marker.group(1), citation, _link(citation))
92
+ )
93
+ continue
94
+ if diagram:
95
+ flush()
96
+ drawn = _diagram(diagram.group(1), store, max_nodes)
97
+ if drawn is not None and drawn.nodes: # a diagram with nothing to draw is left out, not shown empty
98
+ segments.append(Segment("diagram", diagram=drawn))
99
+ else:
100
+ pending.append(line)
101
+ index += 1
102
+ flush()
103
+ return segments
104
+
105
+
106
+ def _link(citation: str | None) -> str | None:
107
+ match = FILE_CITATION.match(citation or "")
108
+ return f"/source/{quote(match.group('path'))}#L{match.group('start')}" if match else None
109
+
110
+
111
+ def _diagram(arguments: str, store: FactStore, max_nodes: int) -> Diagram | None:
112
+ parts = arguments.split()
113
+ if parts == ["system"]:
114
+ return system_diagram(store, None, max_nodes)
115
+ if len(parts) != 2 or "=" not in parts[1]:
116
+ return None
117
+ kind, value = parts[0], parts[1].split("=", 1)[1]
118
+ if kind == "imports":
119
+ return imports_diagram(store, value, max_nodes)
120
+ if kind == "dependencies":
121
+ return dependencies_diagram(store, value)
122
+ if kind == "resources":
123
+ return resources_diagram(store, value, max_nodes)
124
+ if kind == "system" and parts[1].startswith("focus="):
125
+ return system_diagram(store, value, max_nodes)
126
+ return None
@@ -0,0 +1,230 @@
1
+ """The Repository page's chart data: what `static/js/repository.js` draws, with its labels translated (design 20.3).
2
+
3
+ The document goes into the page as a JSON block the browser never runs, escaped so no text in it can end the block.
4
+ Counted phrases come from the catalog for a few sample numbers that between them reach every plural category of every
5
+ language; the script finds the sample in the same `Intl.PluralRules` category as its number. Placeholders are written
6
+ `{name}` for the script to fill. Babel is a development tool only (ADR 0005), so nothing here uses it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from collections.abc import Callable, Mapping, Sequence
14
+ from datetime import UTC, date, datetime, timedelta
15
+ from typing import Any
16
+ from urllib.parse import quote
17
+
18
+ from codetrail.metrics.activity import Activity
19
+ from codetrail.metrics.churn import Churn, HotSpot, folder_of
20
+ from codetrail.metrics.repository import RepositoryReport
21
+ from codetrail.metrics.size import LanguageSize
22
+ from codetrail.web.i18n import Language
23
+
24
+ PLACEHOLDER = re.compile(r"%\((\w+)\)s")
25
+ LISTED_FILES = 5 # files a tile's panel lists
26
+ # Characters that could end the JSON block or break a script, written as JSON's \u escapes instead.
27
+ ESCAPES = {character: f"\\u{ord(character):04x}" for character in ("<", ">", "&", chr(0x2028), chr(0x2029))}
28
+
29
+
30
+ def _template(text: str) -> str:
31
+ return PLACEHOLDER.sub(r"{\1}", text)
32
+
33
+
34
+ # Numbers that between them fall in every plural category CLDR gives any language (zero, one, two, few, many, other).
35
+ PLURAL_SAMPLES = (0, 1, 2, 3, 4, 5, 6, 7, 11, 12, 21, 22, 100, 101, 102, 1_000_000)
36
+
37
+
38
+ def plural_templates(choose: Callable[[int], str]) -> dict[str, str]:
39
+ """`choose(n)` is an ngettext call; its template for each sample number, keyed by the number."""
40
+ return {str(number): _template(choose(number)) for number in PLURAL_SAMPLES}
41
+
42
+
43
+ def month_names(language: Language) -> list[str]:
44
+ """The months' short names, January first, from the catalog."""
45
+ t = language.translations
46
+ return [t.pgettext("month", "Jan"), t.pgettext("month", "Feb"), t.pgettext("month", "Mar"),
47
+ t.pgettext("month", "Apr"), t.pgettext("month", "May"), t.pgettext("month", "Jun"),
48
+ t.pgettext("month", "Jul"), t.pgettext("month", "Aug"), t.pgettext("month", "Sep"),
49
+ t.pgettext("month", "Oct"), t.pgettext("month", "Nov"), t.pgettext("month", "Dec")] # fmt: skip
50
+
51
+
52
+ def weekday_names(language: Language) -> list[str]:
53
+ """The weekdays' short names, Monday first, from the catalog."""
54
+ t = language.translations
55
+ return [t.pgettext("weekday", "Mon"), t.pgettext("weekday", "Tue"), t.pgettext("weekday", "Wed"),
56
+ t.pgettext("weekday", "Thu"), t.pgettext("weekday", "Fri"), t.pgettext("weekday", "Sat"),
57
+ t.pgettext("weekday", "Sun")] # fmt: skip
58
+
59
+
60
+ def weekday_full_names(language: Language) -> list[str]:
61
+ """The weekdays' names, Monday first, from the catalog."""
62
+ t = language.translations
63
+ return [t.gettext("Monday"), t.gettext("Tuesday"), t.gettext("Wednesday"), t.gettext("Thursday"),
64
+ t.gettext("Friday"), t.gettext("Saturday"), t.gettext("Sunday")] # fmt: skip
65
+
66
+
67
+ def script_json(document: Mapping[str, object]) -> str:
68
+ """The document as JSON safe inside `<script type="application/json">`: no `<`, `>` or `&` left to end it."""
69
+ text = json.dumps(document, ensure_ascii=False, separators=(",", ":"))
70
+ return "".join(ESCAPES.get(character, character) for character in text)
71
+
72
+
73
+ def _labels(language: Language) -> dict[str, object]:
74
+ t = language.translations
75
+
76
+ plural = plural_templates
77
+
78
+ return {
79
+ "months": month_names(language),
80
+ "commits": plural(lambda n: t.ngettext("%(count)s commit", "%(count)s commits", n)),
81
+ "lines": plural(lambda n: t.ngettext("%(count)s line", "%(count)s lines", n)),
82
+ "commitsSince": plural(
83
+ lambda n: t.ngettext("%(count)s commit since %(name)s", "%(count)s commits since %(name)s", n)
84
+ ),
85
+ "daysAfter": plural(lambda n: t.ngettext("%(count)s day after it", "%(count)s days after it", n)),
86
+ "perMonth": t.gettext("Commits per month"),
87
+ "perWeek": t.gettext("Commits per week"),
88
+ "perDay": t.gettext("Commits per day"),
89
+ "weekOf": _template(t.gettext("Week of %(date)s")),
90
+ "window": _template(t.gettext("From %(start)s to %(end)s")),
91
+ "firstRelease": t.gettext("The first release"),
92
+ "arrived": _template(t.gettext("Arrived: %(name)s")),
93
+ "left": _template(t.gettext("Left: %(name)s")),
94
+ "watched": _template(t.gettext("Watched by Codetrail since %(date)s")),
95
+ "noReleases": t.gettext("No releases in this window."),
96
+ "noDeps": t.gettext("No dependency changes in this window."),
97
+ "notRead": t.gettext("not read"),
98
+ "area": t.gettext("Guide area"),
99
+ "folder": t.gettext("Folder"),
100
+ "file": t.gettext("File"),
101
+ "mostlyInferred": t.gettext("mostly inferred rationale"),
102
+ "openArea": t.gettext("Open the area page"),
103
+ "openFile": t.gettext("Open the file"),
104
+ "noFiles": t.gettext("No file in it changed in the commits read."),
105
+ "releases": t.gettext("Releases"),
106
+ "dependencies": t.gettext("Dependencies"),
107
+ "wholeHistory": t.gettext("Whole history"),
108
+ "plotHelp": t.gettext("Use the left and right arrow keys to read each bar."),
109
+ "calendarHelp": t.gettext(
110
+ "Commits per day over the last 53 weeks; the numbers by month are in the timeline's table."
111
+ ),
112
+ "noAreas": t.gettext("Areas are counted once your assistant has written the guide's area pages."),
113
+ "noCode": t.gettext("No code to draw yet."),
114
+ "weekdays": weekday_names(language),
115
+ } # fmt: skip
116
+
117
+
118
+ def _day(seconds: int) -> str:
119
+ """The UTC day of a time, or "" for one Python's dates can't hold (past the year 9999)."""
120
+ try:
121
+ return datetime.fromtimestamp(seconds, UTC).date().isoformat()
122
+ except ValueError, OverflowError, OSError:
123
+ return ""
124
+
125
+
126
+ def _top(files: list[HotSpot], keep: Callable[[str], bool]) -> list[list[object]]:
127
+ return [[spot.name, spot.count] for spot in files if keep(spot.name)][:LISTED_FILES]
128
+
129
+
130
+ def _heat(churn: Churn) -> dict[str, Any]:
131
+ def in_folder(folder: str) -> Callable[[str], bool]:
132
+ return lambda path: folder_of(path, churn.folder_depth) == folder
133
+
134
+ def in_scope(scope: tuple[str, ...]) -> Callable[[str], bool]:
135
+ prefixes = [part.strip("/") for part in scope]
136
+ return lambda path: any(not part or path == part or path.startswith(part + "/") for part in prefixes)
137
+
138
+ largest_areas = sorted(churn.areas, key=lambda spot: (-spot.lines, spot.page.title))[: churn.tile_cap]
139
+ areas = [
140
+ {"name": spot.page.title, "path": spot.page.id, "href": "/pages/" + quote(spot.page.id), "lines": spot.lines,
141
+ "commits": spot.commits, "documented": spot.documented, "inferred": spot.inferred,
142
+ "mostlyInferred": spot.inferred > spot.documented * 2, "files": _top(churn.files, in_scope(spot.scope))}
143
+ for spot in largest_areas
144
+ ] # fmt: skip
145
+ folders = [{"name": tile.name, "path": tile.path, "href": None, "lines": tile.lines, "commits": tile.commits,
146
+ "files": _top(churn.files, in_folder(tile.path))} for tile in churn.folder_tiles] # fmt: skip
147
+ files = [{"name": tile.name, "path": tile.path, "href": "/source/" + quote(tile.path), "lines": tile.lines,
148
+ "commits": tile.commits} for tile in churn.file_tiles] # fmt: skip
149
+ # A tile with no lines of code has no size to draw: it is left out and counted, like the tiles past the cap.
150
+ levels: dict[str, list[dict[str, Any]]] = {"areas": areas, "folders": folders, "files": files}
151
+ left_out = dict(churn.left_out)
152
+ for level, tiles in levels.items():
153
+ drawn = [tile for tile in tiles if tile["lines"]]
154
+ left_out[level] = left_out.get(level, 0) + len(tiles) - len(drawn)
155
+ levels[level] = drawn
156
+ return {**levels, "leftOut": left_out}
157
+
158
+
159
+ def chart_document(report: RepositoryReport, language: Language, today: date) -> dict[str, Any]:
160
+ """Everything the page's script draws: the timeline, the calendar, the heat map and the area-by-month grid."""
161
+ activity, releases, churn, inventory = report.activity, report.releases, report.churn, report.inventory
162
+ days = None
163
+ if activity is not None and activity.days_start is not None:
164
+ days = {"start": activity.days_start.isoformat(), "counts": list(activity.days)}
165
+ release_list = None
166
+ if releases is not None:
167
+ release_list = [{"name": tag.name, "date": _day(tag.date), "commit": tag.commit[:7], "notes": tag.message}
168
+ for tag in sorted(releases.tags, key=lambda tag: (tag.date, tag.name))] # fmt: skip
169
+ deps = [{"name": change.package.split(":", 1)[-1], "sign": sign, "date": change.snapshot.taken_at[:10]}
170
+ for sign, changes in (("+", inventory.arrived), ("-", inventory.left)) for change in changes] # fmt: skip
171
+ grid = None
172
+ if churn is not None:
173
+ titles = {spot.page.id: spot.page.title for spot in churn.areas}
174
+ rows = [{"name": titles.get(page_id, page_id), "cells": list(cells)} for page_id, cells in churn.area_months]
175
+ grid = {"months": list(churn.months), "rows": rows}
176
+ return {
177
+ "lang": language.code,
178
+ "today": today.isoformat(),
179
+ "days": days,
180
+ "releases": release_list,
181
+ "watchStart": inventory.since.taken_at[:10] if inventory.since else None,
182
+ "deps": deps,
183
+ "heat": _heat(churn) if churn is not None else None,
184
+ "grid": grid,
185
+ "labels": _labels(language),
186
+ }
187
+
188
+
189
+ def months_table(activity: Activity | None) -> list[tuple[str, int]]:
190
+ """Commits per month ("YYYY-MM"), oldest first, from the commits per day."""
191
+ if activity is None or activity.days_start is None:
192
+ return []
193
+ months: dict[str, int] = {}
194
+ for offset, count in enumerate(activity.days):
195
+ key = (activity.days_start + timedelta(days=offset)).strftime("%Y-%m")
196
+ months[key] = months.get(key, 0) + count
197
+ return list(months.items())
198
+
199
+
200
+ def bar_heights(values: Sequence[float]) -> list[int]:
201
+ """Each value as a percent of the highest, for the bars the page draws on the server."""
202
+ highest = max(values, default=0)
203
+ return [round(value / highest * 100) if highest else 0 for value in values]
204
+
205
+
206
+ def ramp_step(count: int, highest: int) -> int:
207
+ """The step of the five-step ramp a count falls in: 0 for nothing, then quarters of the highest."""
208
+ if count <= 0 or highest <= 0:
209
+ return 0
210
+ return min(4, 1 + (count * 4 - 1) // highest)
211
+
212
+
213
+ def language_segments(languages: Sequence[LanguageSize], keep: int = 4) -> list[tuple[str, int, int, int]]:
214
+ """The largest languages by lines with their color slot and rounded percent, then the rest as Other."""
215
+ total = sum(size.lines for size in languages)
216
+ segments = [(size.language, size.lines, slot) for slot, size in enumerate(languages[:keep], start=1)]
217
+ rest = sum(size.lines for size in languages[keep:])
218
+ if rest:
219
+ segments.append(("Other", rest, keep + 1))
220
+ return [(name, lines, slot, round(lines / total * 100) if total else 0) for name, lines, slot in segments]
221
+
222
+
223
+ def busiest_weekday(activity: Activity | None) -> int | None:
224
+ """The weekday (0 for Monday) with the most commits, or None without any."""
225
+ if activity is None or activity.days_start is None or not any(activity.days):
226
+ return None
227
+ totals = [0] * 7
228
+ for offset, count in enumerate(activity.days):
229
+ totals[(activity.days_start + timedelta(days=offset)).weekday()] += count
230
+ return totals.index(max(totals))
@@ -0,0 +1,171 @@
1
+ """The page's security model (design section 7.4).
2
+
3
+ - Only requests whose Host is `127.0.0.1:<port>` or `localhost:<port>` are served (DNS rebinding).
4
+ - Every route but `/login` and `/static/` needs the session cookie, set once from a single-use login code that
5
+ `codetrail serve` prints; the code expires.
6
+ - Every write (any method but GET and HEAD) needs an Origin equal to the served origin and the per-session token in
7
+ the `X-Codetrail-Token` header (cross-site request forgery).
8
+ - A request a browser marks as coming from another site or another port (`Sec-Fetch-Site`) is refused.
9
+ - Every response, refusals included, carries the security headers and `Cache-Control: no-store`, unless its route
10
+ sets its own; static files carry `no-cache`, so the browser revalidates them.
11
+ - The worker that runs D2's WebAssembly build alone gets another Content-Security-Policy: its layout evaluates
12
+ JavaScript, so it may, but it can import nothing but Go's loader and the ELK layout, reach nothing but its wasm
13
+ file, and has no DOM (ADRs 0016, 0018).
14
+ Sessions live in memory and end with the process.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hmac
20
+ import secrets
21
+ import time
22
+ from collections.abc import Callable
23
+ from http.cookies import SimpleCookie
24
+
25
+ from starlette.responses import PlainTextResponse, RedirectResponse, Response
26
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
27
+
28
+ SESSION_COOKIE = "codetrail_session"
29
+ TOKEN_HEADER = "x-codetrail-token" # noqa: S105 - a header name, not a secret
30
+ CONTENT_SECURITY_POLICY = (
31
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
32
+ "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
33
+ )
34
+ D2_WORKER = "/static/js/d2-worker.js"
35
+ SECURITY_HEADERS = [
36
+ (b"content-security-policy", CONTENT_SECURITY_POLICY.encode()),
37
+ (b"x-content-type-options", b"nosniff"),
38
+ (b"referrer-policy", b"no-referrer"),
39
+ (b"x-frame-options", b"DENY"),
40
+ (b"cross-origin-resource-policy", b"same-origin"),
41
+ ]
42
+ READ_METHODS = {"GET", "HEAD"}
43
+ OPEN_PATHS = ("/login", "/static/")
44
+ # Reads that return JSON for the page's script also need the token, as defence in depth (design 16.3).
45
+ TOKEN_READS = ("/search/results", "/bridge/answers")
46
+
47
+
48
+ class SessionState:
49
+ """The one reader's session: a single-use login code, the session cookie's value and the write token."""
50
+
51
+ def __init__(
52
+ self, login_code_ttl_seconds: int, clock: Callable[[], float] = time.monotonic, session_minutes: int = 480
53
+ ) -> None:
54
+ self._ttl = login_code_ttl_seconds
55
+ self._clock = clock
56
+ # Browsers send a cookie to every port of a host, so a session must not outlive its use (design 7.4).
57
+ self._session_expires = clock() + session_minutes * 60
58
+ self._code: str | None = None
59
+ self._code_expires = 0.0
60
+ self.session_id = secrets.token_urlsafe(32)
61
+ self.token = secrets.token_urlsafe(32)
62
+
63
+ def issue_login_code(self) -> str:
64
+ self._code = secrets.token_urlsafe(32)
65
+ self._code_expires = self._clock() + self._ttl
66
+ return self._code
67
+
68
+ def redeem(self, code: str) -> bool:
69
+ """True once for the issued code, before it expires; the code can't be used again."""
70
+ expected = self._code
71
+ if expected is None or not _equal(code, expected):
72
+ return False
73
+ self._code = None
74
+ return self._clock() <= self._code_expires
75
+
76
+ def is_session(self, cookie: str | None) -> bool:
77
+ return cookie is not None and _equal(cookie, self.session_id) and self._clock() <= self._session_expires
78
+
79
+ def is_token(self, token: str | None) -> bool:
80
+ return token is not None and _equal(token, self.token)
81
+
82
+
83
+ def _equal(given: str, expected: str) -> bool:
84
+ return hmac.compare_digest(given.encode(), expected.encode())
85
+
86
+
87
+ def login_response(session: SessionState, code: str) -> Response:
88
+ if not session.redeem(code):
89
+ return PlainTextResponse("This sign-in link has expired or was already used. Run codetrail serve again.", 403)
90
+ response = RedirectResponse("/", status_code=303)
91
+ response.set_cookie(SESSION_COOKIE, session.session_id, httponly=True, samesite="strict", path="/")
92
+ return response
93
+
94
+
95
+ class SecurityMiddleware:
96
+ def __init__(self, app: ASGIApp, session: SessionState, port: int) -> None:
97
+ self.app = app
98
+ self.session = session
99
+ self.hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
100
+
101
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
102
+ if scope["type"] == "lifespan":
103
+ await self.app(scope, receive, send)
104
+ return
105
+ if scope["type"] != "http":
106
+ return # no websockets
107
+ refusal = self._refusal(scope)
108
+ # Static files are revalidated on every load (cheap on 127.0.0.1), so after an upgrade the browser never mixes
109
+ # last version's styles or script with this version's pages. Everything else may carry the write token or the
110
+ # guide, so the browser never stores it, unless its route set its own policy (ASVS 14.3.2).
111
+ is_static = scope["path"].startswith("/static/")
112
+ replaced = {name for name, _ in SECURITY_HEADERS} | ({b"cache-control"} if is_static else set())
113
+ security_headers = SECURITY_HEADERS
114
+ if scope["path"] == D2_WORKER and refusal is None: # the Host was checked, so it can name the wasm's origin
115
+ policy = _worker_policy(_headers(scope)["host"]).encode()
116
+ security_headers = [(name, policy if name == b"content-security-policy" else value)
117
+ for name, value in SECURITY_HEADERS] # fmt: skip
118
+
119
+ async def send_with_headers(message: Message) -> None:
120
+ if message["type"] == "http.response.start":
121
+ headers = [(name, value) for name, value in message.get("headers", []) if name not in replaced]
122
+ if not any(name == b"cache-control" for name, _ in headers):
123
+ headers.append((b"cache-control", b"no-cache" if is_static else b"no-store"))
124
+ message["headers"] = headers + security_headers
125
+ await send(message)
126
+
127
+ if refusal is not None:
128
+ await refusal(scope, receive, send_with_headers)
129
+ return
130
+ await self.app(scope, receive, send_with_headers)
131
+
132
+ def _refusal(self, scope: Scope) -> Response | None:
133
+ headers = _headers(scope)
134
+ host = headers.get("host", "")
135
+ if host not in self.hosts:
136
+ return PlainTextResponse("This page is only served at 127.0.0.1 or localhost.", 400)
137
+ path = scope["path"]
138
+ if path.startswith("/static/"):
139
+ return None
140
+ # Browsers say where a request came from. Only the page itself or the address bar may reach Codetrail, so
141
+ # another site, or a server on another 127.0.0.1 port (same-site), can't time or drive it (ASVS 3.5.8).
142
+ if headers.get("sec-fetch-site", "same-origin") not in ("same-origin", "none"):
143
+ return PlainTextResponse("This request didn't come from Codetrail's page.", 403)
144
+ if path == "/login":
145
+ return None
146
+ cookies = SimpleCookie(headers.get("cookie", ""))
147
+ cookie = cookies[SESSION_COOKIE].value if SESSION_COOKIE in cookies else None
148
+ if not self.session.is_session(cookie):
149
+ return PlainTextResponse("Sign in with the link codetrail serve printed.", 403)
150
+ if path in TOKEN_READS and not self.session.is_token(headers.get(TOKEN_HEADER)):
151
+ return PlainTextResponse("This request didn't come from Codetrail's page.", 403)
152
+ writes = scope["method"] not in READ_METHODS
153
+ if writes and (
154
+ headers.get("origin") != f"http://{host}" or not self.session.is_token(headers.get(TOKEN_HEADER))
155
+ ):
156
+ return PlainTextResponse("This request didn't come from Codetrail's page.", 403)
157
+ return None
158
+
159
+
160
+ def _headers(scope: Scope) -> dict[str, str]:
161
+ return {name.decode("latin-1"): value.decode("latin-1") for name, value in scope["headers"]}
162
+
163
+
164
+ def _worker_policy(host: str) -> str:
165
+ """Eval, but only Go's loader and the ELK layout to import, only the wasm file to fetch and no nested workers (which
166
+ would otherwise fall back to script-src), at the Host already checked."""
167
+ vendor = f"http://{host}/static/vendor/d2"
168
+ return (
169
+ f"default-src 'none'; script-src {vendor}/wasm_exec.js {vendor}/elk.js 'unsafe-eval' 'wasm-unsafe-eval'; "
170
+ f"connect-src {vendor}/d2.wasm; worker-src 'none'"
171
+ )
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" role="img" aria-label="codetrail"><title>codetrail</title><rect width="32" height="32" rx="7" fill="#5b5bd6"/><path d="M7 10L13 16L7 22" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17" cy="22" r="2.5" fill="#fff" fill-opacity=".6"/><circle cx="21" cy="18" r="2.5" fill="#fff" fill-opacity=".85"/><circle cx="25" cy="14" r="2.5" fill="#fff"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" role="img" aria-label="codetrail"><title>codetrail</title><rect width="32" height="32" rx="7" fill="#5b5bd6"/><path d="M7 10.5L12.5 16L7 21.5" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="16.5" cy="21.5" r="2.25" fill="#fff" fill-opacity=".55"/><circle cx="20.5" cy="17.5" r="2.25" fill="#fff" fill-opacity=".8"/><circle cx="24.5" cy="13.5" r="2.25" fill="#fff"/></svg>