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/facts/store.py
ADDED
|
@@ -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
|