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.
@@ -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",)