documentation-engine 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docsystem/__init__.py +22 -0
- docsystem/__main__.py +4 -0
- docsystem/catalog.py +665 -0
- docsystem/cli.py +2573 -0
- docsystem/config.py +216 -0
- docsystem/mcp_server.py +324 -0
- docsystem/metadata.py +307 -0
- docsystem/migration.py +309 -0
- docsystem/projection.py +609 -0
- docsystem/readiness.py +132 -0
- docsystem/sections.py +252 -0
- documentation_engine-0.1.2.dist-info/METADATA +417 -0
- documentation_engine-0.1.2.dist-info/RECORD +16 -0
- documentation_engine-0.1.2.dist-info/WHEEL +4 -0
- documentation_engine-0.1.2.dist-info/entry_points.txt +3 -0
- documentation_engine-0.1.2.dist-info/licenses/LICENSE +21 -0
docsystem/metadata.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""YAML front matter models and deterministic parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from yaml.constructor import ConstructorError
|
|
11
|
+
from yaml.nodes import MappingNode
|
|
12
|
+
from yaml.resolver import BaseResolver
|
|
13
|
+
|
|
14
|
+
RELATION_FIELDS = ("derived_from", "depends_on", "related", "supersedes")
|
|
15
|
+
PINNED_RELATION = "validated_against"
|
|
16
|
+
DOCUMENT_ID_PATTERN = re.compile(r"^([A-Z][A-Z0-9]{1,15})-([0-9]+)$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class MetadataReference:
|
|
21
|
+
"""A normalized semantic reference from one document to another."""
|
|
22
|
+
|
|
23
|
+
relation: str
|
|
24
|
+
target_id: str
|
|
25
|
+
expected_revision: int | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class DocumentMetadata:
|
|
30
|
+
"""The provider-neutral metadata required by the context engine."""
|
|
31
|
+
|
|
32
|
+
document_id: str
|
|
33
|
+
revision: int
|
|
34
|
+
document_type: str | None
|
|
35
|
+
status: str | None
|
|
36
|
+
references: tuple[MetadataReference, ...]
|
|
37
|
+
additional_fields: tuple[tuple[str, object], ...]
|
|
38
|
+
legacy_references: tuple[tuple[str, str], ...] = ()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class FrontMatterResult:
|
|
43
|
+
"""Parsed metadata plus recoverable validation messages."""
|
|
44
|
+
|
|
45
|
+
metadata: DocumentMetadata | None
|
|
46
|
+
end_line: int
|
|
47
|
+
issues: tuple[str, ...]
|
|
48
|
+
graph_issues: tuple[str, ...]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DuplicateKeyError(yaml.YAMLError):
|
|
52
|
+
"""A duplicate YAML mapping key with a Markdown source location."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UniqueKeySafeLoader(yaml.SafeLoader):
|
|
56
|
+
"""Safe YAML loader that rejects duplicate keys at every mapping level."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _construct_unique_mapping(
|
|
60
|
+
loader: UniqueKeySafeLoader, node: MappingNode, deep: bool = False
|
|
61
|
+
) -> dict[object, object]:
|
|
62
|
+
if not isinstance(node, MappingNode):
|
|
63
|
+
raise ConstructorError(
|
|
64
|
+
None,
|
|
65
|
+
None,
|
|
66
|
+
f"expected a mapping node, but found {node.id}",
|
|
67
|
+
node.start_mark,
|
|
68
|
+
)
|
|
69
|
+
loader.flatten_mapping(node)
|
|
70
|
+
mapping: dict[object, object] = {}
|
|
71
|
+
for key_node, value_node in node.value:
|
|
72
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
73
|
+
try:
|
|
74
|
+
duplicate = key in mapping
|
|
75
|
+
except TypeError as error:
|
|
76
|
+
raise ConstructorError(
|
|
77
|
+
"while constructing a mapping",
|
|
78
|
+
node.start_mark,
|
|
79
|
+
"found an unhashable key",
|
|
80
|
+
key_node.start_mark,
|
|
81
|
+
) from error
|
|
82
|
+
if duplicate:
|
|
83
|
+
line = key_node.start_mark.line + 2
|
|
84
|
+
column = key_node.start_mark.column + 1
|
|
85
|
+
raise DuplicateKeyError(
|
|
86
|
+
f"duplicate mapping key {key!r} at line {line}, column {column}"
|
|
87
|
+
)
|
|
88
|
+
mapping[key] = loader.construct_object(value_node, deep=deep)
|
|
89
|
+
return mapping
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
UniqueKeySafeLoader.add_constructor(
|
|
93
|
+
BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _freeze(value: Any) -> object:
|
|
98
|
+
if isinstance(value, dict):
|
|
99
|
+
return tuple(
|
|
100
|
+
(str(key), _freeze(item))
|
|
101
|
+
for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
|
|
102
|
+
)
|
|
103
|
+
if isinstance(value, list):
|
|
104
|
+
return tuple(_freeze(item) for item in value)
|
|
105
|
+
if isinstance(value, set):
|
|
106
|
+
return tuple(sorted((_freeze(item) for item in value), key=repr))
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _valid_id(value: object, prefixes: frozenset[str]) -> bool:
|
|
111
|
+
if not isinstance(value, str):
|
|
112
|
+
return False
|
|
113
|
+
match = DOCUMENT_ID_PATTERN.fullmatch(value)
|
|
114
|
+
return match is not None and match.group(1) in prefixes
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _optional_string(raw: dict[str, Any], field: str, issues: list[str]) -> str | None:
|
|
118
|
+
value = raw.get(field)
|
|
119
|
+
if value is None:
|
|
120
|
+
return None
|
|
121
|
+
if not isinstance(value, str) or not value:
|
|
122
|
+
issues.append(f"metadata.{field} must be a non-empty string when present")
|
|
123
|
+
return None
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _references(
|
|
128
|
+
raw: dict[str, Any],
|
|
129
|
+
prefixes: frozenset[str],
|
|
130
|
+
issues: list[str],
|
|
131
|
+
graph_issues: list[str],
|
|
132
|
+
) -> tuple[tuple[MetadataReference, ...], tuple[tuple[str, str], ...]]:
|
|
133
|
+
def report(message: str) -> None:
|
|
134
|
+
issues.append(message)
|
|
135
|
+
graph_issues.append(message)
|
|
136
|
+
|
|
137
|
+
references: list[MetadataReference] = []
|
|
138
|
+
legacy_references: list[tuple[str, str]] = []
|
|
139
|
+
for relation in RELATION_FIELDS:
|
|
140
|
+
values = raw.get(relation, [])
|
|
141
|
+
if not isinstance(values, list):
|
|
142
|
+
report(f"metadata.{relation} must be a list")
|
|
143
|
+
continue
|
|
144
|
+
seen: set[str] = set()
|
|
145
|
+
for value in values:
|
|
146
|
+
if isinstance(value, str):
|
|
147
|
+
if value in seen:
|
|
148
|
+
report(
|
|
149
|
+
f"metadata.{relation} contains duplicate reference {value}"
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
seen.add(value)
|
|
153
|
+
if not _valid_id(value, prefixes):
|
|
154
|
+
id_shaped = (
|
|
155
|
+
isinstance(value, str)
|
|
156
|
+
and DOCUMENT_ID_PATTERN.fullmatch(value) is not None
|
|
157
|
+
)
|
|
158
|
+
if isinstance(value, str) and value and not id_shaped:
|
|
159
|
+
# A non-ID plain string is always a legacy-reference
|
|
160
|
+
# candidate; the catalog layer resolves it against
|
|
161
|
+
# `relations.legacy_paths` and classifies it as either a
|
|
162
|
+
# migratable document relation or a permanent boundary
|
|
163
|
+
# (URL/resource) that is never itself a stable ID.
|
|
164
|
+
legacy_references.append((relation, value))
|
|
165
|
+
continue
|
|
166
|
+
report(
|
|
167
|
+
f"metadata.{relation} entry {value!r} must use a configured "
|
|
168
|
+
"stable ID"
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
assert isinstance(value, str)
|
|
172
|
+
references.append(MetadataReference(relation, value))
|
|
173
|
+
|
|
174
|
+
pins = raw.get(PINNED_RELATION, [])
|
|
175
|
+
if not isinstance(pins, list):
|
|
176
|
+
report(f"metadata.{PINNED_RELATION} must be a list")
|
|
177
|
+
else:
|
|
178
|
+
seen_pins: set[tuple[str, int]] = set()
|
|
179
|
+
revisions_by_target: dict[str, set[int]] = {}
|
|
180
|
+
for value in pins:
|
|
181
|
+
if not isinstance(value, str) or "@" not in value:
|
|
182
|
+
report(f"metadata.{PINNED_RELATION} entries must use ID@revision")
|
|
183
|
+
continue
|
|
184
|
+
target_id, revision_raw = value.rsplit("@", 1)
|
|
185
|
+
if not _valid_id(target_id, prefixes) or not revision_raw.isdigit():
|
|
186
|
+
report(f"metadata.{PINNED_RELATION} entries must use ID@revision")
|
|
187
|
+
continue
|
|
188
|
+
revision = int(revision_raw)
|
|
189
|
+
if revision < 1:
|
|
190
|
+
report(f"metadata.{PINNED_RELATION} revisions must be positive")
|
|
191
|
+
continue
|
|
192
|
+
key = (target_id, revision)
|
|
193
|
+
if key in seen_pins:
|
|
194
|
+
report(
|
|
195
|
+
f"metadata.{PINNED_RELATION} contains duplicate reference {value}"
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
seen_pins.add(key)
|
|
199
|
+
revisions = revisions_by_target.setdefault(target_id, set())
|
|
200
|
+
if revisions:
|
|
201
|
+
previous_revision = min(revisions)
|
|
202
|
+
report(
|
|
203
|
+
f"metadata.{PINNED_RELATION} has conflicting revisions for "
|
|
204
|
+
f"{target_id}: {previous_revision} and {revision}"
|
|
205
|
+
)
|
|
206
|
+
revisions.add(revision)
|
|
207
|
+
for target_id, revisions in revisions_by_target.items():
|
|
208
|
+
if len(revisions) != 1:
|
|
209
|
+
continue
|
|
210
|
+
revision = next(iter(revisions))
|
|
211
|
+
references.append(
|
|
212
|
+
MetadataReference(PINNED_RELATION, target_id, revision)
|
|
213
|
+
)
|
|
214
|
+
return tuple(references), tuple(legacy_references)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def parse_front_matter(
|
|
218
|
+
text: str,
|
|
219
|
+
prefixes: frozenset[str],
|
|
220
|
+
) -> FrontMatterResult:
|
|
221
|
+
"""Parse leading YAML front matter without rejecting additional fields."""
|
|
222
|
+
|
|
223
|
+
lines = text.splitlines()
|
|
224
|
+
if not lines or lines[0].strip() != "---":
|
|
225
|
+
message = "YAML front matter is required"
|
|
226
|
+
return FrontMatterResult(None, 0, (message,), (message,))
|
|
227
|
+
|
|
228
|
+
closing_line = next(
|
|
229
|
+
(index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---"),
|
|
230
|
+
None,
|
|
231
|
+
)
|
|
232
|
+
if closing_line is None:
|
|
233
|
+
message = "YAML front matter is not closed"
|
|
234
|
+
return FrontMatterResult(None, 0, (message,), (message,))
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
loaded = yaml.load(
|
|
238
|
+
"\n".join(lines[1:closing_line]), Loader=UniqueKeySafeLoader
|
|
239
|
+
)
|
|
240
|
+
except yaml.YAMLError as error:
|
|
241
|
+
summary = str(error).splitlines()[0]
|
|
242
|
+
message = f"invalid YAML front matter: {summary}"
|
|
243
|
+
return FrontMatterResult(
|
|
244
|
+
None,
|
|
245
|
+
closing_line + 1,
|
|
246
|
+
(message,),
|
|
247
|
+
(message,),
|
|
248
|
+
)
|
|
249
|
+
if not isinstance(loaded, dict):
|
|
250
|
+
message = "YAML front matter must be a mapping"
|
|
251
|
+
return FrontMatterResult(
|
|
252
|
+
None,
|
|
253
|
+
closing_line + 1,
|
|
254
|
+
(message,),
|
|
255
|
+
(message,),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
raw = {str(key): value for key, value in loaded.items()}
|
|
259
|
+
issues: list[str] = []
|
|
260
|
+
graph_issues: list[str] = []
|
|
261
|
+
document_id = raw.get("id")
|
|
262
|
+
if not _valid_id(document_id, prefixes):
|
|
263
|
+
message = "metadata.id must use a configured stable ID prefix"
|
|
264
|
+
issues.append(message)
|
|
265
|
+
graph_issues.append(message)
|
|
266
|
+
revision = raw.get("revision")
|
|
267
|
+
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1:
|
|
268
|
+
message = "metadata.revision must be a positive integer"
|
|
269
|
+
issues.append(message)
|
|
270
|
+
graph_issues.append(message)
|
|
271
|
+
|
|
272
|
+
document_type = _optional_string(raw, "type", issues)
|
|
273
|
+
status = _optional_string(raw, "status", issues)
|
|
274
|
+
references, legacy_references = _references(raw, prefixes, issues, graph_issues)
|
|
275
|
+
metadata: DocumentMetadata | None = None
|
|
276
|
+
if _valid_id(document_id, prefixes) and isinstance(revision, int) and not isinstance(
|
|
277
|
+
revision, bool
|
|
278
|
+
) and revision >= 1:
|
|
279
|
+
known = {
|
|
280
|
+
"id",
|
|
281
|
+
"revision",
|
|
282
|
+
"type",
|
|
283
|
+
"status",
|
|
284
|
+
*RELATION_FIELDS,
|
|
285
|
+
PINNED_RELATION,
|
|
286
|
+
}
|
|
287
|
+
additional = tuple(
|
|
288
|
+
(key, _freeze(value))
|
|
289
|
+
for key, value in sorted(raw.items())
|
|
290
|
+
if key not in known
|
|
291
|
+
)
|
|
292
|
+
assert isinstance(document_id, str)
|
|
293
|
+
metadata = DocumentMetadata(
|
|
294
|
+
document_id=document_id,
|
|
295
|
+
revision=revision,
|
|
296
|
+
document_type=document_type,
|
|
297
|
+
status=status,
|
|
298
|
+
references=references,
|
|
299
|
+
additional_fields=additional,
|
|
300
|
+
legacy_references=legacy_references,
|
|
301
|
+
)
|
|
302
|
+
return FrontMatterResult(
|
|
303
|
+
metadata,
|
|
304
|
+
closing_line + 1,
|
|
305
|
+
tuple(issues),
|
|
306
|
+
tuple(graph_issues),
|
|
307
|
+
)
|
docsystem/migration.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Deterministic, atomic migration of resolved legacy relation values.
|
|
2
|
+
|
|
3
|
+
This module rewrites only the exact YAML scalar spans for legacy relative
|
|
4
|
+
path values in `derived_from`, `depends_on`, `related` and `supersedes` that
|
|
5
|
+
`build_catalog` already classified as unambiguously resolved
|
|
6
|
+
(`MarkdownCatalog.relation_migrations`). Nothing else in the file — the rest
|
|
7
|
+
of the front matter, unknown fields, comments, quoting style of untouched
|
|
8
|
+
values and the document body — is changed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import tempfile
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
from itertools import pairwise
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from docsystem.catalog import (
|
|
23
|
+
MarkdownCatalog,
|
|
24
|
+
RelationMigration,
|
|
25
|
+
build_catalog,
|
|
26
|
+
validate_catalog,
|
|
27
|
+
)
|
|
28
|
+
from docsystem.config import ProjectConfig
|
|
29
|
+
|
|
30
|
+
_ANCHOR_PREFIX = re.compile(r"&\S+[ \t]+")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class MigrationChange:
|
|
35
|
+
"""One deterministic legacy-value-to-stable-ID replacement."""
|
|
36
|
+
|
|
37
|
+
path: PurePosixPath
|
|
38
|
+
source_id: str
|
|
39
|
+
relation: str
|
|
40
|
+
old_value: str
|
|
41
|
+
new_value: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class MigrationPlan:
|
|
46
|
+
"""A computed, not-yet-validated set of source-file rewrites."""
|
|
47
|
+
|
|
48
|
+
changes: tuple[MigrationChange, ...]
|
|
49
|
+
updated_contents: tuple[tuple[PurePosixPath, str], ...]
|
|
50
|
+
original_contents: tuple[tuple[PurePosixPath, str], ...]
|
|
51
|
+
blocking_issues: tuple[str, ...]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _front_matter_bounds(content: str) -> tuple[int, int] | None:
|
|
55
|
+
"""Return the (start, end) line indices of the YAML front matter block."""
|
|
56
|
+
|
|
57
|
+
lines = content.splitlines(keepends=True)
|
|
58
|
+
if not lines or lines[0].strip() != "---":
|
|
59
|
+
return None
|
|
60
|
+
closing = next(
|
|
61
|
+
(index for index in range(1, len(lines)) if lines[index].strip() == "---"),
|
|
62
|
+
None,
|
|
63
|
+
)
|
|
64
|
+
if closing is None:
|
|
65
|
+
return None
|
|
66
|
+
return 1, closing
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _rewrite_yaml_values(
|
|
70
|
+
yaml_text: str, replacements: list[tuple[str, str, str]]
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Replace only the exact scalar spans matching `(relation, old_value)`.
|
|
73
|
+
|
|
74
|
+
A YAML anchor and every alias referencing it compose to the *same*
|
|
75
|
+
node object, with marks pointing at the anchor's definition. Two
|
|
76
|
+
relations that share one legacy value through such an anchor/alias
|
|
77
|
+
therefore produce two logical replacements for one physical span; those
|
|
78
|
+
are only safe to fold into a single physical rewrite when they agree on
|
|
79
|
+
the replacement text, otherwise the physical span is rewritten twice
|
|
80
|
+
against stale offsets and the file is corrupted.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
node = yaml.compose(yaml_text, Loader=yaml.SafeLoader)
|
|
84
|
+
if not isinstance(node, yaml.MappingNode):
|
|
85
|
+
raise ValueError("front matter is not a YAML mapping")
|
|
86
|
+
|
|
87
|
+
remaining = list(replacements)
|
|
88
|
+
spans: list[tuple[int, int, str, str, str]] = []
|
|
89
|
+
for key_node, value_node in node.value:
|
|
90
|
+
if not isinstance(value_node, yaml.SequenceNode):
|
|
91
|
+
continue
|
|
92
|
+
relation = key_node.value
|
|
93
|
+
for item in value_node.value:
|
|
94
|
+
if not isinstance(item, yaml.ScalarNode):
|
|
95
|
+
continue
|
|
96
|
+
for index, (want_relation, want_old, want_new) in enumerate(remaining):
|
|
97
|
+
if want_relation == relation and item.value == want_old:
|
|
98
|
+
start, end = item.start_mark.index, item.end_mark.index
|
|
99
|
+
# An anchored scalar's marks span its `&name ` tag too;
|
|
100
|
+
# skip it so the tag survives the rewrite and any alias
|
|
101
|
+
# elsewhere referencing it keeps resolving.
|
|
102
|
+
anchor_match = _ANCHOR_PREFIX.match(yaml_text, start, end)
|
|
103
|
+
if anchor_match is not None:
|
|
104
|
+
start = anchor_match.end()
|
|
105
|
+
spans.append((start, end, want_new, relation, want_old))
|
|
106
|
+
del remaining[index]
|
|
107
|
+
break
|
|
108
|
+
if remaining:
|
|
109
|
+
unmatched = ", ".join(
|
|
110
|
+
f"{relation}={old!r}" for relation, old, _ in remaining
|
|
111
|
+
)
|
|
112
|
+
raise ValueError(f"could not locate legacy value(s) to rewrite: {unmatched}")
|
|
113
|
+
|
|
114
|
+
spans_by_range: dict[tuple[int, int], list[tuple[int, int, str, str, str]]] = {}
|
|
115
|
+
for span in spans:
|
|
116
|
+
spans_by_range.setdefault((span[0], span[1]), []).append(span)
|
|
117
|
+
|
|
118
|
+
deduped: list[tuple[int, int, str]] = []
|
|
119
|
+
for (start, end), group in spans_by_range.items():
|
|
120
|
+
new_values = {entry[2] for entry in group}
|
|
121
|
+
if len(new_values) > 1:
|
|
122
|
+
detail = ", ".join(
|
|
123
|
+
f"{relation}={old!r}->{new!r}" for _, _, new, relation, old in group
|
|
124
|
+
)
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"unsupported YAML anchor/alias: one physical value is shared by "
|
|
127
|
+
f"relations that resolve to different stable IDs ({detail}); "
|
|
128
|
+
"migrate this document manually"
|
|
129
|
+
)
|
|
130
|
+
deduped.append((start, end, group[0][2]))
|
|
131
|
+
|
|
132
|
+
ordered = sorted(deduped, key=lambda span: span[0])
|
|
133
|
+
for previous, current in pairwise(ordered):
|
|
134
|
+
if current[0] < previous[1]:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
"unsupported YAML anchor/alias: overlapping physical replacement "
|
|
137
|
+
f"spans at {previous[:2]} and {current[:2]}; migrate this "
|
|
138
|
+
"document manually"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
new_text = yaml_text
|
|
142
|
+
for start, end, new_value in sorted(deduped, key=lambda span: span[0], reverse=True):
|
|
143
|
+
new_text = new_text[:start] + new_value + new_text[end:]
|
|
144
|
+
return new_text
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _apply_relation_rewrites(
|
|
148
|
+
content: str, replacements: list[tuple[str, str, str]]
|
|
149
|
+
) -> str:
|
|
150
|
+
bounds = _front_matter_bounds(content)
|
|
151
|
+
if bounds is None:
|
|
152
|
+
raise ValueError("YAML front matter is required")
|
|
153
|
+
start, end = bounds
|
|
154
|
+
lines = content.splitlines(keepends=True)
|
|
155
|
+
yaml_text = "".join(lines[start:end])
|
|
156
|
+
new_yaml_text = _rewrite_yaml_values(yaml_text, replacements)
|
|
157
|
+
return "".join(lines[:start]) + new_yaml_text + "".join(lines[end:])
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_migration_plan(config: ProjectConfig, catalog: MarkdownCatalog) -> MigrationPlan:
|
|
161
|
+
"""Compute (without writing) the rewrite for every resolved legacy value."""
|
|
162
|
+
|
|
163
|
+
root = config.documentation_root
|
|
164
|
+
paths_by_source_id = {
|
|
165
|
+
document.metadata.document_id: document.path
|
|
166
|
+
for document in catalog.documents
|
|
167
|
+
if document.metadata is not None
|
|
168
|
+
}
|
|
169
|
+
grouped: dict[PurePosixPath, list[RelationMigration]] = {}
|
|
170
|
+
for item in catalog.relation_migrations:
|
|
171
|
+
path = paths_by_source_id[item.source_id]
|
|
172
|
+
grouped.setdefault(path, []).append(item)
|
|
173
|
+
|
|
174
|
+
changes: list[MigrationChange] = []
|
|
175
|
+
updated_contents: list[tuple[PurePosixPath, str]] = []
|
|
176
|
+
original_contents: list[tuple[PurePosixPath, str]] = []
|
|
177
|
+
blocking_issues: list[str] = []
|
|
178
|
+
for path in sorted(grouped, key=PurePosixPath.as_posix):
|
|
179
|
+
items = grouped[path]
|
|
180
|
+
original = (root / path).read_bytes().decode("utf-8")
|
|
181
|
+
try:
|
|
182
|
+
new_content = _apply_relation_rewrites(
|
|
183
|
+
original,
|
|
184
|
+
[(item.relation, item.value, item.target_id) for item in items],
|
|
185
|
+
)
|
|
186
|
+
except ValueError as error:
|
|
187
|
+
blocking_issues.append(f"{path.as_posix()}: {error}")
|
|
188
|
+
continue
|
|
189
|
+
updated_contents.append((path, new_content))
|
|
190
|
+
original_contents.append((path, original))
|
|
191
|
+
changes.extend(
|
|
192
|
+
MigrationChange(path, item.source_id, item.relation, item.value, item.target_id)
|
|
193
|
+
for item in items
|
|
194
|
+
)
|
|
195
|
+
def _change_key(change: MigrationChange) -> tuple[str, str, str]:
|
|
196
|
+
return (change.path.as_posix(), change.relation, change.old_value)
|
|
197
|
+
|
|
198
|
+
return MigrationPlan(
|
|
199
|
+
changes=tuple(sorted(changes, key=_change_key)),
|
|
200
|
+
updated_contents=tuple(updated_contents),
|
|
201
|
+
original_contents=tuple(original_contents),
|
|
202
|
+
blocking_issues=tuple(blocking_issues),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def validate_plan(config: ProjectConfig, plan: MigrationPlan) -> tuple[str, ...]:
|
|
207
|
+
"""Re-validate the whole catalog as if the plan were already applied.
|
|
208
|
+
|
|
209
|
+
The check runs against a scratch copy of the documentation root so a
|
|
210
|
+
preview or a failed validation never touches the real project files.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
if plan.blocking_issues:
|
|
214
|
+
return plan.blocking_issues
|
|
215
|
+
if not plan.updated_contents:
|
|
216
|
+
return ()
|
|
217
|
+
|
|
218
|
+
doc_relative = config.documentation_root.relative_to(config.project_root)
|
|
219
|
+
with tempfile.TemporaryDirectory(prefix="docsystem-migrate-") as staging:
|
|
220
|
+
staging_root = Path(staging)
|
|
221
|
+
staged_project_root = staging_root / "project"
|
|
222
|
+
staged_documentation_root = staged_project_root / doc_relative
|
|
223
|
+
shutil.copytree(config.documentation_root, staged_documentation_root)
|
|
224
|
+
for path, new_content in plan.updated_contents:
|
|
225
|
+
(staged_documentation_root / path).write_bytes(
|
|
226
|
+
new_content.encode("utf-8")
|
|
227
|
+
)
|
|
228
|
+
staged_config = replace(
|
|
229
|
+
config,
|
|
230
|
+
project_root=staged_project_root,
|
|
231
|
+
documentation_root=staged_documentation_root,
|
|
232
|
+
)
|
|
233
|
+
staged_catalog = build_catalog(staged_config)
|
|
234
|
+
problems = [
|
|
235
|
+
f"{issue.path.as_posix()}: {issue.message}"
|
|
236
|
+
for issue in validate_catalog(staged_catalog, staged_config)
|
|
237
|
+
if issue.severity != "warning"
|
|
238
|
+
]
|
|
239
|
+
touched_ids = {change.source_id for change in plan.changes}
|
|
240
|
+
remaining = [
|
|
241
|
+
item
|
|
242
|
+
for item in staged_catalog.relation_migrations
|
|
243
|
+
if item.source_id in touched_ids
|
|
244
|
+
]
|
|
245
|
+
if remaining:
|
|
246
|
+
unresolved = ", ".join(
|
|
247
|
+
f"{item.source_id}.{item.relation}={item.value!r}" for item in remaining
|
|
248
|
+
)
|
|
249
|
+
problems.append(
|
|
250
|
+
f"migration is not idempotent; still resolvable after apply: {unresolved}"
|
|
251
|
+
)
|
|
252
|
+
return tuple(problems)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def apply_migration_plan(config: ProjectConfig, plan: MigrationPlan) -> None:
|
|
256
|
+
"""Write every planned change atomically, or leave the tree untouched.
|
|
257
|
+
|
|
258
|
+
Every source file is first re-read and compared against the content the
|
|
259
|
+
plan was computed from; a mismatch (for example, a concurrent edit
|
|
260
|
+
between planning and applying) aborts before touching any file. Every new
|
|
261
|
+
file is then first written to a sibling temporary file; only once every
|
|
262
|
+
temporary file has been written successfully are the files renamed into
|
|
263
|
+
place. If a rename fails partway through, already-renamed files are
|
|
264
|
+
restored from their cached original bytes so a mid-migration OS failure
|
|
265
|
+
cannot leave a partially migrated multi-file change.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
root = config.documentation_root
|
|
269
|
+
expected_originals = dict(plan.original_contents)
|
|
270
|
+
originals: dict[Path, bytes] = {}
|
|
271
|
+
for path, _ in plan.updated_contents:
|
|
272
|
+
final_path = root / path
|
|
273
|
+
raw = final_path.read_bytes()
|
|
274
|
+
if raw.decode("utf-8") != expected_originals[path]:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"{path.as_posix()} changed since the migration plan was computed; "
|
|
277
|
+
"re-run migrate to recompute the plan"
|
|
278
|
+
)
|
|
279
|
+
originals[final_path] = raw
|
|
280
|
+
|
|
281
|
+
temp_files: list[tuple[Path, Path]] = []
|
|
282
|
+
try:
|
|
283
|
+
for path, new_content in plan.updated_contents:
|
|
284
|
+
final_path = root / path
|
|
285
|
+
descriptor, temp_name = tempfile.mkstemp(
|
|
286
|
+
prefix=f".{final_path.name}.", suffix=".tmp", dir=str(final_path.parent)
|
|
287
|
+
)
|
|
288
|
+
with open(descriptor, "wb") as handle:
|
|
289
|
+
handle.write(new_content.encode("utf-8"))
|
|
290
|
+
temp_path = Path(temp_name)
|
|
291
|
+
shutil.copymode(final_path, temp_path)
|
|
292
|
+
temp_files.append((temp_path, final_path))
|
|
293
|
+
except OSError:
|
|
294
|
+
for temp_path, _ in temp_files:
|
|
295
|
+
temp_path.unlink(missing_ok=True)
|
|
296
|
+
raise
|
|
297
|
+
|
|
298
|
+
committed: list[Path] = []
|
|
299
|
+
try:
|
|
300
|
+
for temp_path, final_path in temp_files:
|
|
301
|
+
temp_path.replace(final_path)
|
|
302
|
+
committed.append(final_path)
|
|
303
|
+
except OSError:
|
|
304
|
+
for final_path in committed:
|
|
305
|
+
final_path.write_bytes(originals[final_path])
|
|
306
|
+
for temp_path, final_path in temp_files:
|
|
307
|
+
if final_path not in committed:
|
|
308
|
+
temp_path.unlink(missing_ok=True)
|
|
309
|
+
raise
|