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,65 @@
1
+ """The "you're behind" signal: what landed since the last update, from git alone (design section 8.5).
2
+
3
+ It fetches into the mirror without rebuilding `source/`, under the target's lock taken without waiting: while an
4
+ update runs, the signal says so instead. Areas are the top-level folders of visible changed paths; excluded paths
5
+ are never counted.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from codetrail.config import Paths, check_containment, load_target
13
+ from codetrail.database import connect
14
+ from codetrail.facts.store import FactStore
15
+ from codetrail.lock import TargetBusy, target_lock
16
+ from codetrail.repo.history import changed_paths, commit_count, merges_between
17
+ from codetrail.repo.mirror import read_file_at, refresh_mirror
18
+ from codetrail.repo.refresh import TARGET_IGNORE_FILE, ignore_lines
19
+ from codetrail.repo.rules import ExclusionRules
20
+ from codetrail.repo.source import SourceManifest
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Signal:
25
+ updated_commit: str | None = None
26
+ head: str | None = None
27
+ merges: int = 0
28
+ commits: int = 0
29
+ areas: list[str] = field(default_factory=list)
30
+ updating: bool = False
31
+
32
+
33
+ def behind(paths: Paths, name: str) -> Signal:
34
+ target = load_target(paths, name)
35
+ check_containment(paths, target.repository)
36
+ data = paths.target_data(name)
37
+ try:
38
+ with target_lock(paths, name):
39
+ mirror = data / "mirror.git"
40
+ head = refresh_mirror(mirror, target.repository, target.branch)
41
+ snapshot = _latest_snapshot_commit(paths, name)
42
+ if snapshot is None:
43
+ return Signal(head=head)
44
+ rules = ExclusionRules(ignore_lines(paths, name, read_file_at(mirror, head, TARGET_IGNORE_FILE)))
45
+ manifest = SourceManifest.load(data / "source.json")
46
+ flagged = manifest.excluded_paths() if manifest else set()
47
+ visible = [path for path in changed_paths(mirror, snapshot, head)
48
+ if rules.reason(path) is None and path not in flagged] # fmt: skip
49
+ areas = sorted({path.split("/", 1)[0] for path in visible if "/" in path})
50
+ return Signal(snapshot, head, merges_between(mirror, snapshot, head), commit_count(mirror, snapshot, head),
51
+ areas) # fmt: skip
52
+ except TargetBusy:
53
+ return Signal(updating=True)
54
+
55
+
56
+ def _latest_snapshot_commit(paths: Paths, name: str) -> str | None:
57
+ database = paths.target_data(name) / "codetrail.db"
58
+ if not database.exists():
59
+ return None
60
+ connection = connect(database)
61
+ try:
62
+ snapshot = FactStore(connection).latest_snapshot()
63
+ finally:
64
+ connection.close()
65
+ return snapshot.commit if snapshot else None
@@ -0,0 +1,166 @@
1
+ """The materialized sources: only the files Codetrail may see, at one commit (design sections 3.2 to 3.4).
2
+
3
+ `source/` is rebuilt in full on every refresh: written into `source.next/`, scanned with gitleaks, cleared of what it
4
+ flags, then swapped into place. If anything fails, `source.next/` is removed and `source/` stays as it was.
5
+ Symlinks, submodules and unsafe paths are never written.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import shutil
13
+ from collections.abc import Callable, Mapping
14
+ from dataclasses import asdict, dataclass, field
15
+ from pathlib import Path, PurePosixPath
16
+
17
+ from codetrail.errors import CodetrailError
18
+ from codetrail.repo.mirror import list_tree, read_blobs
19
+ from codetrail.repo.rules import ExclusionRules, Reason, on_disk_key
20
+ from codetrail.repo.secrets import SecretScanner
21
+
22
+ REGULAR_FILE_MODES = {"100644", "100755"}
23
+ # A full SHA-1 or SHA-256 object name, the only commit a manifest may name: never an option, a ref or a range.
24
+ FULL_COMMIT = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Excluded:
29
+ path: str
30
+ reason: Reason
31
+ rule: str | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class SourceManifest:
36
+ commit: str
37
+ files: dict[str, str] # path -> blob
38
+ excluded: list[Excluded] = field(default_factory=list)
39
+
40
+ def save(self, path: Path) -> None:
41
+ path.write_text(json.dumps(asdict(self), indent=1, sort_keys=True), encoding="utf-8")
42
+
43
+ @classmethod
44
+ def load(cls, path: Path) -> SourceManifest | None:
45
+ if not path.exists():
46
+ return None
47
+ data = json.loads(path.read_text(encoding="utf-8"))
48
+ if not isinstance(data["commit"], str) or not FULL_COMMIT.fullmatch(data["commit"]):
49
+ raise CodetrailError(f"{path} names no valid commit; run codetrail update again.")
50
+ excluded = [Excluded(item["path"], Reason(item["reason"]), item["rule"]) for item in data["excluded"]]
51
+ return cls(commit=data["commit"], files=data["files"], excluded=excluded)
52
+
53
+ def excluded_paths(self) -> set[str]:
54
+ return {item.path for item in self.excluded}
55
+
56
+
57
+ def safe_path(path: str) -> bool:
58
+ """A relative path with no empty, `.`, `..` or `.git` parts (in any case), which stays inside its folder."""
59
+ if not path or path.startswith("/") or "\\" in path:
60
+ return False
61
+ if any("\udc80" <= character <= "\udcff" for character in path): # bytes that aren't UTF-8 (surrogateescape)
62
+ return False
63
+ parts = path.split("/")
64
+ return all(part and part not in (".", "..") and on_disk_key(part) != ".git" for part in parts)
65
+
66
+
67
+ def allowed_reader(source: Path, files: Mapping[str, str], max_bytes: int) -> Callable[[str], bytes | None]:
68
+ """A reader of allowed files only: a listed path, inside `source/`, a plain file within the size cap.
69
+
70
+ The system pass (design 17.3) and the documentation metrics (design 18.2) read files only through it.
71
+ """
72
+ root = source.resolve()
73
+
74
+ def read(path: str) -> bytes | None:
75
+ if path not in files:
76
+ return None
77
+ file = source / PurePosixPath(path)
78
+ try:
79
+ if file.is_symlink() or not file.resolve().is_relative_to(root) or not file.is_file():
80
+ return None
81
+ return file.read_bytes() if file.stat().st_size <= max_bytes else None
82
+ except OSError:
83
+ return None
84
+
85
+ return read
86
+
87
+
88
+ def build_source(
89
+ mirror: Path, commit: str, rules: ExclusionRules, scanner: SecretScanner, data_dir: Path
90
+ ) -> SourceManifest:
91
+ source, next_source = data_dir / "source", data_dir / "source.next"
92
+ shutil.rmtree(next_source, ignore_errors=True)
93
+ next_source.mkdir(parents=True)
94
+ try:
95
+ files: dict[str, str] = {}
96
+ excluded: list[Excluded] = []
97
+ entries = list_tree(mirror, commit)
98
+ colliding = _colliding_paths([entry.path for entry in entries])
99
+ for entry in entries:
100
+ reason = Reason.UNSAFE_PATH if entry.path in colliding else _reason(entry.mode, entry.path, rules)
101
+ if reason is None:
102
+ files[entry.path] = entry.blob
103
+ else:
104
+ excluded.append(Excluded(entry.path, reason))
105
+ paths_by_blob: dict[str, list[str]] = {}
106
+ for path, blob in files.items():
107
+ paths_by_blob.setdefault(blob, []).append(path)
108
+ for blob, content in read_blobs(mirror, list(paths_by_blob)):
109
+ for path in paths_by_blob[blob]:
110
+ destination = next_source / PurePosixPath(path)
111
+ destination.parent.mkdir(parents=True, exist_ok=True)
112
+ destination.write_bytes(content)
113
+ flagged: dict[str, str] = {}
114
+ for finding in scanner.scan_directory(next_source):
115
+ flagged.setdefault(finding.path, finding.rule)
116
+ for path, rule in sorted(flagged.items()):
117
+ (next_source / PurePosixPath(path)).unlink()
118
+ del files[path]
119
+ excluded.append(Excluded(path, Reason.GITLEAKS, rule))
120
+ manifest = SourceManifest(
121
+ commit=commit, files=dict(sorted(files.items())), excluded=sorted(excluded, key=lambda item: item.path)
122
+ )
123
+ _swap(next_source, source)
124
+ manifest.save(data_dir / "source.json")
125
+ return manifest
126
+ except BaseException:
127
+ shutil.rmtree(next_source, ignore_errors=True)
128
+ raise
129
+
130
+
131
+ def _colliding_paths(paths: list[str]) -> set[str]:
132
+ """Paths that would share a file or folder on a case-insensitive, normalizing file system.
133
+
134
+ Each path and each of its parent folders is a name on disk; two different spellings of one name collide, and
135
+ every path under either spelling is excluded (A.txt with a.txt, Docs/a.md with docs/b.md, Notes with notes/x).
136
+ """
137
+ spellings: dict[str, set[str]] = {}
138
+ for path in paths:
139
+ parts = path.split("/")
140
+ for end in range(1, len(parts) + 1):
141
+ prefix = "/".join(parts[:end])
142
+ spellings.setdefault(on_disk_key(prefix), set()).add(prefix)
143
+ clashing = {key for key, names in spellings.items() if len(names) > 1}
144
+ colliding = set()
145
+ for path in paths:
146
+ parts = path.split("/")
147
+ if any(on_disk_key("/".join(parts[:end])) in clashing for end in range(1, len(parts) + 1)):
148
+ colliding.add(path)
149
+ return colliding
150
+
151
+
152
+ def _reason(mode: str, path: str, rules: ExclusionRules) -> Reason | None:
153
+ if not safe_path(path):
154
+ return Reason.UNSAFE_PATH
155
+ if mode not in REGULAR_FILE_MODES:
156
+ return Reason.NOT_A_FILE
157
+ return rules.reason(path)
158
+
159
+
160
+ def _swap(next_source: Path, source: Path) -> None:
161
+ old = source.with_name("source.old")
162
+ shutil.rmtree(old, ignore_errors=True)
163
+ if source.exists():
164
+ source.rename(old)
165
+ next_source.rename(source)
166
+ shutil.rmtree(old, ignore_errors=True)
codetrail/search.py ADDED
@@ -0,0 +1,178 @@
1
+ """Search over the guide: an in-memory SQLite FTS5 index of pages, saved answers, decisions and facts (design 16.3).
2
+
3
+ Only what the reader can already see in the guide is indexed: each page's title and body (never its front matter,
4
+ which holds the checks' rubrics), and the names and kinds of facts. Source files are never read. Queries never reach
5
+ FTS5's query language: every word is quoted as a literal, the last one matches as a prefix, and the expression is
6
+ always a bound parameter. Snippets are plain text, split into segments that say whether they matched.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import sqlite3
13
+ import threading
14
+ import unicodedata
15
+ from collections.abc import Iterable
16
+ from dataclasses import dataclass
17
+ from urllib.parse import quote
18
+
19
+ from markdown_it import MarkdownIt
20
+
21
+ from codetrail.facts import EntityKind
22
+ from codetrail.facts.store import FactStore
23
+ from codetrail.generate.validate import DIAGRAM, FACT_LINK, MARKER
24
+ from codetrail.guide import GuideRepository
25
+
26
+ WORD = re.compile(r"\w+")
27
+ SNIPPET_CHARS = 160
28
+ PARSER = MarkdownIt("commonmark").enable("table")
29
+ # Title, headings and text, weighted for bm25; the other columns are stored, not searched.
30
+ SCHEMA = (
31
+ "CREATE VIRTUAL TABLE documents USING fts5(title, headings, text, id UNINDEXED, kind UNINDEXED, "
32
+ "link UNINDEXED, tokenize = 'unicode61 remove_diacritics 2')"
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Document:
38
+ id: str # a page id, "facts/<fact id>" or "decisions/<fact id>"
39
+ kind: str # "area", "concept", "path", "digest", "answer", "decision" or "fact"
40
+ title: str
41
+ headings: str
42
+ text: str
43
+ link: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Result:
48
+ document: Document
49
+ snippet: list[tuple[str, bool]] # plain-text segments, and whether each one matched
50
+
51
+
52
+ def match_expression(query: str, max_chars: int) -> str | None:
53
+ """The query as an FTS5 expression of quoted words, the last a prefix; None when no word is left."""
54
+ words = [word for word in query[:max_chars].split() if WORD.search(word)]
55
+ if not words:
56
+ return None
57
+ quoted = ['"' + word.replace('"', '""') + '"' for word in words]
58
+ return " ".join(quoted) + "*"
59
+
60
+
61
+ def _fold(text: str) -> str:
62
+ """Lower case without accents, the way the index's tokenizer compares words."""
63
+ return "".join(char for char in unicodedata.normalize("NFKD", text.casefold()) if not unicodedata.combining(char))
64
+
65
+
66
+ def _snippet(text: str, query: str, max_chars: int) -> list[tuple[str, bool]]:
67
+ terms = [_fold(word) for word in WORD.findall(query[:max_chars])]
68
+ if not text or not terms:
69
+ return [(text[:SNIPPET_CHARS], False)] if text else []
70
+
71
+ def matches(word: str) -> bool:
72
+ folded = _fold(word)
73
+ return folded in terms[:-1] or folded.startswith(terms[-1])
74
+
75
+ hits = [match for match in WORD.finditer(text) if matches(match.group())]
76
+ start = max(0, hits[0].start() - SNIPPET_CHARS // 3) if hits else 0
77
+ if start > 0: # begin at a word, not inside one
78
+ space = text.find(" ", start)
79
+ start = space + 1 if 0 <= space < (hits[0].start() if hits else len(text)) else start
80
+ end = min(len(text), start + SNIPPET_CHARS)
81
+ if end < len(text):
82
+ space = text.rfind(" ", start, end)
83
+ end = space if space > (hits[0].end() if hits else start) else end
84
+ segments: list[tuple[str, bool]] = [("…", False)] if start > 0 else []
85
+ position = start
86
+ for hit in hits:
87
+ if hit.start() < start or hit.end() > end:
88
+ continue
89
+ if hit.start() > position:
90
+ segments.append((text[position : hit.start()], False))
91
+ segments.append((hit.group(), True))
92
+ position = hit.end()
93
+ if position < end:
94
+ segments.append((text[position:end], False))
95
+ if end < len(text):
96
+ segments.append(("…", False))
97
+ return segments
98
+
99
+
100
+ class SearchIndex:
101
+ """One in-memory index, shared by the page's requests; every use holds the lock."""
102
+
103
+ def __init__(self) -> None:
104
+ self._connection = sqlite3.connect(":memory:", check_same_thread=False)
105
+ self._connection.execute(SCHEMA)
106
+ self._lock = threading.Lock()
107
+
108
+ def rebuild(self, documents: Iterable[Document]) -> None:
109
+ rows = [_row(document) for document in documents]
110
+ with self._lock, self._connection:
111
+ self._connection.execute("DELETE FROM documents")
112
+ self._connection.executemany("INSERT INTO documents VALUES (?, ?, ?, ?, ?, ?)", rows)
113
+
114
+ def add(self, document: Document) -> None:
115
+ with self._lock, self._connection:
116
+ self._connection.execute("DELETE FROM documents WHERE id = ?", (document.id,))
117
+ self._connection.execute("INSERT INTO documents VALUES (?, ?, ?, ?, ?, ?)", _row(document))
118
+
119
+ def search(self, query: str, max_chars: int, limit: int) -> list[Result]:
120
+ expression = match_expression(query, max_chars)
121
+ if expression is None:
122
+ return []
123
+ with self._lock:
124
+ try:
125
+ rows = self._connection.execute(
126
+ "SELECT title, headings, text, id, kind, link FROM documents WHERE documents MATCH ?"
127
+ " ORDER BY bm25(documents, 10.0, 4.0, 1.0) LIMIT ?",
128
+ (expression, limit),
129
+ ).fetchall()
130
+ except sqlite3.OperationalError: # every word is quoted, so this is FTS5 refusing an odd token
131
+ return []
132
+ results = []
133
+ for title, headings, text, identifier, kind, link in rows:
134
+ document = Document(identifier, kind, title, headings, text, link)
135
+ results.append(Result(document, _snippet(text, query, max_chars)))
136
+ return results
137
+
138
+
139
+ def _row(document: Document) -> tuple[str, str, str, str, str, str]:
140
+ return (document.title, document.headings, document.text, document.id, document.kind, document.link)
141
+
142
+
143
+ def _plain(body: str) -> tuple[str, str]:
144
+ """A page body's headings and its text, without Markdown, rationale markers or diagram placeholders."""
145
+ lines = [line for line in body.splitlines() if not MARKER.match(line) and not DIAGRAM.match(line.strip())]
146
+ source = FACT_LINK.sub(lambda match: match.group(1), "\n".join(lines))
147
+ headings: list[str] = []
148
+ pieces: list[str] = []
149
+ in_heading = False
150
+ for token in PARSER.parse(source):
151
+ if token.type == "heading_open":
152
+ in_heading = True
153
+ elif token.type == "heading_close":
154
+ in_heading = False
155
+ elif token.type == "inline":
156
+ words = "".join(child.content for child in token.children or [] if child.type in ("text", "code_inline"))
157
+ (headings if in_heading else pieces).append(words)
158
+ elif token.type in ("fence", "code_block"):
159
+ pieces.append(token.content.strip())
160
+ return "\n".join(headings), " ".join(piece for piece in pieces if piece)
161
+
162
+
163
+ def guide_documents(guide: GuideRepository, store: FactStore | None) -> list[Document]:
164
+ """Everything search covers: the guide's pages (title and body only), decisions, and facts by name and kind."""
165
+ documents = []
166
+ for page in guide.pages() if guide.root.exists() else []:
167
+ headings, text = _plain(page.body)
168
+ documents.append(Document(page.id, page.kind, page.title, headings, text, f"/pages/{page.id}"))
169
+ for entity in store.entities() if store is not None else []:
170
+ link = f"/facts/{quote(entity.id, safe='')}"
171
+ if entity.kind == EntityKind.DECISION:
172
+ title = f"{entity.attributes.get('number', '')}. {entity.attributes.get('title', '')}".strip(". ")
173
+ text = str(entity.attributes.get("status", ""))
174
+ documents.append(Document(f"decisions/{entity.id}", "decision", title, "", text, link))
175
+ else:
176
+ text = f"{entity.kind} {entity.attributes.get('name', '')}".strip()
177
+ documents.append(Document(f"facts/{entity.id}", "fact", entity.id, "", text, link))
178
+ return documents
codetrail/server.py ADDED
@@ -0,0 +1,45 @@
1
+ """`codetrail serve`: the page on 127.0.0.1 only, opened through a single-use sign-in link (design section 7.4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import webbrowser
6
+ from dataclasses import dataclass
7
+
8
+ import uvicorn
9
+ from starlette.types import ASGIApp
10
+
11
+ from codetrail.config import Paths, check_containment, load_global, load_target
12
+ from codetrail.lock import target_in_use
13
+ from codetrail.web.app import create_app
14
+ from codetrail.web.security import SessionState
15
+
16
+ HOST = "127.0.0.1" # never configurable
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Server:
21
+ app: ASGIApp
22
+ host: str
23
+ port: int
24
+ url: str
25
+
26
+
27
+ def prepare_server(paths: Paths, name: str) -> Server:
28
+ target = load_target(paths, name)
29
+ check_containment(paths, target.repository)
30
+ settings = load_global(paths)
31
+ session = SessionState(settings.server.login_code_ttl_seconds, session_minutes=settings.server.session_minutes)
32
+ app = create_app(paths, name, session, settings)
33
+ port = settings.server.port
34
+ return Server(app, HOST, port, f"http://{HOST}:{port}/login?code={session.issue_login_code()}")
35
+
36
+
37
+ def serve(paths: Paths, name: str, open_browser: bool) -> None:
38
+ with target_in_use(paths, name): # for as long as it serves, so the target isn't removed under it
39
+ server = prepare_server(paths, name)
40
+ print(f"Codetrail is serving {name} at http://{server.host}:{server.port}/", flush=True)
41
+ print(f"Sign in (this link works once, for a short time): {server.url}", flush=True)
42
+ if open_browser:
43
+ webbrowser.open(server.url)
44
+ # No access log: the sign-in link's code would be written to it.
45
+ uvicorn.run(server.app, host=server.host, port=server.port, log_level="warning", access_log=False)