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,240 @@
1
+ """Repository-neutral author and freshness-check documentation modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from dataclasses import dataclass, replace
12
+ from pathlib import Path
13
+
14
+ from .contract import DocsEvidenceContract
15
+ from .stage import stage_docs
16
+ from .vectorize import docs_index_freshness_diagnostics
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class DocsModeResult:
21
+ mode: str
22
+ ok: bool
23
+ manifest_path: Path
24
+ diagnostics: tuple[str, ...] = ()
25
+
26
+
27
+ def _sha(path: Path) -> str:
28
+ return hashlib.sha256(path.read_bytes()).hexdigest()
29
+
30
+
31
+ def _repo_files(
32
+ root: Path, repo_root: Path, *, exclude: Path | None = None
33
+ ) -> dict[str, str]:
34
+ if not root.exists():
35
+ return {}
36
+ return {
37
+ path.relative_to(repo_root).as_posix(): _sha(path)
38
+ for path in sorted(root.rglob("*"))
39
+ if path.is_file() and path != exclude
40
+ }
41
+
42
+
43
+ def _staged_files(source: Path, destination: Path, repo_root: Path) -> dict[str, str]:
44
+ if not source.exists():
45
+ return {}
46
+ return {
47
+ (destination / path.relative_to(source))
48
+ .relative_to(repo_root)
49
+ .as_posix(): _sha(path)
50
+ for path in sorted(source.rglob("*"))
51
+ if path.is_file()
52
+ }
53
+
54
+
55
+ def _manifest(
56
+ contract: DocsEvidenceContract,
57
+ config_path: Path,
58
+ repo_root: Path,
59
+ public_root: Path,
60
+ ) -> dict[str, object]:
61
+ manifest_path = contract.retained_root / ".tracecite-manifest.json"
62
+ return {
63
+ "schema_version": 1,
64
+ "contract_sha256": _sha(config_path),
65
+ "retained": _repo_files(
66
+ contract.retained_root, repo_root, exclude=manifest_path
67
+ ),
68
+ "public": _staged_files(
69
+ public_root, contract.staged_root / "public", repo_root
70
+ ),
71
+ }
72
+
73
+
74
+ def _dump(data: dict[str, object], path: Path) -> None:
75
+ path.write_text(
76
+ json.dumps(data, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
77
+ encoding="utf-8",
78
+ )
79
+
80
+
81
+ def author_docs(
82
+ contract: DocsEvidenceContract, *, config_path: str | Path, repo_root: str | Path
83
+ ) -> DocsModeResult:
84
+ root = Path(repo_root).resolve()
85
+ config = Path(config_path).resolve()
86
+ if contract.host_render_command is not None:
87
+ subprocess.run(list(contract.host_render_command), cwd=root, check=True)
88
+ parent = contract.staged_root.parent
89
+ parent.mkdir(parents=True, exist_ok=True)
90
+ temporary = Path(tempfile.mkdtemp(prefix=".tracecite-author-", dir=parent))
91
+ try:
92
+ staged_retained = temporary / "retained"
93
+ shutil.copytree(
94
+ contract.retained_root,
95
+ staged_retained,
96
+ ignore=shutil.ignore_patterns(".tracecite-manifest.json"),
97
+ )
98
+ staged = replace(contract, retained_root=staged_retained, staged_root=temporary)
99
+ canonical_retained = contract.retained_root
100
+ stage_docs(
101
+ staged,
102
+ target="local",
103
+ repo_root=root,
104
+ link_markdown_root=canonical_retained,
105
+ )
106
+ stage_docs(
107
+ staged,
108
+ target="public",
109
+ repo_root=root,
110
+ link_markdown_root=canonical_retained,
111
+ )
112
+ manifest = _manifest(contract, config, root, staged.staged_root / "public")
113
+ manifest_tmp = temporary / "manifest.json"
114
+ _dump(manifest, manifest_tmp)
115
+ targets = [
116
+ (contract.staged_root / "local", staged.staged_root / "local"),
117
+ (contract.staged_root / "public", staged.staged_root / "public"),
118
+ (contract.retained_root / ".tracecite-manifest.json", manifest_tmp),
119
+ ]
120
+ backups: list[tuple[Path, Path]] = []
121
+ promoted: list[Path] = []
122
+ try:
123
+ for destination, source in targets:
124
+ backup = destination.with_name(destination.name + ".tracecite-previous")
125
+ if backup.exists():
126
+ shutil.rmtree(backup) if backup.is_dir() else backup.unlink()
127
+ if destination.exists():
128
+ os.replace(destination, backup)
129
+ backups.append((destination, backup))
130
+ for destination, source in targets:
131
+ destination.parent.mkdir(parents=True, exist_ok=True)
132
+ os.replace(source, destination)
133
+ promoted.append(destination)
134
+ for _, backup in backups:
135
+ if backup.exists():
136
+ shutil.rmtree(backup) if backup.is_dir() else backup.unlink()
137
+ except Exception:
138
+ for destination in promoted:
139
+ if destination.exists():
140
+ shutil.rmtree(
141
+ destination
142
+ ) if destination.is_dir() else destination.unlink()
143
+ for destination, backup in reversed(backups):
144
+ if backup.exists():
145
+ os.replace(backup, destination)
146
+ raise
147
+ finally:
148
+ if temporary.exists():
149
+ shutil.rmtree(temporary)
150
+ return DocsModeResult(
151
+ "author", True, contract.retained_root / ".tracecite-manifest.json"
152
+ )
153
+
154
+
155
+ def check_docs(
156
+ contract: DocsEvidenceContract, *, config_path: str | Path, repo_root: str | Path
157
+ ) -> DocsModeResult:
158
+ root = Path(repo_root).resolve()
159
+ manifest_path = contract.retained_root / ".tracecite-manifest.json"
160
+ diagnostics: list[str] = []
161
+ try:
162
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
163
+ if not isinstance(manifest, dict) or manifest.get("schema_version") != 1:
164
+ raise ValueError("invalid schema")
165
+ except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
166
+ return DocsModeResult(
167
+ "check", False, manifest_path, (f"invalid manifest: {exc}",)
168
+ )
169
+ if manifest.get("contract_sha256") != _sha(Path(config_path).resolve()):
170
+ diagnostics.append("contract hash mismatch")
171
+ try:
172
+ temporary = Path(
173
+ tempfile.mkdtemp(
174
+ prefix=".tracecite-check-", dir=contract.staged_root.parent
175
+ )
176
+ )
177
+ except OSError as exc:
178
+ return DocsModeResult(
179
+ "check", False, manifest_path, (f"temporary staging setup failed: {exc}",)
180
+ )
181
+ try:
182
+ expected_retained = temporary / "retained"
183
+ shutil.copytree(
184
+ contract.retained_root,
185
+ expected_retained,
186
+ ignore=shutil.ignore_patterns(".tracecite-manifest.json"),
187
+ )
188
+ expected = replace(
189
+ contract, retained_root=expected_retained, staged_root=temporary
190
+ )
191
+ canonical_retained = contract.retained_root
192
+ stage_docs(
193
+ expected,
194
+ target="local",
195
+ repo_root=root,
196
+ link_markdown_root=canonical_retained,
197
+ )
198
+ stage_docs(
199
+ expected,
200
+ target="public",
201
+ repo_root=root,
202
+ link_markdown_root=canonical_retained,
203
+ )
204
+ expected_local = _staged_files(
205
+ expected.staged_root / "local", contract.staged_root / "local", root
206
+ )
207
+ expected_public = _staged_files(
208
+ expected.staged_root / "public", contract.staged_root / "public", root
209
+ )
210
+ actual_local = _staged_files(
211
+ contract.staged_root / "local", contract.staged_root / "local", root
212
+ )
213
+ actual_public = _staged_files(
214
+ contract.staged_root / "public", contract.staged_root / "public", root
215
+ )
216
+ retained = _repo_files(contract.retained_root, root, exclude=manifest_path)
217
+ manifest_retained = manifest.get("retained", {})
218
+ for label, actual, wanted in (
219
+ ("retained", retained, manifest_retained),
220
+ ("local staged", actual_local, expected_local),
221
+ ("public staged", actual_public, expected_public),
222
+ ):
223
+ if actual != wanted:
224
+ for path in sorted(set(actual) - set(wanted)):
225
+ diagnostics.append(f"{label} extra: {path}")
226
+ for path in sorted(set(wanted) - set(actual)):
227
+ diagnostics.append(f"{label} missing: {path}")
228
+ for path in sorted(set(actual) & set(wanted)):
229
+ if actual[path] != wanted[path]:
230
+ diagnostics.append(f"{label} hash mismatch: {path}")
231
+ recorded_public = manifest.get("public", {})
232
+ if recorded_public != expected_public:
233
+ diagnostics.append("public staged manifest is stale")
234
+ diagnostics.extend(docs_index_freshness_diagnostics(contract))
235
+ except Exception as exc: # noqa: BLE001
236
+ diagnostics.append(f"staging expectation failed: {exc}")
237
+ finally:
238
+ if temporary.exists():
239
+ shutil.rmtree(temporary)
240
+ return DocsModeResult("check", not diagnostics, manifest_path, tuple(diagnostics))
@@ -0,0 +1,407 @@
1
+ """Conservative, atomic schema-v3 local/public Markdown source-link staging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from ..evidence.source_links import (
14
+ SourceLinkEntry,
15
+ load_source_links,
16
+ parse_staged_markdown_destination,
17
+ parse_staged_source_destination,
18
+ )
19
+ from .contract import DocsEvidenceContract
20
+
21
+ StageTarget = Literal["local", "public"]
22
+ _DEFINITION = re.compile(
23
+ r"^(?P<prefix>\s{0,3}\[[^\]]+\]:\s*)(?P<destination>\S+)(?P<suffix>\s*)$"
24
+ )
25
+ _INLINE = re.compile(r"(?P<prefix>\[[^\]]+\]\()(?P<destination>[^\s)]+)(?P<suffix>\))")
26
+ _FENCE = re.compile(r"^\s{0,3}(?P<marker>`{3,}|~{3,})(?P<suffix>.*)$")
27
+ _HTML_TAG = re.compile(r"</?[A-Za-z][^>]*>")
28
+ _HTML_OPEN_TAG = re.compile(r"<(?P<tag>[A-Za-z][\w:-]*)\b[^>]*>")
29
+ _HTML_ELEMENT = re.compile(
30
+ r"<(?P<tag>[A-Za-z][\w:-]*)\b[^>]*>.*?</(?P=tag)\s*>", re.DOTALL
31
+ )
32
+ _HTML_BLOCK_TAGS = {
33
+ "address",
34
+ "article",
35
+ "aside",
36
+ "blockquote",
37
+ "body",
38
+ "caption",
39
+ "center",
40
+ "colgroup",
41
+ "dd",
42
+ "details",
43
+ "dialog",
44
+ "dir",
45
+ "div",
46
+ "dl",
47
+ "dt",
48
+ "fieldset",
49
+ "figcaption",
50
+ "figure",
51
+ "footer",
52
+ "form",
53
+ "h1",
54
+ "h2",
55
+ "h3",
56
+ "h4",
57
+ "h5",
58
+ "h6",
59
+ "head",
60
+ "header",
61
+ "hgroup",
62
+ "hr",
63
+ "html",
64
+ "legend",
65
+ "li",
66
+ "main",
67
+ "menu",
68
+ "nav",
69
+ "ol",
70
+ "p",
71
+ "pre",
72
+ "script",
73
+ "section",
74
+ "summary",
75
+ "table",
76
+ "tbody",
77
+ "td",
78
+ "tfoot",
79
+ "th",
80
+ "thead",
81
+ "title",
82
+ "tr",
83
+ "ul",
84
+ }
85
+ _TABLE_SEPARATOR = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
86
+
87
+
88
+ def _protected_ranges(
89
+ line: str, in_comment: bool
90
+ ) -> tuple[list[tuple[int, int]], bool]:
91
+ ranges: list[tuple[int, int]] = []
92
+ cursor = 0
93
+ while cursor < len(line):
94
+ if in_comment:
95
+ end = line.find("-->", cursor)
96
+ if end < 0:
97
+ ranges.append((cursor, len(line)))
98
+ return ranges, True
99
+ ranges.append((cursor, end + 3))
100
+ cursor = end + 3
101
+ in_comment = False
102
+ continue
103
+ start = line.find("<!--", cursor)
104
+ if start < 0:
105
+ break
106
+ cursor = start
107
+ in_comment = True
108
+ ranges.extend(
109
+ (match.start(), match.end()) for match in _HTML_ELEMENT.finditer(line)
110
+ )
111
+ for match in _HTML_TAG.finditer(line):
112
+ if match.group(0).startswith("</"):
113
+ continue
114
+ if not any(start <= match.start() < end for start, end in ranges):
115
+ ranges.append((match.start(), len(line)))
116
+ return ranges, in_comment
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class DocsStageResult:
121
+ target: StageTarget
122
+ staged_root: Path
123
+ changed_files: tuple[Path, ...]
124
+
125
+
126
+ def _registry_entries(
127
+ relative: str, markdown_path: Path, registry: dict[Path, list[SourceLinkEntry]]
128
+ ) -> list[SourceLinkEntry]:
129
+ return registry.get((markdown_path.parent / relative).resolve(), [])
130
+
131
+
132
+ def _routed_destination(
133
+ destination: str,
134
+ markdown_path: Path,
135
+ output_markdown_path: Path,
136
+ repo_root: Path,
137
+ registry: dict[Path, list[SourceLinkEntry]],
138
+ target: StageTarget,
139
+ ) -> str | None:
140
+ """Return the staged form of a source-link destination, or ``None`` to leave it unchanged.
141
+
142
+ A PDF ``#page=N`` destination must resolve to exactly one registry entry.
143
+ A local Markdown destination is routed only when it resolves to exactly
144
+ one entry; any other Markdown link is ordinary navigation.
145
+ """
146
+ if "://" in destination or "?" in destination:
147
+ return None
148
+ if ".pdf" in destination.lower() or "#page=" in destination:
149
+ parsed = parse_staged_source_destination(destination)
150
+ if parsed is None:
151
+ raise ValueError(f"malformed source-PDF destination: {destination}")
152
+ relative, page = parsed
153
+ entries = _registry_entries(relative, markdown_path, registry)
154
+ if len(entries) != 1:
155
+ raise ValueError(
156
+ f"source-link destination has {len(entries)} registry entries: {relative}"
157
+ )
158
+ fragment = f"#page={page}"
159
+ elif ".md" in destination.lower():
160
+ parsed = parse_staged_markdown_destination(destination)
161
+ if parsed is None:
162
+ return None
163
+ relative, anchor = parsed
164
+ entries = _registry_entries(relative, markdown_path, registry)
165
+ if len(entries) != 1:
166
+ return None
167
+ fragment = f"#{anchor}" if anchor else ""
168
+ else:
169
+ return None
170
+ if target == "public":
171
+ return f"{entries[0].public_url}{fragment}"
172
+ source = (repo_root / entries[0].local_path).resolve()
173
+ return f"{Path(os.path.relpath(source, output_markdown_path.parent)).as_posix()}{fragment}"
174
+
175
+
176
+ def _rewrite_markdown(
177
+ text: str,
178
+ markdown_path: Path,
179
+ output_markdown_path: Path,
180
+ repo_root: Path,
181
+ registry: dict[Path, list[SourceLinkEntry]],
182
+ target: StageTarget,
183
+ ) -> str:
184
+ output: list[str] = []
185
+ fenced = False
186
+ marker = ""
187
+ frontmatter = False
188
+ first_logical_line_seen = False
189
+ html_comment = False
190
+ html_block_tag: str | None = None
191
+ table_mode = False
192
+ lines = text.splitlines(keepends=True)
193
+ for index, line in enumerate(lines):
194
+ bare = line.rstrip("\r\n")
195
+ if not first_logical_line_seen and bare.strip() == "":
196
+ output.append(line)
197
+ continue
198
+ if not first_logical_line_seen and bare.strip() == "---":
199
+ frontmatter = True
200
+ first_logical_line_seen = True
201
+ output.append(line)
202
+ continue
203
+ first_logical_line_seen = True
204
+ if frontmatter:
205
+ output.append(line)
206
+ if bare.strip() == "---":
207
+ frontmatter = False
208
+ continue
209
+ if html_block_tag is not None:
210
+ output.append(line)
211
+ if re.search(rf"</{re.escape(html_block_tag)}\s*>", bare, re.IGNORECASE):
212
+ html_block_tag = None
213
+ continue
214
+ fence = _FENCE.match(bare)
215
+ if fenced:
216
+ output.append(line)
217
+ if (
218
+ fence
219
+ and fence.group("marker")[0] == marker
220
+ and not fence.group("suffix").strip()
221
+ ):
222
+ fenced = False
223
+ continue
224
+ if fence:
225
+ fenced = True
226
+ marker = fence.group("marker")[0]
227
+ output.append(line)
228
+ continue
229
+ next_bare = lines[index + 1].rstrip("\r\n") if index + 1 < len(lines) else ""
230
+ separator = bool(_TABLE_SEPARATOR.match(bare))
231
+ table_line = (
232
+ bare.lstrip().startswith("|")
233
+ or separator
234
+ or (table_mode and "|" in bare)
235
+ or ("|" in bare and bool(_TABLE_SEPARATOR.match(next_bare)))
236
+ )
237
+ if table_line:
238
+ table_mode = (
239
+ separator
240
+ or (table_mode and "|" in bare)
241
+ or bare.lstrip().startswith("|")
242
+ )
243
+ output.append(line)
244
+ continue
245
+ if not bare.strip():
246
+ table_mode = False
247
+ if bare.startswith((" ", "\t")):
248
+ output.append(line)
249
+ continue
250
+ protected, html_comment = _protected_ranges(bare, html_comment)
251
+ for opening in _HTML_OPEN_TAG.finditer(bare):
252
+ tag = opening.group("tag").lower()
253
+ if tag in _HTML_BLOCK_TAGS and not re.search(
254
+ rf"</{re.escape(tag)}\s*>", bare[opening.end() :], re.IGNORECASE
255
+ ):
256
+ html_block_tag = tag
257
+ break
258
+ definition = _DEFINITION.match(bare)
259
+ if definition and not any(
260
+ start < definition.end() and definition.start() < end
261
+ for start, end in protected
262
+ ):
263
+ replacement = _routed_destination(
264
+ definition.group("destination"),
265
+ markdown_path,
266
+ output_markdown_path,
267
+ repo_root,
268
+ registry,
269
+ target,
270
+ )
271
+ if replacement is not None:
272
+ line = (
273
+ f"{definition.group('prefix')}{replacement}{definition.group('suffix')}"
274
+ + line[len(bare) :]
275
+ )
276
+ output.append(line)
277
+ continue
278
+ cursor = 0
279
+ pieces: list[str] = []
280
+ for match in _INLINE.finditer(bare):
281
+ if bare[: match.start()].count("`") % 2 == 1:
282
+ continue
283
+ if any(
284
+ start < match.end() and match.start() < end for start, end in protected
285
+ ):
286
+ continue
287
+ if match.start() > 0 and bare[match.start() - 1] == "!":
288
+ continue
289
+ destination = match.group("destination")
290
+ if destination.startswith("<"):
291
+ continue
292
+ replacement = _routed_destination(
293
+ destination,
294
+ markdown_path,
295
+ output_markdown_path,
296
+ repo_root,
297
+ registry,
298
+ target,
299
+ )
300
+ if replacement is not None:
301
+ pieces.extend(
302
+ (
303
+ bare[cursor : match.start()],
304
+ match.group("prefix"),
305
+ replacement,
306
+ match.group("suffix"),
307
+ )
308
+ )
309
+ cursor = match.end()
310
+ if pieces:
311
+ pieces.append(bare[cursor:])
312
+ line = "".join(pieces) + line[len(bare) :]
313
+ output.append(line)
314
+ return "".join(output)
315
+
316
+
317
+ def stage_docs(
318
+ contract: DocsEvidenceContract,
319
+ *,
320
+ target: StageTarget,
321
+ repo_root: str | Path,
322
+ link_markdown_root: Path | None = None,
323
+ ) -> DocsStageResult:
324
+ if target not in {"local", "public"}:
325
+ raise ValueError("target must be local or public")
326
+ root = Path(repo_root).resolve()
327
+ registry, issues = load_source_links(contract.source_links, root)
328
+ if issues:
329
+ raise ValueError("invalid source-links registry: " + "; ".join(issues))
330
+ contract.staged_root.parent.mkdir(parents=True, exist_ok=True)
331
+ contract.staged_root.mkdir(parents=True, exist_ok=True)
332
+ temporary = Path(
333
+ tempfile.mkdtemp(prefix=f".{target}-", dir=contract.staged_root.parent)
334
+ )
335
+ target_path = contract.staged_root / target
336
+ backup = target_path.with_name(f".{target}.previous")
337
+ try:
338
+ shutil.copytree(contract.retained_root, temporary, dirs_exist_ok=True)
339
+ changed: list[Path] = []
340
+ link_root = link_markdown_root or contract.retained_root
341
+ if target == "public":
342
+ for entries in registry.values():
343
+ for entry in entries:
344
+ source = (root / entry.local_path).resolve()
345
+ try:
346
+ relative = source.relative_to(link_root.resolve())
347
+ except ValueError:
348
+ continue
349
+ candidate = temporary / relative
350
+ if candidate.is_file():
351
+ candidate.unlink()
352
+ for path in temporary.rglob("*.md"):
353
+ original = path.read_text(encoding="utf-8")
354
+ relative = path.relative_to(temporary)
355
+ link_path = link_root / relative
356
+ transformed = _rewrite_markdown(
357
+ original, link_path, target_path / relative, root, registry, target
358
+ )
359
+ if transformed != original:
360
+ path.write_text(transformed, encoding="utf-8")
361
+ changed.append(path.relative_to(temporary))
362
+ if backup.exists():
363
+ shutil.rmtree(backup)
364
+ if target_path.exists():
365
+ os.replace(target_path, backup)
366
+ os.replace(temporary, target_path)
367
+ if backup.exists():
368
+ shutil.rmtree(backup)
369
+ except Exception:
370
+ if temporary.exists():
371
+ shutil.rmtree(temporary)
372
+ if backup.exists() and not target_path.exists():
373
+ os.replace(backup, target_path)
374
+ raise
375
+ return DocsStageResult(target, target_path, tuple(changed))
376
+
377
+
378
+ def is_publication_excluded(path: Path, contract: DocsEvidenceContract) -> bool:
379
+ resolved = path.resolve()
380
+ return any(
381
+ resolved == target or (target.is_dir() and resolved.is_relative_to(target))
382
+ for target in (exclude.resolve() for exclude in contract.publication_exclude)
383
+ )
384
+
385
+
386
+ def validate_retained_source_links(
387
+ contract: DocsEvidenceContract,
388
+ *,
389
+ repo_root: str | Path,
390
+ ) -> tuple[str, ...]:
391
+ """Validate source-PDF/Markdown link candidates in retained Markdown without rewriting."""
392
+ root = Path(repo_root).resolve()
393
+ registry, issues = load_source_links(contract.source_links, root)
394
+ if issues:
395
+ return tuple(f"source-links registry: {issue}" for issue in issues)
396
+ diagnostics: list[str] = []
397
+ for path in sorted(contract.retained_root.rglob("*.md")):
398
+ if is_publication_excluded(path, contract):
399
+ continue
400
+ relative = path.relative_to(contract.retained_root).as_posix()
401
+ try:
402
+ _rewrite_markdown(
403
+ path.read_text(encoding="utf-8"), path, path, root, registry, "local"
404
+ )
405
+ except ValueError as exc:
406
+ diagnostics.append(f"{relative}: {exc}")
407
+ return tuple(diagnostics)