codetrail-cli 1.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
codetrail/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
|
+
}
|