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.
- tracecite/__init__.py +35 -0
- tracecite/__main__.py +3 -0
- tracecite/cli.py +656 -0
- tracecite/docs/__init__.py +56 -0
- tracecite/docs/build.py +381 -0
- tracecite/docs/config.py +114 -0
- tracecite/docs/contract.py +17 -0
- tracecite/docs/modes.py +240 -0
- tracecite/docs/stage.py +407 -0
- tracecite/docs/vectorize.py +457 -0
- tracecite/evidence/__init__.py +3 -0
- tracecite/evidence/chunking.py +214 -0
- tracecite/evidence/commands.py +552 -0
- tracecite/evidence/config.py +49 -0
- tracecite/evidence/embedding.py +59 -0
- tracecite/evidence/manifest.py +197 -0
- tracecite/evidence/page_extraction.py +200 -0
- tracecite/evidence/page_output.py +80 -0
- tracecite/evidence/page_selection.py +116 -0
- tracecite/evidence/parsers/__init__.py +1 -0
- tracecite/evidence/parsers/base.py +65 -0
- tracecite/evidence/parsers/markdown.py +142 -0
- tracecite/evidence/parsers/pdf.py +315 -0
- tracecite/evidence/parsers/workbook.py +334 -0
- tracecite/evidence/paths.py +94 -0
- tracecite/evidence/schema.py +570 -0
- tracecite/evidence/source_links.py +185 -0
- tracecite/evidence/sync.py +1434 -0
- tracecite/evidence/vector_backend.py +130 -0
- tracecite/evidence/verify.py +368 -0
- tracecite/skills/routing-documentation-source-links/SKILL.md +167 -0
- tracecite/skills/routing-documentation-source-links/agents/openai.yaml +3 -0
- tracecite/skills/routing-documentation-source-links/references/documenter-integration.md +71 -0
- tracecite/skills/routing-documentation-source-links/references/registry-contract.md +86 -0
- tracecite/skills/routing-documentation-source-links/references/staging-and-rewriting.md +118 -0
- tracecite/skills/routing-documentation-source-links/references/validation-and-failure-modes.md +67 -0
- tracecite/skills/using-tracecite/SKILL.md +97 -0
- tracecite/skills/using-tracecite/agents/openai.yaml +3 -0
- tracecite/skills/using-tracecite/references/architecture.md +68 -0
- tracecite/skills/using-tracecite/references/pdf-and-assets.md +67 -0
- tracecite/skills/using-tracecite/references/retrieval.md +45 -0
- tracecite/skills/using-tracecite/references/schema-and-migrations.md +52 -0
- tracecite/skills/using-tracecite/references/synchronisation.md +98 -0
- tracecite/skills/using-tracecite/references/workspace-runtime.md +44 -0
- tracecite/skills/writing-evidence-backed-reports/SKILL.md +91 -0
- tracecite/skills/writing-evidence-backed-reports/agents/openai.yaml +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/README.md +13 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-bad-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-grammar.md +8 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-missing-definition.md +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-page-not-indexed.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-unindexed-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-grammar.md +12 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-report.md +27 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/source-links.toml +17 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/validate_fixtures.py +295 -0
- tracecite/skills/writing-evidence-backed-reports/references/images-and-source-links.md +34 -0
- tracecite/skills/writing-evidence-backed-reports/references/quotation-and-verification.md +43 -0
- tracecite/skills/writing-evidence-backed-reports/references/reference-style-citations.md +48 -0
- tracecite/skills/writing-evidence-backed-reports/references/report-structure.md +58 -0
- tracecite/tables/__init__.py +41 -0
- tracecite/tables/_text.py +142 -0
- tracecite/tables/document.py +582 -0
- tracecite/tables/html.py +299 -0
- tracecite/tables/models.py +119 -0
- tracecite/tables/normalise.py +786 -0
- tracecite/tables/pandoc.py +503 -0
- tracecite/tables/publish.py +238 -0
- tracecite/tables/render.py +128 -0
- tracecite/tables/site.py +457 -0
- tracecite-0.0.2.dist-info/METADATA +320 -0
- tracecite-0.0.2.dist-info/RECORD +76 -0
- tracecite-0.0.2.dist-info/WHEEL +5 -0
- tracecite-0.0.2.dist-info/entry_points.txt +2 -0
- tracecite-0.0.2.dist-info/licenses/LICENSE +21 -0
- tracecite-0.0.2.dist-info/top_level.txt +1 -0
tracecite/docs/modes.py
ADDED
|
@@ -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))
|
tracecite/docs/stage.py
ADDED
|
@@ -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)
|