documentation-engine 0.1.2__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.
- docsystem/__init__.py +22 -0
- docsystem/__main__.py +4 -0
- docsystem/catalog.py +665 -0
- docsystem/cli.py +2573 -0
- docsystem/config.py +216 -0
- docsystem/mcp_server.py +324 -0
- docsystem/metadata.py +307 -0
- docsystem/migration.py +309 -0
- docsystem/projection.py +609 -0
- docsystem/readiness.py +132 -0
- docsystem/sections.py +252 -0
- documentation_engine-0.1.2.dist-info/METADATA +417 -0
- documentation_engine-0.1.2.dist-info/RECORD +16 -0
- documentation_engine-0.1.2.dist-info/WHEEL +4 -0
- documentation_engine-0.1.2.dist-info/entry_points.txt +3 -0
- documentation_engine-0.1.2.dist-info/licenses/LICENSE +21 -0
docsystem/projection.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""Deterministic sharded projection derived exclusively from Markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from docsystem.catalog import (
|
|
14
|
+
MarkdownCatalog,
|
|
15
|
+
build_dependency_graph,
|
|
16
|
+
included_source_paths,
|
|
17
|
+
)
|
|
18
|
+
from docsystem.config import ProjectConfig
|
|
19
|
+
|
|
20
|
+
# Version 2 adds per-document `migrations` and `related_values` shard fields
|
|
21
|
+
# so read commands can serve context packets from shards alone.
|
|
22
|
+
SCHEMA_VERSION = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class DocumentChange:
|
|
27
|
+
"""One document-level change between a projection and current Markdown."""
|
|
28
|
+
|
|
29
|
+
document_id: str
|
|
30
|
+
kind: str
|
|
31
|
+
sections: tuple[str, ...] = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ChangesReport:
|
|
36
|
+
"""A deterministic snapshot of changes since the selected projection.
|
|
37
|
+
|
|
38
|
+
`status` is `"absent"` when no projection has ever been written,
|
|
39
|
+
`"unavailable"` when the selected generation cannot be read, or
|
|
40
|
+
`"compared"` when `changes` reflects a real comparison (possibly empty).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
status: str
|
|
44
|
+
changes: tuple[DocumentChange, ...] = field(default_factory=tuple)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sha(text: str) -> str:
|
|
48
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _json(value: object) -> str:
|
|
52
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cache_root(config: ProjectConfig) -> Path:
|
|
56
|
+
return config.project_root / ".docsystem" / "cache"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def config_fingerprint(config: ProjectConfig) -> str:
|
|
60
|
+
"""Return a deterministic fingerprint of projection-relevant config.
|
|
61
|
+
|
|
62
|
+
A projection generation is only valid while the configuration that shaped
|
|
63
|
+
it is unchanged, so this fingerprint is folded into the generation hash and
|
|
64
|
+
recorded in the manifest. It covers every normalized field that affects
|
|
65
|
+
catalog membership, metadata parsing/validation, section and navigation
|
|
66
|
+
policy, dependency-graph semantics, or projection layout: the documentation
|
|
67
|
+
root identity relative to the project, area and identifier maps, catalog
|
|
68
|
+
exclusions, `navigation.extend_through`, `relations.legacy_paths`,
|
|
69
|
+
`relations.snapshot_types`, the projection format, and the schema version.
|
|
70
|
+
When any of these change the generation identity changes too, so
|
|
71
|
+
`load_verified_projection` reports the generation stale and reads fall back
|
|
72
|
+
to direct Markdown and normal validation instead of serving output that no
|
|
73
|
+
longer matches the active configuration.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
documentation_root = config.documentation_root.relative_to(
|
|
78
|
+
config.project_root
|
|
79
|
+
).as_posix()
|
|
80
|
+
except ValueError:
|
|
81
|
+
documentation_root = config.documentation_root.as_posix()
|
|
82
|
+
normalized = {
|
|
83
|
+
"schema_version": SCHEMA_VERSION,
|
|
84
|
+
"projection_format": config.projection_format,
|
|
85
|
+
"documentation_root": documentation_root,
|
|
86
|
+
"areas": {role: path.as_posix() for role, path in config.areas.items()},
|
|
87
|
+
"identifiers": dict(config.identifiers),
|
|
88
|
+
"catalog_exclusions": list(config.catalog_exclusions),
|
|
89
|
+
"navigation_extend_through": list(config.navigation_extend_through),
|
|
90
|
+
"legacy_relation_mode": config.legacy_relation_mode,
|
|
91
|
+
"snapshot_document_types": list(config.snapshot_document_types),
|
|
92
|
+
}
|
|
93
|
+
return _sha(_json(normalized))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _shard(kind: str, document_id: str) -> Path:
|
|
97
|
+
namespace, number = document_id.split("-", 1)
|
|
98
|
+
bucket = f"{(int(number) // 100) * 100:06d}"
|
|
99
|
+
return Path(kind) / namespace / bucket / f"{document_id}.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_projection(
|
|
103
|
+
catalog: MarkdownCatalog, config: ProjectConfig
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
graph = build_dependency_graph(catalog)
|
|
106
|
+
migrations_by_source: dict[str, list[dict[str, str]]] = {}
|
|
107
|
+
for item in catalog.relation_migrations:
|
|
108
|
+
migrations_by_source.setdefault(item.source_id, []).append(
|
|
109
|
+
{
|
|
110
|
+
"relation": item.relation,
|
|
111
|
+
"value": item.value,
|
|
112
|
+
"target": item.target_id,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
documents: dict[str, Any] = {}
|
|
116
|
+
for document in catalog.documents:
|
|
117
|
+
if document.metadata is None:
|
|
118
|
+
continue
|
|
119
|
+
lines = document.content.splitlines()
|
|
120
|
+
related_values = [
|
|
121
|
+
value
|
|
122
|
+
for relation, value in document.metadata.legacy_references
|
|
123
|
+
if relation == "related"
|
|
124
|
+
]
|
|
125
|
+
related_values.extend(
|
|
126
|
+
reference.target_id
|
|
127
|
+
for reference in document.metadata.references
|
|
128
|
+
if reference.relation == "related"
|
|
129
|
+
)
|
|
130
|
+
documents[document.metadata.document_id] = {
|
|
131
|
+
"path": document.path.as_posix(),
|
|
132
|
+
"revision": document.metadata.revision,
|
|
133
|
+
"type": document.metadata.document_type,
|
|
134
|
+
"status": document.metadata.status,
|
|
135
|
+
"source_sha256": _sha(document.content),
|
|
136
|
+
"sections": {
|
|
137
|
+
section.anchor: {
|
|
138
|
+
"title": section.title,
|
|
139
|
+
"level": section.level,
|
|
140
|
+
"start_line": section.start_line,
|
|
141
|
+
"end_line": section.end_line,
|
|
142
|
+
"sha256": _sha(
|
|
143
|
+
"\n".join(lines[section.start_line - 1 : section.end_line])
|
|
144
|
+
),
|
|
145
|
+
}
|
|
146
|
+
for section in document.sections
|
|
147
|
+
},
|
|
148
|
+
"dependencies": [
|
|
149
|
+
{
|
|
150
|
+
"relation": edge.relation,
|
|
151
|
+
"target": edge.target_id,
|
|
152
|
+
"expected_revision": edge.expected_revision,
|
|
153
|
+
}
|
|
154
|
+
for edge in graph.outgoing(document.metadata.document_id)
|
|
155
|
+
],
|
|
156
|
+
"boundaries": [
|
|
157
|
+
{
|
|
158
|
+
"relation": item.relation,
|
|
159
|
+
"value": item.value,
|
|
160
|
+
"reason": item.reason,
|
|
161
|
+
}
|
|
162
|
+
for item in catalog.relation_boundaries
|
|
163
|
+
if item.source_id == document.metadata.document_id
|
|
164
|
+
],
|
|
165
|
+
"migrations": migrations_by_source.get(
|
|
166
|
+
document.metadata.document_id, []
|
|
167
|
+
),
|
|
168
|
+
"related_values": related_values,
|
|
169
|
+
}
|
|
170
|
+
reverse = {
|
|
171
|
+
document_id: [
|
|
172
|
+
{
|
|
173
|
+
"source": edge.source_id,
|
|
174
|
+
"relation": edge.relation,
|
|
175
|
+
"expected_revision": edge.expected_revision,
|
|
176
|
+
}
|
|
177
|
+
for edge in graph.incoming(document_id)
|
|
178
|
+
]
|
|
179
|
+
for document_id in documents
|
|
180
|
+
}
|
|
181
|
+
payload = {
|
|
182
|
+
"schema_version": SCHEMA_VERSION,
|
|
183
|
+
"config_fingerprint": config_fingerprint(config),
|
|
184
|
+
"documents": dict(sorted(documents.items())),
|
|
185
|
+
"reverse": {key: value for key, value in sorted(reverse.items()) if value},
|
|
186
|
+
}
|
|
187
|
+
payload["generation"] = _sha(_json(payload))
|
|
188
|
+
return payload
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _read(path: Path) -> dict[str, Any]:
|
|
192
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
193
|
+
if not isinstance(value, dict):
|
|
194
|
+
raise ValueError(f"projection JSON is not an object: {path}")
|
|
195
|
+
return value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def projection_status(
|
|
199
|
+
config: ProjectConfig, current: dict[str, Any]
|
|
200
|
+
) -> tuple[bool, str]:
|
|
201
|
+
pointer = cache_root(config) / "current.json"
|
|
202
|
+
if not pointer.is_file():
|
|
203
|
+
return False, "projection absent"
|
|
204
|
+
try:
|
|
205
|
+
selected = _read(pointer)
|
|
206
|
+
if selected.get("schema_version") != SCHEMA_VERSION:
|
|
207
|
+
return False, "projection schema incompatible"
|
|
208
|
+
generation = selected.get("generation")
|
|
209
|
+
manifest = _read(
|
|
210
|
+
cache_root(config) / "generations" / str(generation) / "manifest.json"
|
|
211
|
+
)
|
|
212
|
+
if generation != manifest.get("generation"):
|
|
213
|
+
return False, "projection pointer mismatch"
|
|
214
|
+
if generation != current.get("generation"):
|
|
215
|
+
return False, "projection stale"
|
|
216
|
+
generation_dir = cache_root(config) / "generations" / str(generation)
|
|
217
|
+
for document_id in current["documents"]:
|
|
218
|
+
shard = _read(generation_dir / _shard("documents", document_id))
|
|
219
|
+
if (
|
|
220
|
+
shard.get("schema_version") != SCHEMA_VERSION
|
|
221
|
+
or shard.get("id") != document_id
|
|
222
|
+
):
|
|
223
|
+
return False, f"projection document shard invalid: {document_id}"
|
|
224
|
+
for document_id in current["reverse"]:
|
|
225
|
+
shard = _read(generation_dir / _shard("reverse", document_id))
|
|
226
|
+
if (
|
|
227
|
+
shard.get("schema_version") != SCHEMA_VERSION
|
|
228
|
+
or shard.get("id") != document_id
|
|
229
|
+
):
|
|
230
|
+
return False, f"projection reverse shard invalid: {document_id}"
|
|
231
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
232
|
+
return False, f"projection unreadable: {error}"
|
|
233
|
+
return True, "projection current"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class LoadedProjection:
|
|
238
|
+
"""A hash-verified projection generation ready to serve read commands."""
|
|
239
|
+
|
|
240
|
+
generation: str
|
|
241
|
+
documents: dict[str, dict[str, Any]]
|
|
242
|
+
reverse: dict[str, tuple[dict[str, Any], ...]]
|
|
243
|
+
contents: dict[str, str]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def load_verified_projection(
|
|
247
|
+
config: ProjectConfig,
|
|
248
|
+
) -> tuple[LoadedProjection | None, str]:
|
|
249
|
+
"""Verify the selected generation against current sources and load it.
|
|
250
|
+
|
|
251
|
+
Verification re-reads every included source file and compares its sha256
|
|
252
|
+
with the generation manifest, so a served read can never disagree with
|
|
253
|
+
the Markdown truth; what the fast path removes is Markdown, metadata and
|
|
254
|
+
link parsing plus graph reconstruction, not source I/O. It also rejects
|
|
255
|
+
the generation when the active configuration fingerprint no longer
|
|
256
|
+
matches the one recorded at build time, so a read-time policy change
|
|
257
|
+
(for example `relations.legacy_paths` or `navigation.extend_through`)
|
|
258
|
+
forces a rebuild instead of serving stale, differently-shaped output.
|
|
259
|
+
Every document and reverse shard is validated up front, and the canonical
|
|
260
|
+
projection payload is reconstructed from the loaded shards and compared
|
|
261
|
+
with the selected generation hash, so any semantic shard tampering is
|
|
262
|
+
detected before output is produced -- without parsing Markdown on the
|
|
263
|
+
fast path. On any mismatch the caller receives `(None, reason)` and falls
|
|
264
|
+
back to direct Markdown with a diagnostic.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
pointer = cache_root(config) / "current.json"
|
|
268
|
+
if not pointer.is_file():
|
|
269
|
+
return None, "projection absent"
|
|
270
|
+
try:
|
|
271
|
+
selected = _read(pointer)
|
|
272
|
+
if selected.get("schema_version") != SCHEMA_VERSION:
|
|
273
|
+
return None, "projection schema incompatible"
|
|
274
|
+
generation = str(selected.get("generation"))
|
|
275
|
+
generation_dir = cache_root(config) / "generations" / generation
|
|
276
|
+
manifest = _read(generation_dir / "manifest.json")
|
|
277
|
+
if generation != manifest.get("generation"):
|
|
278
|
+
return None, "projection pointer mismatch"
|
|
279
|
+
if manifest.get("config_fingerprint") != config_fingerprint(config):
|
|
280
|
+
return None, "projection stale: configuration changed"
|
|
281
|
+
manifest_documents = manifest.get("documents")
|
|
282
|
+
if not isinstance(manifest_documents, dict):
|
|
283
|
+
return None, "projection unreadable: manifest documents missing"
|
|
284
|
+
manifest_paths = {
|
|
285
|
+
str(record.get("path")): document_id
|
|
286
|
+
for document_id, record in manifest_documents.items()
|
|
287
|
+
}
|
|
288
|
+
included = included_source_paths(config)
|
|
289
|
+
if {path.as_posix() for path in included} != set(manifest_paths):
|
|
290
|
+
return None, "projection stale"
|
|
291
|
+
contents: dict[str, str] = {}
|
|
292
|
+
for relative in included:
|
|
293
|
+
text = (config.documentation_root / relative).read_text(encoding="utf-8")
|
|
294
|
+
document_id = manifest_paths[relative.as_posix()]
|
|
295
|
+
if _sha(text) != manifest_documents[document_id].get("source_sha256"):
|
|
296
|
+
return None, "projection stale"
|
|
297
|
+
contents[relative.as_posix()] = text
|
|
298
|
+
documents: dict[str, dict[str, Any]] = {}
|
|
299
|
+
for document_id in manifest_documents:
|
|
300
|
+
shard = _read(generation_dir / _shard("documents", document_id))
|
|
301
|
+
if (
|
|
302
|
+
shard.get("schema_version") != SCHEMA_VERSION
|
|
303
|
+
or shard.get("id") != document_id
|
|
304
|
+
):
|
|
305
|
+
return None, f"projection document shard invalid: {document_id}"
|
|
306
|
+
record = manifest_documents[document_id]
|
|
307
|
+
if (
|
|
308
|
+
shard.get("path") != record.get("path")
|
|
309
|
+
or shard.get("source_sha256") != record.get("source_sha256")
|
|
310
|
+
):
|
|
311
|
+
# The freshness decision above trusts the manifest's per-source
|
|
312
|
+
# path and hash; binding them to the generation-verified shard
|
|
313
|
+
# keeps a manifest-only edit from masking stale shard data.
|
|
314
|
+
return None, "projection manifest mismatch"
|
|
315
|
+
documents[document_id] = shard
|
|
316
|
+
reverse: dict[str, tuple[dict[str, Any], ...]] = {}
|
|
317
|
+
targets = {
|
|
318
|
+
dependency["target"]
|
|
319
|
+
for shard in documents.values()
|
|
320
|
+
for dependency in shard.get("dependencies", ())
|
|
321
|
+
}
|
|
322
|
+
for document_id in sorted(targets):
|
|
323
|
+
shard = _read(generation_dir / _shard("reverse", document_id))
|
|
324
|
+
if (
|
|
325
|
+
shard.get("schema_version") != SCHEMA_VERSION
|
|
326
|
+
or shard.get("id") != document_id
|
|
327
|
+
):
|
|
328
|
+
return None, f"projection reverse shard invalid: {document_id}"
|
|
329
|
+
reverse[document_id] = tuple(shard.get("incoming", ()))
|
|
330
|
+
if _reconstructed_generation(documents, reverse, config) != generation:
|
|
331
|
+
return None, "projection corrupt"
|
|
332
|
+
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
|
|
333
|
+
return None, f"projection unreadable: {error}"
|
|
334
|
+
return LoadedProjection(generation, documents, reverse, contents), "projection current"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _reconstructed_generation(
|
|
338
|
+
documents: dict[str, dict[str, Any]],
|
|
339
|
+
reverse: dict[str, tuple[dict[str, Any], ...]],
|
|
340
|
+
config: ProjectConfig,
|
|
341
|
+
) -> str:
|
|
342
|
+
"""Recompute the generation hash from loaded shards alone.
|
|
343
|
+
|
|
344
|
+
`build_projection` derives the generation from the canonical payload of
|
|
345
|
+
the active configuration fingerprint, the document records, and the
|
|
346
|
+
reverse edges. Rebuilding that payload from the shards (stripping the
|
|
347
|
+
per-shard `schema_version`/`id` envelope) and re-hashing it detects any
|
|
348
|
+
semantic tampering -- a removed dependency, an altered revision, a
|
|
349
|
+
rewritten section map -- that leaves the Markdown source hashes untouched,
|
|
350
|
+
using only data already read from the cache and no Markdown parsing.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
recon_documents = {
|
|
354
|
+
document_id: {
|
|
355
|
+
key: value
|
|
356
|
+
for key, value in shard.items()
|
|
357
|
+
if key not in ("schema_version", "id")
|
|
358
|
+
}
|
|
359
|
+
for document_id, shard in documents.items()
|
|
360
|
+
}
|
|
361
|
+
recon_reverse = {
|
|
362
|
+
document_id: list(incoming)
|
|
363
|
+
for document_id, incoming in reverse.items()
|
|
364
|
+
if incoming
|
|
365
|
+
}
|
|
366
|
+
payload = {
|
|
367
|
+
"schema_version": SCHEMA_VERSION,
|
|
368
|
+
"config_fingerprint": config_fingerprint(config),
|
|
369
|
+
"documents": dict(sorted(recon_documents.items())),
|
|
370
|
+
"reverse": dict(sorted(recon_reverse.items())),
|
|
371
|
+
}
|
|
372
|
+
return _sha(_json(payload))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def write_projection(config: ProjectConfig, projection: dict[str, Any]) -> str:
|
|
376
|
+
root = cache_root(config)
|
|
377
|
+
generation = str(projection["generation"])
|
|
378
|
+
generation_dir = root / "generations" / generation
|
|
379
|
+
manifest = {
|
|
380
|
+
"schema_version": SCHEMA_VERSION,
|
|
381
|
+
"generation": generation,
|
|
382
|
+
"config_fingerprint": projection.get(
|
|
383
|
+
"config_fingerprint", config_fingerprint(config)
|
|
384
|
+
),
|
|
385
|
+
"documents": {
|
|
386
|
+
key: {
|
|
387
|
+
"path": value["path"],
|
|
388
|
+
"source_sha256": value["source_sha256"],
|
|
389
|
+
"sections": value["sections"],
|
|
390
|
+
}
|
|
391
|
+
for key, value in projection["documents"].items()
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
generations = root / "generations"
|
|
395
|
+
generations.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
if not generation_dir.exists():
|
|
397
|
+
staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=generations))
|
|
398
|
+
try:
|
|
399
|
+
(staging / "manifest.json").write_text(
|
|
400
|
+
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True)
|
|
401
|
+
+ "\n",
|
|
402
|
+
encoding="utf-8",
|
|
403
|
+
)
|
|
404
|
+
for document_id, record in projection["documents"].items():
|
|
405
|
+
path = staging / _shard("documents", document_id)
|
|
406
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
path.write_text(
|
|
408
|
+
json.dumps(
|
|
409
|
+
{
|
|
410
|
+
"schema_version": SCHEMA_VERSION,
|
|
411
|
+
"id": document_id,
|
|
412
|
+
**record,
|
|
413
|
+
},
|
|
414
|
+
ensure_ascii=False,
|
|
415
|
+
indent=2,
|
|
416
|
+
sort_keys=True,
|
|
417
|
+
)
|
|
418
|
+
+ "\n",
|
|
419
|
+
encoding="utf-8",
|
|
420
|
+
)
|
|
421
|
+
for document_id, incoming in projection["reverse"].items():
|
|
422
|
+
path = staging / _shard("reverse", document_id)
|
|
423
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
424
|
+
path.write_text(
|
|
425
|
+
json.dumps(
|
|
426
|
+
{
|
|
427
|
+
"schema_version": SCHEMA_VERSION,
|
|
428
|
+
"id": document_id,
|
|
429
|
+
"incoming": incoming,
|
|
430
|
+
},
|
|
431
|
+
ensure_ascii=False,
|
|
432
|
+
indent=2,
|
|
433
|
+
sort_keys=True,
|
|
434
|
+
)
|
|
435
|
+
+ "\n",
|
|
436
|
+
encoding="utf-8",
|
|
437
|
+
)
|
|
438
|
+
staging.replace(generation_dir)
|
|
439
|
+
finally:
|
|
440
|
+
if staging.exists():
|
|
441
|
+
shutil.rmtree(staging)
|
|
442
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
443
|
+
with tempfile.NamedTemporaryFile(
|
|
444
|
+
mode="w", encoding="utf-8", dir=root, delete=False
|
|
445
|
+
) as handle:
|
|
446
|
+
json.dump(
|
|
447
|
+
{"schema_version": SCHEMA_VERSION, "generation": generation},
|
|
448
|
+
handle,
|
|
449
|
+
indent=2,
|
|
450
|
+
sort_keys=True,
|
|
451
|
+
)
|
|
452
|
+
handle.write("\n")
|
|
453
|
+
temporary = Path(handle.name)
|
|
454
|
+
temporary.replace(root / "current.json")
|
|
455
|
+
others = sorted(
|
|
456
|
+
(
|
|
457
|
+
path
|
|
458
|
+
for path in generations.iterdir()
|
|
459
|
+
if path.is_dir()
|
|
460
|
+
and path != generation_dir
|
|
461
|
+
and not path.name.startswith(".staging-")
|
|
462
|
+
),
|
|
463
|
+
key=lambda path: (path.stat().st_mtime_ns, path.name),
|
|
464
|
+
reverse=True,
|
|
465
|
+
)
|
|
466
|
+
for obsolete in others[max(0, config.keep_generations - 1) :]:
|
|
467
|
+
shutil.rmtree(obsolete)
|
|
468
|
+
return generation
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def resolve_generation_manifest(
|
|
472
|
+
config: ProjectConfig, selector: str
|
|
473
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
474
|
+
"""Resolve and verify a `--since` generation.
|
|
475
|
+
|
|
476
|
+
A selector is accepted when it is a full retained generation hash or an
|
|
477
|
+
unambiguous prefix of at least 12 characters that matches exactly one
|
|
478
|
+
retained generation. The manifest is then bound to every document and
|
|
479
|
+
reverse shard, the active configuration fingerprint and the reconstructed
|
|
480
|
+
generation hash before any recorded source hash can authorize an omission.
|
|
481
|
+
The returned `documents` mapping contains the verified full document
|
|
482
|
+
shards rather than the manifest summaries, so delta callers can compare
|
|
483
|
+
semantic metadata as well as section hashes.
|
|
484
|
+
|
|
485
|
+
Anything shorter, ambiguous, unknown, incompatible, corrupt or unreadable
|
|
486
|
+
returns `None`, so the caller fails closed with a single deterministic
|
|
487
|
+
error. Retention staging directories are ignored, matching projection
|
|
488
|
+
write semantics.
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
if not isinstance(selector, str) or len(selector) < 12:
|
|
492
|
+
return None
|
|
493
|
+
generations_dir = cache_root(config) / "generations"
|
|
494
|
+
try:
|
|
495
|
+
names = [
|
|
496
|
+
path.name
|
|
497
|
+
for path in generations_dir.iterdir()
|
|
498
|
+
if path.is_dir() and not path.name.startswith(".staging-")
|
|
499
|
+
]
|
|
500
|
+
except OSError:
|
|
501
|
+
return None
|
|
502
|
+
matches = sorted({name for name in names if name.startswith(selector)})
|
|
503
|
+
if len(matches) != 1:
|
|
504
|
+
return None
|
|
505
|
+
generation = matches[0]
|
|
506
|
+
try:
|
|
507
|
+
generation_dir = generations_dir / generation
|
|
508
|
+
manifest = _read(generation_dir / "manifest.json")
|
|
509
|
+
if manifest.get("schema_version") != SCHEMA_VERSION:
|
|
510
|
+
return None
|
|
511
|
+
if manifest.get("generation") != generation:
|
|
512
|
+
return None
|
|
513
|
+
if manifest.get("config_fingerprint") != config_fingerprint(config):
|
|
514
|
+
return None
|
|
515
|
+
manifest_documents = manifest.get("documents")
|
|
516
|
+
if not isinstance(manifest_documents, dict):
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
documents: dict[str, dict[str, Any]] = {}
|
|
520
|
+
for document_id, record in manifest_documents.items():
|
|
521
|
+
if not isinstance(document_id, str) or not isinstance(record, dict):
|
|
522
|
+
return None
|
|
523
|
+
shard = _read(generation_dir / _shard("documents", document_id))
|
|
524
|
+
if (
|
|
525
|
+
shard.get("schema_version") != SCHEMA_VERSION
|
|
526
|
+
or shard.get("id") != document_id
|
|
527
|
+
or shard.get("path") != record.get("path")
|
|
528
|
+
or shard.get("source_sha256") != record.get("source_sha256")
|
|
529
|
+
or shard.get("sections") != record.get("sections")
|
|
530
|
+
):
|
|
531
|
+
return None
|
|
532
|
+
documents[document_id] = shard
|
|
533
|
+
|
|
534
|
+
reverse: dict[str, tuple[dict[str, Any], ...]] = {}
|
|
535
|
+
targets = {
|
|
536
|
+
dependency["target"]
|
|
537
|
+
for shard in documents.values()
|
|
538
|
+
for dependency in shard.get("dependencies", ())
|
|
539
|
+
}
|
|
540
|
+
for document_id in sorted(targets):
|
|
541
|
+
shard = _read(generation_dir / _shard("reverse", document_id))
|
|
542
|
+
if (
|
|
543
|
+
shard.get("schema_version") != SCHEMA_VERSION
|
|
544
|
+
or shard.get("id") != document_id
|
|
545
|
+
or not isinstance(shard.get("incoming"), list)
|
|
546
|
+
):
|
|
547
|
+
return None
|
|
548
|
+
reverse[document_id] = tuple(shard["incoming"])
|
|
549
|
+
if _reconstructed_generation(documents, reverse, config) != generation:
|
|
550
|
+
return None
|
|
551
|
+
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
552
|
+
return None
|
|
553
|
+
return generation, {**manifest, "documents": documents}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def evaluate_changes(config: ProjectConfig, current: dict[str, Any]) -> ChangesReport:
|
|
557
|
+
"""Compare `current` against the selected projection generation, if any."""
|
|
558
|
+
|
|
559
|
+
pointer = cache_root(config) / "current.json"
|
|
560
|
+
if not pointer.is_file():
|
|
561
|
+
return ChangesReport(status="absent")
|
|
562
|
+
try:
|
|
563
|
+
selected = _read(pointer)
|
|
564
|
+
manifest = _read(
|
|
565
|
+
cache_root(config)
|
|
566
|
+
/ "generations"
|
|
567
|
+
/ str(selected["generation"])
|
|
568
|
+
/ "manifest.json"
|
|
569
|
+
)
|
|
570
|
+
except (OSError, KeyError, ValueError, json.JSONDecodeError):
|
|
571
|
+
return ChangesReport(status="unavailable")
|
|
572
|
+
previous = manifest.get("documents", {})
|
|
573
|
+
current_documents = current["documents"]
|
|
574
|
+
document_changes: list[DocumentChange] = []
|
|
575
|
+
for document_id in sorted(set(previous) | set(current_documents)):
|
|
576
|
+
if document_id not in previous:
|
|
577
|
+
document_changes.append(DocumentChange(document_id, "added"))
|
|
578
|
+
elif document_id not in current_documents:
|
|
579
|
+
document_changes.append(DocumentChange(document_id, "removed"))
|
|
580
|
+
elif (
|
|
581
|
+
previous[document_id].get("source_sha256")
|
|
582
|
+
!= current_documents[document_id].get("source_sha256")
|
|
583
|
+
):
|
|
584
|
+
old_sections = previous[document_id].get("sections", {})
|
|
585
|
+
new_sections = current_documents[document_id].get("sections", {})
|
|
586
|
+
changed_sections = tuple(
|
|
587
|
+
sorted(
|
|
588
|
+
anchor
|
|
589
|
+
for anchor in set(old_sections) | set(new_sections)
|
|
590
|
+
if old_sections.get(anchor, {}).get("sha256")
|
|
591
|
+
!= new_sections.get(anchor, {}).get("sha256")
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
document_changes.append(DocumentChange(document_id, "changed", changed_sections))
|
|
595
|
+
return ChangesReport(status="compared", changes=tuple(document_changes))
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def changes(config: ProjectConfig, current: dict[str, Any]) -> tuple[str, ...]:
|
|
599
|
+
report = evaluate_changes(config, current)
|
|
600
|
+
if report.status == "absent":
|
|
601
|
+
return ("projection absent; every document is new",)
|
|
602
|
+
if report.status == "unavailable":
|
|
603
|
+
return ("projection unavailable; changes cannot be compared",)
|
|
604
|
+
lines: list[str] = []
|
|
605
|
+
for change in report.changes:
|
|
606
|
+
lines.append(f"{change.kind}\t{change.document_id}")
|
|
607
|
+
for anchor in change.sections:
|
|
608
|
+
lines.append(f"section\t{change.document_id}#{anchor}")
|
|
609
|
+
return tuple(lines) or ("no changes",)
|