tracecite 0.0.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.
Files changed (76) hide show
  1. tracecite/__init__.py +35 -0
  2. tracecite/__main__.py +3 -0
  3. tracecite/cli.py +656 -0
  4. tracecite/docs/__init__.py +56 -0
  5. tracecite/docs/build.py +381 -0
  6. tracecite/docs/config.py +114 -0
  7. tracecite/docs/contract.py +17 -0
  8. tracecite/docs/modes.py +240 -0
  9. tracecite/docs/stage.py +407 -0
  10. tracecite/docs/vectorize.py +457 -0
  11. tracecite/evidence/__init__.py +3 -0
  12. tracecite/evidence/chunking.py +214 -0
  13. tracecite/evidence/commands.py +552 -0
  14. tracecite/evidence/config.py +49 -0
  15. tracecite/evidence/embedding.py +59 -0
  16. tracecite/evidence/manifest.py +197 -0
  17. tracecite/evidence/page_extraction.py +200 -0
  18. tracecite/evidence/page_output.py +80 -0
  19. tracecite/evidence/page_selection.py +116 -0
  20. tracecite/evidence/parsers/__init__.py +1 -0
  21. tracecite/evidence/parsers/base.py +65 -0
  22. tracecite/evidence/parsers/markdown.py +142 -0
  23. tracecite/evidence/parsers/pdf.py +315 -0
  24. tracecite/evidence/parsers/workbook.py +334 -0
  25. tracecite/evidence/paths.py +94 -0
  26. tracecite/evidence/schema.py +570 -0
  27. tracecite/evidence/source_links.py +185 -0
  28. tracecite/evidence/sync.py +1434 -0
  29. tracecite/evidence/vector_backend.py +130 -0
  30. tracecite/evidence/verify.py +368 -0
  31. tracecite/skills/routing-documentation-source-links/SKILL.md +167 -0
  32. tracecite/skills/routing-documentation-source-links/agents/openai.yaml +3 -0
  33. tracecite/skills/routing-documentation-source-links/references/documenter-integration.md +71 -0
  34. tracecite/skills/routing-documentation-source-links/references/registry-contract.md +86 -0
  35. tracecite/skills/routing-documentation-source-links/references/staging-and-rewriting.md +118 -0
  36. tracecite/skills/routing-documentation-source-links/references/validation-and-failure-modes.md +67 -0
  37. tracecite/skills/using-tracecite/SKILL.md +97 -0
  38. tracecite/skills/using-tracecite/agents/openai.yaml +3 -0
  39. tracecite/skills/using-tracecite/references/architecture.md +68 -0
  40. tracecite/skills/using-tracecite/references/pdf-and-assets.md +67 -0
  41. tracecite/skills/using-tracecite/references/retrieval.md +45 -0
  42. tracecite/skills/using-tracecite/references/schema-and-migrations.md +52 -0
  43. tracecite/skills/using-tracecite/references/synchronisation.md +98 -0
  44. tracecite/skills/using-tracecite/references/workspace-runtime.md +44 -0
  45. tracecite/skills/writing-evidence-backed-reports/SKILL.md +91 -0
  46. tracecite/skills/writing-evidence-backed-reports/agents/openai.yaml +3 -0
  47. tracecite/skills/writing-evidence-backed-reports/fixtures/README.md +13 -0
  48. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-bad-path.md +5 -0
  49. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-grammar.md +8 -0
  50. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-missing-definition.md +3 -0
  51. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-page-not-indexed.md +5 -0
  52. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-unindexed-path.md +5 -0
  53. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-grammar.md +12 -0
  54. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-report.md +27 -0
  55. tracecite/skills/writing-evidence-backed-reports/fixtures/source-links.toml +17 -0
  56. tracecite/skills/writing-evidence-backed-reports/fixtures/validate_fixtures.py +295 -0
  57. tracecite/skills/writing-evidence-backed-reports/references/images-and-source-links.md +34 -0
  58. tracecite/skills/writing-evidence-backed-reports/references/quotation-and-verification.md +43 -0
  59. tracecite/skills/writing-evidence-backed-reports/references/reference-style-citations.md +48 -0
  60. tracecite/skills/writing-evidence-backed-reports/references/report-structure.md +58 -0
  61. tracecite/tables/__init__.py +41 -0
  62. tracecite/tables/_text.py +142 -0
  63. tracecite/tables/document.py +582 -0
  64. tracecite/tables/html.py +299 -0
  65. tracecite/tables/models.py +119 -0
  66. tracecite/tables/normalise.py +786 -0
  67. tracecite/tables/pandoc.py +503 -0
  68. tracecite/tables/publish.py +238 -0
  69. tracecite/tables/render.py +128 -0
  70. tracecite/tables/site.py +457 -0
  71. tracecite-0.0.2.dist-info/METADATA +320 -0
  72. tracecite-0.0.2.dist-info/RECORD +76 -0
  73. tracecite-0.0.2.dist-info/WHEEL +5 -0
  74. tracecite-0.0.2.dist-info/entry_points.txt +2 -0
  75. tracecite-0.0.2.dist-info/licenses/LICENSE +21 -0
  76. tracecite-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,56 @@
1
+ """Reusable documentation build and evidence-contract APIs."""
2
+
3
+ from .build import (
4
+ BuildSelection,
5
+ BuildVariant,
6
+ DocsBuildResult,
7
+ Language,
8
+ build_docs,
9
+ changed_retained_markdown,
10
+ classify_render_inputs,
11
+ discover_render_inputs,
12
+ select_build_variant,
13
+ snapshot_retained_markdown,
14
+ stage_retained_markdown,
15
+ )
16
+ from .config import load_docs_contract
17
+ from .contract import DocsEvidenceContract
18
+ from .modes import DocsModeResult, author_docs, check_docs
19
+ from .stage import DocsStageResult, stage_docs
20
+ from .vectorize import (
21
+ DocsIndexProfile,
22
+ DocsIndexResult,
23
+ doctor_docs_index,
24
+ prepare_docs_index_input,
25
+ resolve_docs_index_profile,
26
+ search_docs_index,
27
+ sync_docs_index,
28
+ )
29
+
30
+ __all__ = [
31
+ "BuildSelection",
32
+ "BuildVariant",
33
+ "DocsBuildResult",
34
+ "DocsEvidenceContract",
35
+ "DocsIndexProfile",
36
+ "DocsIndexResult",
37
+ "DocsModeResult",
38
+ "DocsStageResult",
39
+ "Language",
40
+ "author_docs",
41
+ "build_docs",
42
+ "changed_retained_markdown",
43
+ "check_docs",
44
+ "classify_render_inputs",
45
+ "discover_render_inputs",
46
+ "doctor_docs_index",
47
+ "load_docs_contract",
48
+ "prepare_docs_index_input",
49
+ "resolve_docs_index_profile",
50
+ "search_docs_index",
51
+ "select_build_variant",
52
+ "snapshot_retained_markdown",
53
+ "stage_docs",
54
+ "stage_retained_markdown",
55
+ "sync_docs_index",
56
+ ]
@@ -0,0 +1,381 @@
1
+ """Reusable Quarto documentation-build orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from collections.abc import Iterator, Mapping, Sequence
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ import yaml
15
+
16
+ from ..tables import SiteExportResult, export_embedding_site
17
+
18
+ Language = Literal["python", "julia"]
19
+ BuildVariant = Literal["combined", "python", "julia", "prose"]
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class BuildSelection:
24
+ variant: BuildVariant
25
+ profile: str | None
26
+ included: tuple[Path, ...]
27
+ skipped: tuple[Path, ...]
28
+ warning: str | None
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class DocsBuildResult:
33
+ project_root: Path
34
+ output_root: Path
35
+ selection: BuildSelection
36
+ retained_count: int
37
+ inspection: SiteExportResult | None
38
+ changed_retained: tuple[Path, ...]
39
+
40
+
41
+ def _load_config(project_root: Path) -> dict:
42
+ path = project_root / "_quarto.yml"
43
+ if not path.is_file():
44
+ raise ValueError(f"Quarto project configuration does not exist: {path}")
45
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
46
+ return loaded if isinstance(loaded, dict) else {}
47
+
48
+
49
+ def discover_render_inputs(project_root: Path) -> tuple[Path, ...]:
50
+ """Expand the base project's ordered ``project.render`` entries."""
51
+ project_root = Path(project_root).resolve()
52
+ config = _load_config(project_root)
53
+ render = (config.get("project") or {}).get("render", [])
54
+ if isinstance(render, str):
55
+ render = [render]
56
+ found: list[Path] = []
57
+ for pattern in render:
58
+ if not isinstance(pattern, str) or pattern.startswith("!"):
59
+ continue
60
+ matches = sorted(project_root.glob(pattern))
61
+ for match in matches:
62
+ if (
63
+ match.is_file()
64
+ and not match.name.endswith(".html.md")
65
+ and match not in found
66
+ ):
67
+ found.append(match)
68
+ return tuple(found)
69
+
70
+
71
+ def _qmd_language(path: Path) -> Language | None:
72
+ text = path.read_text(encoding="utf-8")
73
+ if not text.startswith("---"):
74
+ return None
75
+ end = text.find("\n---", 3)
76
+ if end < 0:
77
+ return None
78
+ metadata = yaml.safe_load(text[3:end]) or {}
79
+ engine = str(metadata.get("jupyter", metadata.get("engine", ""))).lower()
80
+ if "julia" in engine:
81
+ return "julia"
82
+ if engine in {"python", "python3", "jupyter"} or "python" in engine:
83
+ return "python"
84
+ return None
85
+
86
+
87
+ def classify_render_inputs(paths: Sequence[Path]) -> dict[Language, tuple[Path, ...]]:
88
+ result: dict[Language, list[Path]] = {"python": [], "julia": []}
89
+ for path in paths:
90
+ suffix = path.suffix.lower()
91
+ language = {".py": "python", ".jl": "julia"}.get(suffix)
92
+ if suffix == ".qmd":
93
+ language = _qmd_language(path)
94
+ if language:
95
+ result[language].append(path)
96
+ return {key: tuple(value) for key, value in result.items()}
97
+
98
+
99
+ def _normalize_input_path(path: Path, project_root: Path) -> Path:
100
+ """Resolve an input path when it stays inside the project root."""
101
+ candidate = path if path.is_absolute() else project_root / path
102
+ resolved = candidate.resolve()
103
+ try:
104
+ resolved.relative_to(project_root)
105
+ except ValueError:
106
+ return candidate
107
+ return resolved
108
+
109
+
110
+ def select_build_variant(
111
+ project_root: Path,
112
+ inputs: Sequence[Path],
113
+ *,
114
+ only: Language | None = None,
115
+ julia: str | None = None,
116
+ ) -> BuildSelection:
117
+ project_root = Path(project_root).resolve()
118
+ inputs = tuple(_normalize_input_path(Path(path), project_root) for path in inputs)
119
+ classified = classify_render_inputs(inputs)
120
+ available = {language for language, paths in classified.items() if paths}
121
+ if only and not classified[only]:
122
+ raise ValueError(
123
+ f"Requested language is absent from configured render inputs: {only}"
124
+ )
125
+ if only == "julia" and not julia:
126
+ raise ValueError("Julia-only documentation build requires Julia on PATH")
127
+ if only and len(available) > 1:
128
+ overlay = project_root / f"_quarto-{only}.yml"
129
+ if not overlay.is_file():
130
+ raise ValueError(
131
+ f"Explicit {only} build requires reduced overlay: {overlay}"
132
+ )
133
+ if only:
134
+ skipped = tuple(
135
+ path
136
+ for language, paths in classified.items()
137
+ if language != only
138
+ for path in paths
139
+ )
140
+ profile = only if len(available) > 1 else None
141
+ return BuildSelection(
142
+ only,
143
+ profile,
144
+ tuple(path for path in inputs if path not in skipped),
145
+ skipped,
146
+ None,
147
+ )
148
+ if available == {"python", "julia"}:
149
+ if julia:
150
+ return BuildSelection("combined", None, tuple(inputs), (), None)
151
+ overlay = project_root / "_quarto-python.yml"
152
+ if not overlay.is_file():
153
+ raise ValueError(
154
+ f"Automatic Python fallback requires reduced overlay: {overlay}"
155
+ )
156
+ skipped = classified["julia"]
157
+ warning = "Julia unavailable; skipping configured files:\n" + "\n".join(
158
+ f"- {path.relative_to(project_root)}" for path in skipped
159
+ )
160
+ included = tuple(path for path in inputs if path not in skipped)
161
+ return BuildSelection("python", "python", included, skipped, warning)
162
+ if available == {"julia"}:
163
+ if julia:
164
+ return BuildSelection("julia", None, tuple(inputs), (), None)
165
+ raise ValueError("Configured Julia documentation inputs require Julia on PATH")
166
+ if available == {"python"}:
167
+ return BuildSelection("python", None, tuple(inputs), (), None)
168
+ return BuildSelection("prose", None, tuple(inputs), (), None)
169
+
170
+
171
+ def snapshot_retained_markdown(project_root: Path) -> dict[Path, bytes]:
172
+ project_root = Path(project_root).resolve()
173
+ output = _output_root(project_root)
174
+ return {
175
+ path.relative_to(project_root): path.read_bytes()
176
+ for path in project_root.rglob("*.html.md")
177
+ if not path.is_relative_to(output) and ".quarto" not in path.parts
178
+ }
179
+
180
+
181
+ def changed_retained_markdown(
182
+ before: Mapping[Path, bytes],
183
+ project_root: Path,
184
+ ) -> tuple[Path, ...]:
185
+ after = snapshot_retained_markdown(project_root)
186
+ return tuple(
187
+ sorted(
188
+ path
189
+ for path in before.keys() | after.keys()
190
+ if before.get(path) != after.get(path)
191
+ )
192
+ )
193
+
194
+
195
+ def stage_retained_markdown(
196
+ project_root: Path,
197
+ output_root: Path,
198
+ inputs: Sequence[Path],
199
+ ) -> int:
200
+ project_root = Path(project_root).resolve()
201
+ output_root = Path(output_root).resolve()
202
+ for stale in output_root.rglob("*.html.md") if output_root.exists() else ():
203
+ stale.unlink()
204
+ count = 0
205
+ for authored in inputs:
206
+ retained = _retained_destination(authored)
207
+ if not retained.is_file():
208
+ continue
209
+ relative = authored.relative_to(project_root)
210
+ destination = output_root / relative.with_name(relative.stem + ".html.md")
211
+ destination.parent.mkdir(parents=True, exist_ok=True)
212
+ shutil.copy2(retained, destination)
213
+ count += 1
214
+ return count
215
+
216
+
217
+ def _output_root(project_root: Path) -> Path:
218
+ project = _load_config(project_root).get("project") or {}
219
+ return project_root / project.get("output-dir", "_site")
220
+
221
+
222
+ def _contains_source_symlinks(project_root: Path, output_root: Path) -> bool:
223
+ """Return whether a Quarto source tree contains symlinked inputs/resources."""
224
+ project_root = project_root.resolve()
225
+ output_root = output_root.resolve()
226
+ for path in project_root.rglob("*"):
227
+ if path.is_relative_to(output_root) or ".quarto" in path.parts:
228
+ continue
229
+ if path.is_symlink():
230
+ return True
231
+ return False
232
+
233
+
234
+ def _copy_project_without_build_outputs(
235
+ project_root: Path,
236
+ staged_root: Path,
237
+ output_root: Path,
238
+ ) -> None:
239
+ """Materialise a symlinked Quarto project as ordinary files for rendering."""
240
+ try:
241
+ output_relative = output_root.relative_to(project_root)
242
+ except ValueError as error:
243
+ raise ValueError(
244
+ "Symlinked Quarto projects require project.output-dir to remain "
245
+ f"inside the project root: {output_root}"
246
+ ) from error
247
+ if not output_root.resolve().is_relative_to(project_root.resolve()):
248
+ raise ValueError(
249
+ "Symlinked Quarto projects require project.output-dir to remain "
250
+ f"inside the project root: {output_root}"
251
+ )
252
+
253
+ def ignore(directory: str, names: list[str]) -> set[str]:
254
+ relative = Path(directory).relative_to(project_root)
255
+ ignored: set[str] = set()
256
+ for name in names:
257
+ candidate = relative / name
258
+ if candidate == output_relative or name == ".quarto":
259
+ ignored.add(name)
260
+ return ignored
261
+
262
+ shutil.copytree(
263
+ project_root,
264
+ staged_root,
265
+ symlinks=False,
266
+ ignore=ignore,
267
+ dirs_exist_ok=True,
268
+ )
269
+
270
+
271
+ def _retained_destination(authored: Path) -> Path:
272
+ """Return the canonical retained-Markdown destination for an authored input."""
273
+ canonical = authored.resolve() if authored.is_symlink() else authored
274
+ return canonical.with_name(canonical.stem + ".html.md")
275
+
276
+
277
+ def _publish_materialised_render(
278
+ staged_root: Path,
279
+ project_root: Path,
280
+ inputs: Sequence[Path],
281
+ ) -> None:
282
+ """Publish a successful staged render and its retained Markdown."""
283
+ staged_output = _output_root(staged_root)
284
+ output_root = _output_root(project_root)
285
+ if not staged_output.is_dir():
286
+ raise RuntimeError(
287
+ f"Quarto did not create the configured output: {staged_output}"
288
+ )
289
+
290
+ for authored in inputs:
291
+ relative = authored.relative_to(project_root)
292
+ staged_authored = staged_root / relative
293
+ staged_retained = staged_authored.with_name(staged_authored.stem + ".html.md")
294
+ if not staged_retained.is_file():
295
+ continue
296
+ destination = _retained_destination(authored)
297
+ destination.parent.mkdir(parents=True, exist_ok=True)
298
+ shutil.copy2(staged_retained, destination)
299
+
300
+ if output_root.exists() or output_root.is_symlink():
301
+ if output_root.is_symlink() or output_root.is_file():
302
+ output_root.unlink()
303
+ else:
304
+ shutil.rmtree(output_root)
305
+ shutil.copytree(staged_output, output_root, symlinks=False)
306
+
307
+
308
+ @contextmanager
309
+ def _render_project_root(
310
+ project_root: Path,
311
+ output_root: Path,
312
+ ) -> Iterator[tuple[Path, bool]]:
313
+ """Yield a Quarto-safe project root, materialising symlinks when required."""
314
+ if not _contains_source_symlinks(project_root, output_root):
315
+ yield project_root, False
316
+ return
317
+
318
+ staged_root = Path(
319
+ tempfile.mkdtemp(
320
+ prefix=f".{project_root.name}-render-",
321
+ dir=project_root.parent,
322
+ )
323
+ )
324
+ try:
325
+ _copy_project_without_build_outputs(project_root, staged_root, output_root)
326
+ yield staged_root, True
327
+ finally:
328
+ shutil.rmtree(staged_root, ignore_errors=True)
329
+
330
+
331
+ def build_docs(
332
+ project_root: str | Path,
333
+ *,
334
+ only: Language | None = None,
335
+ quarto: str | Path | None = None,
336
+ inspection: bool = True,
337
+ render_inspection: bool = True,
338
+ strict_tables: bool = False,
339
+ check_retained: bool = False,
340
+ ) -> DocsBuildResult:
341
+ project_root = Path(project_root).resolve()
342
+ quarto_command = str(quarto) if quarto else shutil.which("quarto")
343
+ if not quarto_command:
344
+ raise FileNotFoundError("Quarto is required. Install Quarto or pass --quarto.")
345
+ inputs = discover_render_inputs(project_root)
346
+ julia = shutil.which("julia")
347
+ selection = select_build_variant(project_root, inputs, only=only, julia=julia)
348
+ if selection.warning:
349
+ print(selection.warning)
350
+ before = snapshot_retained_markdown(project_root) if check_retained else {}
351
+ output_root = _output_root(project_root)
352
+ with _render_project_root(project_root, output_root) as (render_root, staged):
353
+ command = [quarto_command, "render", render_root.name]
354
+ if selection.profile:
355
+ command.extend(["--profile", selection.profile])
356
+ subprocess.run(command, cwd=render_root.parent, check=True)
357
+ if staged:
358
+ _publish_materialised_render(
359
+ render_root,
360
+ project_root,
361
+ selection.included,
362
+ )
363
+ retained_count = stage_retained_markdown(
364
+ project_root, output_root, selection.included
365
+ )
366
+ exported = None
367
+ if inspection:
368
+ exported = export_embedding_site(
369
+ output_root,
370
+ project_root.parent / ".tracecite" / "embedding-site",
371
+ project_config=project_root / "_quarto.yml",
372
+ project_profile=selection.profile,
373
+ source_project=project_root,
374
+ strict=strict_tables,
375
+ render=render_inspection,
376
+ quarto=quarto_command,
377
+ )
378
+ changed = changed_retained_markdown(before, project_root) if check_retained else ()
379
+ return DocsBuildResult(
380
+ project_root, output_root, selection, retained_count, exported, changed
381
+ )
@@ -0,0 +1,114 @@
1
+ """TOML loading and deterministic validation for the docs contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ from .contract import DocsEvidenceContract
9
+
10
+ _FIELDS = {
11
+ "authored_root",
12
+ "retained_root",
13
+ "staged_root",
14
+ "source_links",
15
+ "index_output",
16
+ "publication_exclude",
17
+ "host_render_command",
18
+ }
19
+
20
+
21
+ def _path(value: object, *, name: str, repo_root: Path) -> Path:
22
+ if not isinstance(value, str) or not value:
23
+ raise ValueError(f"{name} must be a non-empty repository-relative path")
24
+ raw = Path(value)
25
+ if raw.is_absolute():
26
+ raise ValueError(f"{name} must be repository-relative")
27
+ if ".." in raw.parts:
28
+ raise ValueError(f"{name} must not escape the repository")
29
+ resolved = (repo_root / raw).resolve(strict=False)
30
+ if not resolved.is_relative_to(repo_root):
31
+ raise ValueError(f"{name} resolves outside the repository")
32
+ return resolved
33
+
34
+
35
+ def _overlap(left: Path, right: Path) -> bool:
36
+ return left.is_relative_to(right) or right.is_relative_to(left)
37
+
38
+
39
+ def load_docs_contract(
40
+ path: str | Path, *, repo_root: str | Path
41
+ ) -> DocsEvidenceContract:
42
+ config_path = Path(path).resolve()
43
+ root = Path(repo_root).resolve()
44
+ try:
45
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
46
+ except (OSError, tomllib.TOMLDecodeError) as exc:
47
+ raise ValueError(f"invalid docs configuration: {exc}") from exc
48
+ if data.get("schema_version") != 1:
49
+ raise ValueError("docs configuration requires schema_version = 1")
50
+ if set(data) - {"schema_version", "docs"} or not isinstance(data.get("docs"), dict):
51
+ raise ValueError("docs configuration requires a [docs] table")
52
+ docs = data["docs"]
53
+ unknown = set(docs) - _FIELDS
54
+ if unknown:
55
+ raise ValueError(
56
+ f"unknown docs configuration field(s): {', '.join(sorted(unknown))}"
57
+ )
58
+ missing = _FIELDS - set(docs) - {"host_render_command"}
59
+ if missing:
60
+ raise ValueError(
61
+ f"missing docs configuration field(s): {', '.join(sorted(missing))}"
62
+ )
63
+
64
+ authored = _path(docs.get("authored_root"), name="authored_root", repo_root=root)
65
+ retained = _path(docs.get("retained_root"), name="retained_root", repo_root=root)
66
+ staged = _path(docs.get("staged_root"), name="staged_root", repo_root=root)
67
+ source_links = _path(docs.get("source_links"), name="source_links", repo_root=root)
68
+ index_output = _path(docs.get("index_output"), name="index_output", repo_root=root)
69
+ if not authored.is_dir() or not retained.is_dir():
70
+ raise ValueError("authored_root and retained_root must be existing directories")
71
+ if not source_links.is_file():
72
+ raise ValueError("source_links must be an existing file")
73
+ if _overlap(retained, staged):
74
+ raise ValueError("retained_root and staged_root must not overlap")
75
+ roots = (authored, retained, staged, source_links, index_output)
76
+ for index, left in enumerate(roots):
77
+ for right in roots[index + 1 :]:
78
+ if (left == authored and right == retained) or (
79
+ left == retained and right == authored
80
+ ):
81
+ if not _overlap(left, right) or left == right:
82
+ continue
83
+ raise ValueError(
84
+ "authored_root and retained_root may overlap only when equal"
85
+ )
86
+ if _overlap(left, right):
87
+ raise ValueError("docs contract paths must not contain one another")
88
+
89
+ excludes = docs.get("publication_exclude")
90
+ if not isinstance(excludes, list):
91
+ raise ValueError("publication_exclude must be an array") # noqa: TRY004
92
+ publication_exclude = tuple(
93
+ _path(value, name="publication_exclude", repo_root=root) for value in excludes
94
+ )
95
+ command = docs.get("host_render_command")
96
+ if command is not None:
97
+ if (
98
+ not isinstance(command, list)
99
+ or not command
100
+ or not all(isinstance(item, str) and item for item in command)
101
+ ):
102
+ raise ValueError("host_render_command must be a non-empty argument array")
103
+ command_tuple = tuple(command)
104
+ else:
105
+ command_tuple = None
106
+ return DocsEvidenceContract(
107
+ authored,
108
+ retained,
109
+ staged,
110
+ source_links,
111
+ index_output,
112
+ publication_exclude,
113
+ command_tuple,
114
+ )
@@ -0,0 +1,17 @@
1
+ """Validated, repository-neutral documentation evidence contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class DocsEvidenceContract:
11
+ authored_root: Path
12
+ retained_root: Path
13
+ staged_root: Path
14
+ source_links: Path
15
+ index_output: Path
16
+ publication_exclude: tuple[Path, ...]
17
+ host_render_command: tuple[str, ...] | None = None