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/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