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,209 @@
1
+ """The fact store: each fact version is one row, valid from `first_seen` to `last_seen` (design section 4.2; ADR 0003).
2
+
3
+ An update writes only what changed: new rows for new and changed facts, a closed range for removed ones, and fresh
4
+ sources in place for unchanged facts. The diff for a snapshot is a query on the ranges that open or close at it.
5
+ Table and column names come only from this module's constants; values are always parameters.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from collections.abc import Iterable, Sequence
13
+ from datetime import UTC, datetime
14
+ from typing import Any
15
+
16
+ from codetrail.facts import Entity, EntityKind, FactDiff, Relation, RelationKind, Snapshot, Source
17
+
18
+ ENTITY_KEY = ("id",)
19
+ RELATION_KEY = ("source_id", "kind", "target_id")
20
+
21
+
22
+ def _sources_json(sources: tuple[Source, ...]) -> str:
23
+ return json.dumps([[source.path, source.start_line, source.end_line] for source in sources])
24
+
25
+
26
+ def _sources(text: str) -> tuple[Source, ...]:
27
+ return tuple(Source(path, start, end) for path, start, end in json.loads(text))
28
+
29
+
30
+ class FactStore:
31
+ def __init__(self, connection: sqlite3.Connection) -> None:
32
+ self.connection = connection
33
+
34
+ def record(
35
+ self, commit: str, entities: Iterable[Entity], relations: Iterable[Relation]
36
+ ) -> tuple[Snapshot, FactDiff]:
37
+ """Records the facts found at `commit` as a new snapshot and returns it with its diff."""
38
+ connection = self.connection
39
+ connection.execute("BEGIN")
40
+ try:
41
+ taken_at = datetime.now(UTC).isoformat(timespec="seconds")
42
+ cursor = connection.execute(
43
+ "INSERT INTO snapshots (commit_sha, taken_at) VALUES (?, ?)", (commit, taken_at)
44
+ )
45
+ snapshot = Snapshot(int(cursor.lastrowid or 0), commit, taken_at)
46
+ previous = self.previous_snapshot(snapshot)
47
+ closing = previous.id if previous else None
48
+ entity_rows: dict[tuple[str, ...], tuple[str, dict[str, Any]]] = {
49
+ (entity.id,): (entity.hash, {"kind": str(entity.kind), "attributes": _json(entity.attributes),
50
+ "sources": _sources_json(entity.sources)})
51
+ for entity in entities
52
+ } # fmt: skip
53
+ relation_rows: dict[tuple[str, ...], tuple[str, dict[str, Any]]] = {
54
+ relation.key: (relation.hash, {"attributes": _json(relation.attributes),
55
+ "sources": _sources_json(relation.sources)})
56
+ for relation in relations
57
+ } # fmt: skip
58
+ self._write("entities", ENTITY_KEY, entity_rows, snapshot.id, closing)
59
+ self._write("relations", RELATION_KEY, relation_rows, snapshot.id, closing)
60
+ connection.execute("COMMIT")
61
+ except BaseException:
62
+ if connection.in_transaction: # SQLite ends it itself after some errors
63
+ connection.execute("ROLLBACK")
64
+ raise
65
+ return snapshot, self.diff(snapshot)
66
+
67
+ def _write(
68
+ self,
69
+ table: str,
70
+ key_columns: Sequence[str],
71
+ rows: dict[tuple[str, ...], tuple[str, dict[str, Any]]],
72
+ snapshot_id: int,
73
+ closing: int | None,
74
+ ) -> None:
75
+ keys = ", ".join(key_columns)
76
+ current = {
77
+ tuple(row[column] for column in key_columns): (row["rowid"], row["hash"])
78
+ for row in self.connection.execute(f"SELECT rowid, hash, {keys} FROM {table} WHERE last_seen IS NULL")
79
+ }
80
+ for key, (fact_hash, values) in rows.items():
81
+ existing = current.pop(key, None)
82
+ if existing is not None and existing[1] == fact_hash:
83
+ self.connection.execute(
84
+ f"UPDATE {table} SET sources = ? WHERE rowid = ?",
85
+ (values["sources"], existing[0]),
86
+ )
87
+ continue
88
+ if existing is not None:
89
+ self.connection.execute(f"UPDATE {table} SET last_seen = ? WHERE rowid = ?", (closing, existing[0]))
90
+ columns = (*key_columns, *values, "hash", "first_seen")
91
+ self.connection.execute(
92
+ f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({', '.join('?' for _ in columns)})",
93
+ (*key, *values.values(), fact_hash, snapshot_id),
94
+ )
95
+ for rowid, _hash in current.values(): # facts no longer found
96
+ self.connection.execute(f"UPDATE {table} SET last_seen = ? WHERE rowid = ?", (closing, rowid))
97
+
98
+ def latest_snapshot(self) -> Snapshot | None:
99
+ row = self.connection.execute("SELECT * FROM snapshots ORDER BY id DESC LIMIT 1").fetchone()
100
+ return Snapshot(row["id"], row["commit_sha"], row["taken_at"]) if row else None
101
+
102
+ def first_snapshot(self, commit: str) -> Snapshot | None:
103
+ """The earliest snapshot taken at the commit (a newer Codetrail can take more at the same one)."""
104
+ row = self.connection.execute(
105
+ "SELECT * FROM snapshots WHERE commit_sha = ? ORDER BY id LIMIT 1", (commit,)
106
+ ).fetchone()
107
+ return Snapshot(row["id"], row["commit_sha"], row["taken_at"]) if row else None
108
+
109
+ def previous_snapshot(self, snapshot: Snapshot) -> Snapshot | None:
110
+ row = self.connection.execute(
111
+ "SELECT * FROM snapshots WHERE id < ? ORDER BY id DESC LIMIT 1", (snapshot.id,)
112
+ ).fetchone()
113
+ return Snapshot(row["id"], row["commit_sha"], row["taken_at"]) if row else None
114
+
115
+ def diff(self, snapshot: Snapshot) -> FactDiff:
116
+ previous = self.previous_snapshot(snapshot)
117
+ closed_at = previous.id if previous else -1
118
+ entities = self._opened_and_closed("entities", ENTITY_KEY, snapshot.id, closed_at)
119
+ relations = self._opened_and_closed("relations", RELATION_KEY, snapshot.id, closed_at)
120
+ return FactDiff(
121
+ added_entities=sorted(key[0] for key in entities[0] - entities[1]),
122
+ changed_entities=sorted(key[0] for key in entities[0] & entities[1]),
123
+ removed_entities=sorted(key[0] for key in entities[1] - entities[0]),
124
+ added_relations=sorted(_relation_key(key) for key in relations[0] - relations[1]),
125
+ changed_relations=sorted(_relation_key(key) for key in relations[0] & relations[1]),
126
+ removed_relations=sorted(_relation_key(key) for key in relations[1] - relations[0]),
127
+ )
128
+
129
+ def _opened_and_closed(
130
+ self, table: str, key_columns: Sequence[str], opened_at: int, closed_at: int
131
+ ) -> tuple[set[tuple[str, ...]], set[tuple[str, ...]]]:
132
+ keys = ", ".join(key_columns)
133
+ opened = {tuple(row) for row in self.connection.execute(
134
+ f"SELECT {keys} FROM {table} WHERE first_seen = ?", (opened_at,))} # fmt: skip
135
+ closed = {tuple(row) for row in self.connection.execute(
136
+ f"SELECT {keys} FROM {table} WHERE last_seen = ?", (closed_at,))} # fmt: skip
137
+ return opened, closed
138
+
139
+ def snapshots(self) -> list[Snapshot]:
140
+ """Every snapshot, oldest first."""
141
+ rows = self.connection.execute("SELECT * FROM snapshots ORDER BY id")
142
+ return [Snapshot(row["id"], row["commit_sha"], row["taken_at"]) for row in rows]
143
+
144
+ def entity_counts(self, snapshot_ids: Sequence[int]) -> dict[int, dict[str, int]]:
145
+ """Each snapshot's entities by kind, from the validity ranges (design section 19.1)."""
146
+ counts: dict[int, dict[str, int]] = {snapshot_id: {} for snapshot_id in snapshot_ids}
147
+ marks = ", ".join("?" for _ in snapshot_ids)
148
+ rows = self.connection.execute(
149
+ "SELECT s.id AS snapshot, e.kind AS kind, COUNT(*) AS count FROM snapshots s JOIN entities e"
150
+ " ON e.first_seen <= s.id AND (e.last_seen IS NULL OR e.last_seen >= s.id)"
151
+ f" WHERE s.id IN ({marks}) GROUP BY s.id, e.kind", tuple(snapshot_ids)) # fmt: skip
152
+ for row in rows:
153
+ counts[row["snapshot"]][row["kind"]] = row["count"]
154
+ return counts
155
+
156
+ def entity_spans(self, kind: EntityKind) -> list[tuple[str, int, int | None]]:
157
+ """Each entity of the kind ever seen: its first snapshot, and its last unless it is still current."""
158
+ rows = self.connection.execute(
159
+ "SELECT id, MIN(first_seen) AS first, CASE WHEN SUM(last_seen IS NULL) > 0 THEN NULL"
160
+ " ELSE MAX(last_seen) END AS last FROM entities WHERE kind = ? GROUP BY id ORDER BY id",
161
+ (str(kind),),
162
+ )
163
+ return [(row["id"], row["first"], row["last"]) for row in rows]
164
+
165
+ def entities(self, kind: EntityKind | None = None) -> list[Entity]:
166
+ query = "SELECT * FROM entities WHERE last_seen IS NULL" + (" AND kind = ?" if kind else "") + " ORDER BY id"
167
+ return [_entity(row) for row in self.connection.execute(query, (str(kind),) if kind else ())]
168
+
169
+ def relations(self, kind: RelationKind | None = None) -> list[Relation]:
170
+ query = "SELECT * FROM relations WHERE last_seen IS NULL" + (" AND kind = ?" if kind else "")
171
+ query += " ORDER BY source_id, kind, target_id"
172
+ return [_relation(row) for row in self.connection.execute(query, (str(kind),) if kind else ())]
173
+
174
+ def entities_at(self, snapshot_id: int) -> list[Entity]:
175
+ """Every entity as it was at the snapshot."""
176
+ query = ("SELECT * FROM entities WHERE first_seen <= ? AND (last_seen IS NULL OR last_seen >= ?)"
177
+ " ORDER BY id") # fmt: skip
178
+ return [_entity(row) for row in self.connection.execute(query, (snapshot_id, snapshot_id))]
179
+
180
+ def relations_at(self, snapshot_id: int) -> list[Relation]:
181
+ """Every relation as it was at the snapshot."""
182
+ query = ("SELECT * FROM relations WHERE first_seen <= ? AND (last_seen IS NULL OR last_seen >= ?)"
183
+ " ORDER BY source_id, kind, target_id") # fmt: skip
184
+ return [_relation(row) for row in self.connection.execute(query, (snapshot_id, snapshot_id))]
185
+
186
+ def entity(self, entity_id: str) -> Entity | None:
187
+ row = self.connection.execute(
188
+ "SELECT * FROM entities WHERE id = ? AND last_seen IS NULL", (entity_id,)
189
+ ).fetchone()
190
+ return _entity(row) if row else None
191
+
192
+
193
+ def _json(attributes: Any) -> str:
194
+ return json.dumps(dict(attributes), sort_keys=True, ensure_ascii=False)
195
+
196
+
197
+ def _relation_key(key: tuple[str, ...]) -> tuple[str, str, str]:
198
+ return (key[0], key[1], key[2])
199
+
200
+
201
+ def _entity(row: sqlite3.Row) -> Entity:
202
+ return Entity(row["id"], EntityKind(row["kind"]), json.loads(row["attributes"]), _sources(row["sources"]))
203
+
204
+
205
+ def _relation(row: sqlite3.Row) -> Relation:
206
+ return Relation(
207
+ row["source_id"], RelationKind(row["kind"]), row["target_id"], json.loads(row["attributes"]),
208
+ _sources(row["sources"]),
209
+ ) # fmt: skip
@@ -0,0 +1 @@
1
+ """Generation: the outline, the pages, their validation and the digest (design section 6)."""
@@ -0,0 +1,31 @@
1
+ """Pages whose drafts failed validation, remembered so an update doesn't pay for them again unchanged (design 6.5).
2
+
3
+ A page is remembered with the hash of its scope when it failed; while the hash is the same, updates skip it. A
4
+ provider error isn't remembered, since it says nothing about the page.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sqlite3
10
+ from datetime import UTC, datetime
11
+
12
+
13
+ class FailedPages:
14
+ def __init__(self, connection: sqlite3.Connection) -> None:
15
+ self.connection = connection
16
+
17
+ def scope_hash(self, page_id: str) -> str | None:
18
+ """The scope hash the page failed with, or None when it hasn't failed."""
19
+ row = self.connection.execute("SELECT scope_hash FROM page_failures WHERE page_id = ?", (page_id,)).fetchone()
20
+ return str(row["scope_hash"]) if row else None
21
+
22
+ def record(self, page_id: str, scope_hash: str) -> None:
23
+ now = datetime.now(UTC).isoformat(timespec="seconds")
24
+ self.connection.execute(
25
+ "INSERT INTO page_failures (page_id, scope_hash, failed_at) VALUES (?, ?, ?) ON CONFLICT (page_id)"
26
+ " DO UPDATE SET scope_hash = excluded.scope_hash, failed_at = excluded.failed_at",
27
+ (page_id, scope_hash, now),
28
+ )
29
+
30
+ def forget(self, page_id: str) -> None:
31
+ self.connection.execute("DELETE FROM page_failures WHERE page_id = ?", (page_id,))
@@ -0,0 +1,159 @@
1
+ """The outline: every page's id, kind, title and scope (design section 6.2).
2
+
3
+ Claude proposes it; Codetrail keeps only entries it can check: a valid id for the kind, a scope that holds visible
4
+ files, and fact ids that exist. The founder may edit `outline.yaml`; Claude only adds to it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import unicodedata
10
+ from collections.abc import Iterable, Mapping, Sequence
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Any
13
+
14
+ from codetrail.facts import EntityKind
15
+ from codetrail.facts.store import FactStore
16
+ from codetrail.guide import PAGE_ID
17
+ from codetrail.repo.source import SourceManifest
18
+
19
+ KIND_FOLDERS = {"area": "areas", "concept": "concepts"}
20
+
21
+
22
+ CONTROL_CATEGORIES = ("Cc", "Cf", "Cs", "Zl", "Zp") # characters a terminal acts on, or that hide text
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class OutlineEntry:
27
+ id: str
28
+ kind: str
29
+ title: str
30
+ scope_paths: list[str]
31
+ scope_kinds: list[str] = field(default_factory=list)
32
+ facts: list[str] = field(default_factory=list)
33
+
34
+
35
+ def in_scope(path: str, scope_paths: Iterable[str]) -> bool:
36
+ for scope in scope_paths:
37
+ scope = scope.strip("/")
38
+ if not scope or path == scope or path.startswith(scope + "/"):
39
+ return True
40
+ return False
41
+
42
+
43
+ def validate_outline(
44
+ raw: Sequence[Mapping[str, Any]], store: FactStore, manifest: SourceManifest, existing: Sequence[OutlineEntry] = ()
45
+ ) -> tuple[list[OutlineEntry], list[str]]:
46
+ """The entries that pass, and a problem for each one dropped or trimmed."""
47
+ entries: list[OutlineEntry] = []
48
+ problems: list[str] = []
49
+ seen = {entry.id for entry in existing}
50
+ kinds = {str(kind) for kind in EntityKind}
51
+ for item in raw:
52
+ page_id = str(item.get("id", "")).strip().lower()
53
+ kind = str(item.get("kind", ""))
54
+ if (
55
+ kind not in KIND_FOLDERS
56
+ or not PAGE_ID.fullmatch(page_id)
57
+ or not page_id.startswith(KIND_FOLDERS[kind] + "/")
58
+ ):
59
+ problems.append(f"{page_id!r}: the id must be {KIND_FOLDERS.get(kind, 'areas or concepts')}/<slug>.")
60
+ continue
61
+ if page_id in seen:
62
+ problems.append(f"{page_id}: the id is already used.")
63
+ continue
64
+ scope = [str(path).strip("/") for path in item.get("scope_paths", []) if isinstance(path, str)]
65
+ scope = [path for path in scope if any(in_scope(file, [path]) for file in manifest.files)]
66
+ if not scope:
67
+ problems.append(f"{page_id}: none of its scope paths holds a visible file.")
68
+ continue
69
+ facts = [str(fact) for fact in item.get("facts", []) if isinstance(fact, str)]
70
+ known = [fact for fact in facts if store.entity(fact) is not None]
71
+ if len(known) != len(facts):
72
+ problems.append(f"{page_id}: dropped facts that don't exist: {sorted(set(facts) - set(known))}.")
73
+ scope_kinds = [str(kind) for kind in item.get("scope_kinds", []) if str(kind) in kinds]
74
+ title = str(item.get("title", page_id)).strip()
75
+ if any(unicodedata.category(character) in CONTROL_CATEGORIES for character in title):
76
+ problems.append(f"{page_id}: the title holds a control character.") # a terminal would act on it
77
+ continue
78
+ entries.append(OutlineEntry(page_id, kind, title, scope, scope_kinds, known))
79
+ seen.add(page_id)
80
+ return entries, problems
81
+
82
+
83
+ def outline_entries(data: Mapping[str, Any] | None) -> list[OutlineEntry]:
84
+ """The entries of a stored outline, skipping any that are malformed (the founder may have edited it)."""
85
+ entries = []
86
+ for item in (data or {}).get("pages", []) or []:
87
+ if isinstance(item, Mapping) and PAGE_ID.fullmatch(str(item.get("id", ""))):
88
+ entries.append(
89
+ OutlineEntry(
90
+ str(item["id"]),
91
+ str(item.get("kind", "")),
92
+ str(item.get("title", item["id"])),
93
+ [str(path) for path in item.get("scope_paths", []) or []],
94
+ [str(kind) for kind in item.get("scope_kinds", []) or []],
95
+ [str(fact) for fact in item.get("facts", []) or []],
96
+ )
97
+ )
98
+ return entries
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class OutlinePath:
103
+ id: str
104
+ title: str
105
+ goal: str
106
+ steps: list[str]
107
+
108
+
109
+ def outline_data(entries: Iterable[OutlineEntry], paths: Iterable[OutlinePath] = ()) -> dict[str, Any]:
110
+ return {"pages": [asdict(entry) for entry in entries], "paths": [asdict(path) for path in paths]}
111
+
112
+
113
+ def validate_paths(
114
+ raw: Sequence[Mapping[str, Any]], page_ids: set[str], existing: Sequence[OutlinePath] = ()
115
+ ) -> tuple[list[OutlinePath], list[str]]:
116
+ """Paths whose id is valid and that keep at least one step naming an existing page."""
117
+ paths: list[OutlinePath] = []
118
+ problems: list[str] = []
119
+ seen = {path.id for path in existing}
120
+ for item in raw:
121
+ path_id = str(item.get("id", "")).strip().lower()
122
+ if not path_id.startswith("paths/") or not PAGE_ID.fullmatch(path_id) or path_id in seen:
123
+ problems.append(f"{path_id!r}: a path id must be a new paths/<slug>.")
124
+ continue
125
+ steps = [str(step) for step in item.get("steps", []) if isinstance(step, str)]
126
+ kept = [step for step in steps if step in page_ids]
127
+ if len(kept) != len(steps):
128
+ problems.append(f"{path_id}: dropped steps that aren't pages: {sorted(set(steps) - set(kept))}.")
129
+ if not kept:
130
+ problems.append(f"{path_id}: no step names an existing page.")
131
+ continue
132
+ paths.append(OutlinePath(path_id, str(item.get("title", path_id)), str(item.get("goal", "")), kept))
133
+ seen.add(path_id)
134
+ return paths, problems
135
+
136
+
137
+ def outline_paths(data: Mapping[str, Any] | None, page_ids: set[str]) -> list[OutlinePath]:
138
+ """The stored paths, each keeping only steps that are still pages."""
139
+ paths = []
140
+ for item in (data or {}).get("paths", []) or []:
141
+ path_id = str(item.get("id", "")) if isinstance(item, Mapping) else ""
142
+ if isinstance(item, Mapping) and path_id.startswith("paths/") and PAGE_ID.fullmatch(path_id):
143
+ steps = [str(step) for step in item.get("steps", []) or [] if str(step) in page_ids]
144
+ if steps:
145
+ paths.append(OutlinePath(str(item["id"]), str(item.get("title", "")), str(item.get("goal", "")), steps))
146
+ return paths
147
+
148
+
149
+ def uncovered_facts(store: FactStore, entries: Sequence[OutlineEntry]) -> list[str]:
150
+ """Projects, modules and decisions that no page's scope covers."""
151
+ named = {fact for entry in entries for fact in entry.facts}
152
+ uncovered = []
153
+ for entity in store.entities():
154
+ if entity.kind is EntityKind.PACKAGE or entity.id in named:
155
+ continue
156
+ paths = [source.path for source in entity.sources]
157
+ if not any(in_scope(path, entry.scope_paths) for path in paths for entry in entries):
158
+ uncovered.append(entity.id)
159
+ return sorted(uncovered)
@@ -0,0 +1,72 @@
1
+ """Revising a page: the sections the assistant rewrote, put into the page in place of the old ones (design 6.3).
2
+
3
+ A section is a heading and everything under it up to the next heading of the same or a higher level, so replacing
4
+ `## How it works` replaces its `###` subsections too. Lines inside code fences are never headings. A section whose
5
+ heading the page doesn't have is added at the end; one with an empty body is removed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Mapping, Sequence
12
+
13
+ from codetrail.assistant.prompts import FULLWIDTH_AT
14
+
15
+ HEADING = re.compile(r"(#{1,6})\s+\S")
16
+ FENCE = re.compile(r"(```|~~~)")
17
+
18
+
19
+ def apply_sections(body: str, sections: Sequence[Mapping[str, object]]) -> str:
20
+ lines = body.rstrip("\n").split("\n")
21
+ for section in sections:
22
+ heading = str(section.get("heading", "")).strip().replace(FULLWIDTH_AT, "@") # the prompt showed it fullwidth
23
+ if not HEADING.match(heading):
24
+ continue
25
+ text = _without_heading(str(section.get("body", "")), heading)
26
+ new = [heading, "", *text.split("\n")] if text else []
27
+ start, end = _section(lines, heading)
28
+ if start is None:
29
+ if new:
30
+ lines += ["", *new]
31
+ continue
32
+ if new and end < len(lines) and lines[end].strip(): # keep a blank line before the next heading
33
+ new.append("")
34
+ lines[start:end] = new
35
+ if not new and 0 < start < len(lines) and not lines[start - 1].strip() and not lines[start].strip():
36
+ del lines[start] # the blank lines on both sides of a removed section become one
37
+ while lines and not lines[-1].strip():
38
+ lines.pop()
39
+ return "\n".join(lines) + "\n"
40
+
41
+
42
+ def _without_heading(text: str, heading: str) -> str:
43
+ """The section's text, without the heading line a model may repeat at its top."""
44
+ text = text.strip("\n")
45
+ first, _, rest = text.partition("\n")
46
+ return rest.strip("\n") if first.strip() == heading else text
47
+
48
+
49
+ def _section(lines: list[str], heading: str) -> tuple[int | None, int]:
50
+ """Where the section under the heading starts and ends (the line after it), outside code fences."""
51
+ start: int | None = None
52
+ level = 0
53
+ fenced = False
54
+ for index, line in enumerate(lines):
55
+ if FENCE.match(line.strip()):
56
+ fenced = not fenced
57
+ continue
58
+ match = None if fenced else HEADING.match(line)
59
+ if match is None:
60
+ continue
61
+ if start is not None and len(match.group(1)) <= level:
62
+ return start, _trim_blank(lines, start, index)
63
+ if start is None and line.strip() == heading:
64
+ start, level = index, len(match.group(1))
65
+ return start, len(lines)
66
+
67
+
68
+ def _trim_blank(lines: list[str], start: int, end: int) -> int:
69
+ """The section's end, before the blank lines that separate it from the next heading."""
70
+ while end > start + 1 and not lines[end - 1].strip():
71
+ end -= 1
72
+ return end