codetrail-cli 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
codetrail/lock.py ADDED
@@ -0,0 +1,114 @@
1
+ """One update at a time per target (design section 10), and no removal while a command uses the target.
2
+
3
+ The OS releases the locks if the process dies."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import fcntl
8
+ import os
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+
13
+ from codetrail.config import Paths, validate_target_name
14
+ from codetrail.errors import CodetrailError
15
+
16
+
17
+ class TargetBusy(CodetrailError):
18
+ """Another command holds the target's lock."""
19
+
20
+
21
+ @contextmanager
22
+ def target_lock(paths: Paths, name: str) -> Iterator[None]:
23
+ folder = paths.target_data(name)
24
+ folder.mkdir(parents=True, exist_ok=True)
25
+ with (folder / "update.lock").open("a") as handle:
26
+ try:
27
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
28
+ except BlockingIOError as error:
29
+ raise TargetBusy(_already_updating(name)) from error
30
+ try:
31
+ yield
32
+ finally:
33
+ fcntl.flock(handle, fcntl.LOCK_UN)
34
+
35
+
36
+ @contextmanager
37
+ def existing_target_lock(paths: Paths, name: str) -> Iterator[None]:
38
+ """Holds the update lock if the data folder has one, creating nothing and following no symlink.
39
+
40
+ For removal: an update holds it even when an editor has replaced the settings file its in-use lock is on."""
41
+ folder = paths.target_data(name)
42
+ descriptor = None
43
+ if folder.is_dir() and not folder.is_symlink():
44
+ try:
45
+ descriptor = os.open(folder / "update.lock", os.O_RDONLY | os.O_NOFOLLOW)
46
+ except FileNotFoundError:
47
+ pass
48
+ except OSError as error:
49
+ raise CodetrailError(f"Can't lock target {name!r} ({folder / 'update.lock'}): {error.strerror}.") from error
50
+ if descriptor is None:
51
+ yield
52
+ return
53
+ try:
54
+ try:
55
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
56
+ except BlockingIOError as error:
57
+ raise TargetBusy(_already_updating(name)) from error
58
+ yield
59
+ finally:
60
+ os.close(descriptor) # closing releases the lock
61
+
62
+
63
+ def _already_updating(name: str) -> str:
64
+ return f"Target {name!r} is already updating; try again when that finishes."
65
+
66
+
67
+ @contextmanager
68
+ def target_in_use(paths: Paths, name: str) -> Iterator[None]:
69
+ """Held, shared, by `serve`, `update` and `files` while they use the target, so it isn't removed under them."""
70
+ with _settings_lock(paths, name, fcntl.LOCK_SH, f"Target {name!r} is being removed."):
71
+ yield
72
+
73
+
74
+ @contextmanager
75
+ def target_removal(paths: Paths, name: str) -> Iterator[None]:
76
+ """Held alone by `codetrail target remove` until the target's settings file is gone."""
77
+ busy = f"Target {name!r} is in use: stop `codetrail serve` for it, or wait for its update to finish."
78
+ with _settings_lock(paths, name, fcntl.LOCK_EX, busy):
79
+ yield
80
+
81
+
82
+ @contextmanager
83
+ def _settings_lock(paths: Paths, name: str, operation: int, busy: str) -> Iterator[None]:
84
+ """Locks the target's settings file, which removal deletes last; opening it creates no folder."""
85
+ settings = paths.target_file(validate_target_name(name))
86
+ missing = f"No target named {name!r}. Add it with: codetrail target add {name} <path>"
87
+ try:
88
+ handle = settings.open("rb")
89
+ except FileNotFoundError as error:
90
+ raise CodetrailError(missing) from error
91
+ except OSError as error:
92
+ raise CodetrailError(f"Can't lock target {name!r} ({settings}): {error.strerror}.") from error
93
+ with handle:
94
+ try:
95
+ fcntl.flock(handle, operation | fcntl.LOCK_NB)
96
+ except BlockingIOError as error:
97
+ raise TargetBusy(busy) from error
98
+ except OSError as error: # a filesystem without locks, for one
99
+ raise CodetrailError(f"Can't lock target {name!r} ({settings}): {error.strerror}.") from error
100
+ try:
101
+ if not _is_current(handle.fileno(), settings): # a removal finished between opening and locking
102
+ raise CodetrailError(missing)
103
+ yield
104
+ finally:
105
+ fcntl.flock(handle, fcntl.LOCK_UN)
106
+
107
+
108
+ def _is_current(descriptor: int, settings: Path) -> bool:
109
+ try:
110
+ current = settings.stat()
111
+ except FileNotFoundError:
112
+ return False
113
+ opened = os.fstat(descriptor)
114
+ return (opened.st_dev, opened.st_ino) == (current.st_dev, current.st_ino)
@@ -0,0 +1,6 @@
1
+ """Documentation metrics: how well a repository explains itself, from the guide, the facts and the history (design
2
+ section 18).
3
+
4
+ Each metric is a pure function over data already read; `report.build_report` reads it, and `history` keeps each
5
+ metric's value per update. Nothing here calls an assistant.
6
+ """
@@ -0,0 +1,96 @@
1
+ """The branch's history and its commit types (design section 19.1): dates and counts only, never people."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from collections.abc import Iterable, Sequence
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, date, datetime, timedelta
10
+ from itertools import pairwise
11
+
12
+ from codetrail.repo.history import WITHHELD_MESSAGE, Commit
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Activity:
17
+ commits: int
18
+ merges: int
19
+ first: date | None
20
+ latest: date | None
21
+ months: list[tuple[str, int]] # "YYYY-MM" and its commits, the last few months up to today, oldest first
22
+ years: list[tuple[int, int]] # each year from the first commit's to the latest's, oldest first
23
+ active_weeks: int
24
+ weeks: int # weeks from the first commit's to the latest's, both counted
25
+ longest_gap: tuple[date, date] | None # the two commit days furthest apart with none between
26
+ cut: bool # the history holds more commits than were read
27
+ days: list[int] = field(default_factory=list) # commits per UTC day, from days_start to today
28
+ days_start: date | None = None
29
+
30
+
31
+ def _monday(day: date) -> date:
32
+ return day - timedelta(days=day.weekday())
33
+
34
+
35
+ def measure_activity(times: Sequence[int], commits: int, merges: int, today: date, months: int) -> Activity:
36
+ """Activity from author times in UTC; `commits` and `merges` count the whole history, even past the times read."""
37
+ days = sorted(datetime.fromtimestamp(time, UTC).date() for time in times)
38
+ by_month = Counter(day.strftime("%Y-%m") for day in days)
39
+ shown, year, month = [], today.year, today.month
40
+ for _ in range(months):
41
+ shown.append(f"{year:04d}-{month:02d}")
42
+ year, month = (year, month - 1) if month > 1 else (year - 1, 12)
43
+ month_counts = [(key, by_month.get(key, 0)) for key in reversed(shown)]
44
+ cut = len(times) < commits
45
+ if not days:
46
+ return Activity(commits, merges, None, None, month_counts, [], 0, 0, None, cut)
47
+ first, latest = days[0], days[-1]
48
+ by_year = Counter(day.year for day in days)
49
+ years = [(year, by_year.get(year, 0)) for year in range(first.year, latest.year + 1)]
50
+ active_weeks = len({_monday(day) for day in days})
51
+ weeks = (_monday(latest) - _monday(first)).days // 7 + 1
52
+ widest = max(pairwise(days), key=lambda pair: pair[1] - pair[0], default=None)
53
+ gap = widest if widest and widest[1] > widest[0] else None
54
+ # Commits per day end at today: a commit can claim any date, and a far-future one would make the series huge.
55
+ by_day = Counter(days)
56
+ per_day = [by_day.get(first + timedelta(days=offset), 0) for offset in range((today - first).days + 1)]
57
+ days_start = first if per_day else None
58
+ return Activity(commits, merges, first, latest, month_counts, years, active_weeks, weeks, gap, cut, per_day,
59
+ days_start) # fmt: skip
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class CommitTypes:
64
+ matched: int
65
+ measured: int # subjects read: withheld ones are left out
66
+ types: list[tuple[str, int]] # the most used first
67
+ scopes: list[tuple[str, int]]
68
+
69
+ @property
70
+ def used(self) -> bool:
71
+ """Whether the repository appears to use such subjects: at least half of them match."""
72
+ return self.measured > 0 and self.matched * 2 >= self.measured
73
+
74
+
75
+ def _most_first(counts: Counter[str]) -> list[tuple[str, int]]:
76
+ return sorted(counts.items(), key=lambda item: (-item[1], item[0]))
77
+
78
+
79
+ def measure_types(commits: Iterable[Commit], pattern: re.Pattern[str], max_chars: int) -> CommitTypes:
80
+ """Counts the subjects that match the target's commit type pattern; only the start of a subject is matched."""
81
+ matched = measured = 0
82
+ types: Counter[str] = Counter()
83
+ scopes: Counter[str] = Counter()
84
+ for commit in commits:
85
+ if commit.subject == WITHHELD_MESSAGE:
86
+ continue
87
+ measured += 1
88
+ found = pattern.match(commit.subject[:max_chars])
89
+ if found is None:
90
+ continue
91
+ matched += 1
92
+ types[found["type"].lower()] += 1
93
+ scope = found.groupdict().get("scope")
94
+ if scope:
95
+ scopes[scope] += 1
96
+ return CommitTypes(matched, measured, _most_first(types), _most_first(scopes))
@@ -0,0 +1,156 @@
1
+ """Where change happens: hot spots, the heat map's tiles, the area-by-month grid, quiet code and commit size.
2
+
3
+ Design sections 19.1 and 20.1. Only allowed files are ever named: a commit's other files count toward its size, never
4
+ toward a hot spot or a tile.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import statistics
10
+ from collections import Counter
11
+ from collections.abc import Collection, Mapping, Sequence
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, date, datetime
14
+
15
+ from codetrail.generate.outline import in_scope
16
+ from codetrail.guide import Page
17
+
18
+ MONTHS_SHOWN = 12
19
+ SIZE_BUCKETS = (1, 2, 3, 5, 10, 20) # upper bounds of 1, 2, 3, 4-5, 6-10, 11-20; anything larger is 21+
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class HotSpot:
24
+ name: str # a file, or a folder ("." for the repository's root)
25
+ count: int # commits that changed it, or for quiet code, its quiet files
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AreaSpot:
30
+ page: Page
31
+ commits: int
32
+ documented: int
33
+ inferred: int
34
+ lines: int = 0 # lines of code in the area's scope
35
+ scope: tuple[str, ...] = field(default=(), compare=False) # the area's scope paths
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class HeatTile:
40
+ name: str
41
+ path: str
42
+ lines: int
43
+ commits: int
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Churn:
48
+ commits: int # read: the latest non-merge commits, up to the window
49
+ files: list[HotSpot] # the most changed first
50
+ folders: list[HotSpot]
51
+ areas: list[AreaSpot]
52
+ quiet: list[HotSpot] # code files no recent commit changed, counted by folder
53
+ quiet_files: int
54
+ median_files: float | None # files a commit changes
55
+ p90_files: float | None
56
+ folder_tiles: list[HeatTile] = field(default_factory=list) # the largest by lines, at most the cap
57
+ file_tiles: list[HeatTile] = field(default_factory=list)
58
+ left_out: dict[str, int] = field(default_factory=dict) # tiles past the cap, per level
59
+ months: list[str] = field(default_factory=list) # "YYYY-MM", the last twelve, oldest first
60
+ area_months: list[tuple[str, list[int | None]]] = field(default_factory=list) # None: a month not read
61
+ size_histogram: list[int] = field(default_factory=list) # commits per bucket of files changed
62
+ folder_depth: int = 2 # how deep the folder tiles group files
63
+ tile_cap: int = 60 # tiles a heat map level draws at most
64
+
65
+
66
+ def folder_of(path: str, depth: int) -> str:
67
+ """The file's folder, at most `depth` levels deep; "." for a file at the root."""
68
+ return "/".join(path.split("/")[:-1][:depth]) or "."
69
+
70
+
71
+ def _most_first(counts: Counter[str]) -> list[HotSpot]:
72
+ return [HotSpot(name, count) for name, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))]
73
+
74
+
75
+ def _month(time: int) -> str:
76
+ return datetime.fromtimestamp(time, UTC).strftime("%Y-%m")
77
+
78
+
79
+ def _last_months(today: date) -> list[str]:
80
+ year, month, months = today.year, today.month, []
81
+ for _ in range(MONTHS_SHOWN):
82
+ months.append(f"{year:04d}-{month:02d}")
83
+ year, month = (year, month - 1) if month > 1 else (year - 1, 12)
84
+ return months[::-1]
85
+
86
+
87
+ def _bucket(size: int) -> int:
88
+ return next((index for index, bound in enumerate(SIZE_BUCKETS) if size <= bound), len(SIZE_BUCKETS))
89
+
90
+
91
+ def _capped(tiles: list[HeatTile], cap: int) -> list[HeatTile]:
92
+ return sorted(tiles, key=lambda tile: (-tile.lines, tile.path))[:cap]
93
+
94
+
95
+ def measure_churn(
96
+ changes: Sequence[tuple[int, Sequence[str]]],
97
+ allowed: Collection[str],
98
+ code_lines: Mapping[str, int],
99
+ recently_changed: Collection[str],
100
+ areas: Sequence[tuple[Page, list[str], int, int]],
101
+ today: date,
102
+ folder_depth: int,
103
+ tile_cap: int,
104
+ window: int,
105
+ ) -> Churn:
106
+ """`changes` holds each commit's time and paths, newest first; `areas` each area page with its scope and rationale.
107
+
108
+ `window` is how many commits were asked for: when that many came back, the history goes further back than was
109
+ read, and the grid's months before the oldest commit read are unread rather than quiet.
110
+ """
111
+ files: Counter[str] = Counter()
112
+ folders: Counter[str] = Counter()
113
+ by_area: Counter[str] = Counter()
114
+ by_area_month: Counter[tuple[str, str]] = Counter()
115
+ for time, paths in changes:
116
+ visible = {path for path in paths if path in allowed}
117
+ files.update(visible)
118
+ folders.update({folder_of(path, folder_depth) for path in visible})
119
+ for page, scope_paths, _documented, _inferred in areas:
120
+ if any(in_scope(path, scope_paths) for path in visible):
121
+ by_area[page.id] += 1
122
+ by_area_month[page.id, _month(time)] += 1
123
+ spots = [
124
+ AreaSpot(page, by_area[page.id], documented, inferred,
125
+ sum(lines for path, lines in code_lines.items() if in_scope(path, scope_paths)), tuple(scope_paths))
126
+ for page, scope_paths, documented, inferred in areas
127
+ ] # fmt: skip
128
+ spots.sort(key=lambda spot: (-spot.commits, spot.page.title))
129
+ folder_lines: Counter[str] = Counter()
130
+ for path, lines in code_lines.items():
131
+ folder_lines[folder_of(path, folder_depth)] += lines
132
+ folder_tiles = [HeatTile(name, name, lines, folders[name]) for name, lines in folder_lines.items()]
133
+ file_tiles = [HeatTile(path.rsplit("/", 1)[-1], path, lines, files[path]) for path, lines in code_lines.items()]
134
+ months = _last_months(today)
135
+ oldest = _month(changes[-1][0]) if changes and len(changes) >= window else None
136
+ area_months = [
137
+ (page.id, [None if oldest and month < oldest else by_area_month[page.id, month] for month in months])
138
+ for page, _scope, _documented, _inferred in areas
139
+ ]
140
+ quiet = [path for path in code_lines if path not in recently_changed]
141
+ sizes = [len(paths) for _time, paths in changes]
142
+ histogram = [0] * (len(SIZE_BUCKETS) + 1)
143
+ for size in sizes:
144
+ if size:
145
+ histogram[_bucket(size)] += 1
146
+ median = statistics.median(sizes) if sizes else None
147
+ # Inclusive: the default method extrapolates past the largest commit when there are few.
148
+ p90 = statistics.quantiles(sizes, n=10, method="inclusive")[8] if len(sizes) > 1 else median
149
+ left_out = {"areas": max(0, len(spots) - tile_cap), "folders": max(0, len(folder_tiles) - tile_cap),
150
+ "files": max(0, len(file_tiles) - tile_cap)} # fmt: skip
151
+ return Churn(
152
+ len(changes), _most_first(files), _most_first(folders), spots,
153
+ _most_first(Counter(folder_of(path, folder_depth) for path in quiet)), len(quiet), median, p90,
154
+ _capped(folder_tiles, tile_cap), _capped(file_tiles, tile_cap), left_out, months, area_months, histogram,
155
+ folder_depth, tile_cap,
156
+ ) # fmt: skip
@@ -0,0 +1,45 @@
1
+ """Commits that explain why (design section 18.1).
2
+
3
+ A commit explains why when its body matches the target's `commit_why_pattern`; otherwise it has a body without a why,
4
+ or only a subject. A message gitleaks withheld is counted apart and left out of the share.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Iterable
11
+ from dataclasses import dataclass
12
+
13
+ from codetrail.repo.history import WITHHELD_MESSAGE, Commit
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CommitMetric:
18
+ explains_why: int
19
+ body_only: int
20
+ subject_only: int
21
+ withheld: int
22
+ without_why: list[Commit] # in the order given: newest first
23
+
24
+ @property
25
+ def measured(self) -> int:
26
+ """The share's denominator: every commit but the withheld ones."""
27
+ return self.explains_why + self.body_only + self.subject_only
28
+
29
+
30
+ def measure_commits(commits: Iterable[Commit], why: re.Pattern[str], max_chars: int = 20_000) -> CommitMetric:
31
+ """Sorts each commit into one level; only the start of a body is matched, so no message can make it slow."""
32
+ explains_why = body_only = subject_only = withheld = 0
33
+ without_why = []
34
+ for commit in commits:
35
+ if commit.subject == WITHHELD_MESSAGE:
36
+ withheld += 1
37
+ elif why.search(commit.body[:max_chars]):
38
+ explains_why += 1
39
+ else:
40
+ without_why.append(commit)
41
+ if commit.body:
42
+ body_only += 1
43
+ else:
44
+ subject_only += 1
45
+ return CommitMetric(explains_why, body_only, subject_only, withheld, without_why)
@@ -0,0 +1,114 @@
1
+ """Facts a document mentions, and facts the guide explains (design section 18.1).
2
+
3
+ A mention isn't a reason: this only shows which dependencies, projects and resources some document names. Names are
4
+ looked up in the set of a document's words, so no regular expression is ever built from a name a repository controls.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Iterable, Mapping
11
+ from dataclasses import dataclass
12
+ from pathlib import PurePosixPath
13
+
14
+ from codetrail.facts import Entity, EntityKind
15
+ from codetrail.generate.outline import OutlineEntry
16
+ from codetrail.generate.scope import entities_in_scope
17
+ from codetrail.guide import Page
18
+ from codetrail.metrics.rationale import guide_pages
19
+
20
+ MENTIONABLE = (EntityKind.PACKAGE, EntityKind.PROJECT, EntityKind.RESOURCE)
21
+ WORD = re.compile(r"[A-Za-z0-9@/._-]+")
22
+ SEPARATORS = re.compile(r"[-_.]+")
23
+
24
+
25
+ def normalise(word: str) -> str:
26
+ """Lower-cased, with each run of `-`, `_` and `.` made one `-`, as Python distribution names are compared."""
27
+ return SEPARATORS.sub("-", word.lower()).strip("-/")
28
+
29
+
30
+ def words_of(text: str) -> set[str]:
31
+ """Every word of the text, and each part of a word with slashes, normalised."""
32
+ words = set()
33
+ for token in WORD.findall(text):
34
+ words.add(normalise(token))
35
+ words.update(normalise(part) for part in token.split("/"))
36
+ words.discard("")
37
+ return words
38
+
39
+
40
+ def mention_name(entity: Entity) -> str:
41
+ """What a document names: a package's name, a project's folder, a resource's address."""
42
+ key = entity.id.split(":", 1)[1]
43
+ if entity.kind == EntityKind.PACKAGE:
44
+ return normalise(key.split("/", 1)[-1]) # after the ecosystem: pypi/, npm/, swift/
45
+ if entity.kind == EntityKind.RESOURCE:
46
+ return normalise(key.rsplit("/", 1)[-1])
47
+ return normalise(key)
48
+
49
+
50
+ def _folder(path: str) -> str:
51
+ parent = str(PurePosixPath(path).parent)
52
+ return "" if parent == "." else parent
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class MentionMetric:
57
+ mentioned: list[Entity]
58
+ unmentioned: list[Entity]
59
+
60
+
61
+ def measure_mentions(entities: Iterable[Entity], documents: Mapping[str, str]) -> MentionMetric:
62
+ words: set[str] = set()
63
+ for text in documents.values():
64
+ words |= words_of(text)
65
+ readme_folders = {_folder(path) for path in documents if PurePosixPath(path).name.lower().startswith("readme")}
66
+ mentioned: list[Entity] = []
67
+ unmentioned: list[Entity] = []
68
+ for entity in sorted(entities, key=lambda found: found.id):
69
+ if entity.kind not in MENTIONABLE:
70
+ continue
71
+ if entity.kind == EntityKind.PROJECT:
72
+ folder = entity.id.split(":", 1)[1]
73
+ found = (("" if folder == "." else folder) in readme_folders) or (folder != "." and
74
+ mention_name(entity) in words) # fmt: skip
75
+ else:
76
+ found = mention_name(entity) in words
77
+ (mentioned if found else unmentioned).append(entity)
78
+ return MentionMetric(mentioned, unmentioned)
79
+
80
+
81
+ def _strings(value: object) -> list[str] | None:
82
+ if isinstance(value, list) and all(isinstance(item, str) for item in value):
83
+ return value
84
+ return None
85
+
86
+
87
+ def page_scope(page: Page) -> OutlineEntry | None:
88
+ """The scope a written page records (design section 4.3), or None when its front matter isn't usable."""
89
+ scope = page.meta.get("scope", {})
90
+ facts = page.meta.get("facts", [])
91
+ if not isinstance(scope, dict) or not isinstance(facts, list):
92
+ return None
93
+ paths, kinds = _strings(scope.get("paths", [])), _strings(scope.get("kinds", []))
94
+ if paths is None or kinds is None or not all(isinstance(fact, dict) and isinstance(fact.get("id"), str)
95
+ for fact in facts): # fmt: skip
96
+ return None
97
+ return OutlineEntry(page.id, page.kind, page.title, paths, kinds, [fact["id"] for fact in facts])
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ExplainedMetric:
102
+ explained: int
103
+ total: int
104
+ unexplained: list[Entity]
105
+
106
+
107
+ def measure_explained(entities: list[Entity], pages: Iterable[Page]) -> ExplainedMetric:
108
+ explained: set[str] = set()
109
+ for page in guide_pages(pages):
110
+ entry = page_scope(page)
111
+ if entry is not None:
112
+ explained.update(entity.id for entity in entities_in_scope(entities, entry))
113
+ unexplained = sorted((entity for entity in entities if entity.id not in explained), key=lambda found: found.id)
114
+ return ExplainedMetric(len(entities) - len(unexplained), len(entities), unexplained)
@@ -0,0 +1,77 @@
1
+ """ADR health: statuses, proposed ADRs waiting too long, and which ADRs the guide cites (design section 18.1).
2
+
3
+ A page cites an ADR with a documented block quoting its file, a `[[decision:…]]` link, or the ADR in its front
4
+ matter's `facts`. Only area and concept pages count, as for the documented share.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass
11
+ from datetime import date
12
+
13
+ from codetrail.facts import Entity
14
+ from codetrail.generate.validate import FACT_LINK, parse_rationale
15
+ from codetrail.guide import Page
16
+ from codetrail.metrics.rationale import guide_pages
17
+
18
+ RETIRED = ("superseded", "deprecated")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class DecisionMetric:
23
+ by_status: dict[str, list[Entity]] # "none" when an ADR states no status
24
+ overdue: list[Entity]
25
+ undated: list[Entity] # proposed, with no usable date
26
+ superseded_cited: list[Entity] # superseded or deprecated, still cited
27
+ uncited: list[Entity] # accepted, cited by no page
28
+
29
+ @property
30
+ def attention(self) -> int:
31
+ return len(self.overdue) + len(self.superseded_cited)
32
+
33
+
34
+ def cited_decisions(pages: Iterable[Page], decisions: Iterable[Entity]) -> set[str]:
35
+ by_path = {str(decision.attributes.get("path", "")): decision.id for decision in decisions}
36
+ cited: set[str] = set()
37
+ for page in guide_pages(pages):
38
+ cited.update(FACT_LINK.findall(page.body))
39
+ for block in parse_rationale(page.body):
40
+ if block.kind == "documented" and block.citation:
41
+ cited.add(by_path.get(block.citation.split("#", 1)[0], ""))
42
+ facts = page.meta.get("facts")
43
+ for fact in facts if isinstance(facts, list) else []:
44
+ if isinstance(fact, dict):
45
+ cited.add(str(fact.get("id", "")))
46
+ return cited
47
+
48
+
49
+ def written_on(decision: Entity) -> date | None:
50
+ """The ADR's `Date:`, or None when it gives none Python can read."""
51
+ try:
52
+ return date.fromisoformat(str(decision.attributes.get("date", "")))
53
+ except ValueError:
54
+ return None
55
+
56
+
57
+ def measure_decisions(
58
+ decisions: Iterable[Entity], pages: Iterable[Page], today: date, proposed_days: int
59
+ ) -> DecisionMetric:
60
+ found = sorted(decisions, key=lambda decision: decision.id)
61
+ cited = cited_decisions(pages, found)
62
+ by_status: dict[str, list[Entity]] = {}
63
+ overdue, undated, superseded_cited, uncited = [], [], [], []
64
+ for decision in found:
65
+ status = str(decision.attributes.get("status") or "none")
66
+ by_status.setdefault(status, []).append(decision)
67
+ if status == "proposed":
68
+ written = written_on(decision)
69
+ if written is None:
70
+ undated.append(decision)
71
+ elif (today - written).days > proposed_days:
72
+ overdue.append(decision)
73
+ elif status in RETIRED and decision.id in cited:
74
+ superseded_cited.append(decision)
75
+ elif status == "accepted" and decision.id not in cited:
76
+ uncited.append(decision)
77
+ return DecisionMetric(by_status, overdue, undated, superseded_cited, uncited)
@@ -0,0 +1,47 @@
1
+ """Each metric's value per update, in the `metric_values` table, for the trend (design section 18.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from collections.abc import Mapping
7
+
8
+ from codetrail.metrics.report import Metric, Value
9
+
10
+ KNOWN = {str(metric) for metric in Metric}
11
+
12
+
13
+ def record_values(connection: sqlite3.Connection, snapshot_id: int, values: Mapping[Metric, Value]) -> None:
14
+ """Replaces the snapshot's values with these, in one transaction."""
15
+ connection.execute("BEGIN")
16
+ try:
17
+ connection.execute("DELETE FROM metric_values WHERE snapshot = ?", (snapshot_id,))
18
+ connection.executemany(
19
+ "INSERT INTO metric_values (snapshot, metric, numerator, denominator) VALUES (?, ?, ?, ?)",
20
+ [(snapshot_id, str(metric), value.numerator, value.denominator) for metric, value in values.items()],
21
+ )
22
+ connection.execute("COMMIT")
23
+ except BaseException:
24
+ if connection.in_transaction: # SQLite ends it itself after some errors
25
+ connection.execute("ROLLBACK")
26
+ raise
27
+
28
+
29
+ def recent_values(connection: sqlite3.Connection, metric: Metric, limit: int) -> list[tuple[int, Value]]:
30
+ """The metric's last `limit` values with their snapshots, oldest first."""
31
+ rows = connection.execute(
32
+ "SELECT snapshot, numerator, denominator FROM metric_values WHERE metric = ? ORDER BY snapshot DESC LIMIT ?",
33
+ (str(metric), limit),
34
+ ).fetchall()
35
+ return [(row["snapshot"], Value(row["numerator"], row["denominator"])) for row in reversed(rows)]
36
+
37
+
38
+ def values_before(connection: sqlite3.Connection, snapshot_id: int) -> dict[Metric, Value]:
39
+ """The values of the latest update before the snapshot that recorded any."""
40
+ rows = connection.execute(
41
+ "SELECT metric, numerator, denominator FROM metric_values"
42
+ " WHERE snapshot = (SELECT MAX(snapshot) FROM metric_values WHERE snapshot < ?)",
43
+ (snapshot_id,),
44
+ )
45
+ return {
46
+ Metric(row["metric"]): Value(row["numerator"], row["denominator"]) for row in rows if row["metric"] in KNOWN
47
+ }