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/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Provider-neutral structured Markdown documentation engine."""
|
|
2
|
+
|
|
3
|
+
from docsystem.catalog import (
|
|
4
|
+
CatalogMembership,
|
|
5
|
+
DependencyEdge,
|
|
6
|
+
DependencyGraph,
|
|
7
|
+
MarkdownCatalog,
|
|
8
|
+
)
|
|
9
|
+
from docsystem.metadata import DocumentMetadata, MetadataReference
|
|
10
|
+
from docsystem.sections import MarkdownSection
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.2"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CatalogMembership",
|
|
16
|
+
"DependencyEdge",
|
|
17
|
+
"DependencyGraph",
|
|
18
|
+
"DocumentMetadata",
|
|
19
|
+
"MarkdownCatalog",
|
|
20
|
+
"MarkdownSection",
|
|
21
|
+
"MetadataReference",
|
|
22
|
+
]
|
docsystem/__main__.py
ADDED
docsystem/catalog.py
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""Provider-neutral Markdown discovery, metadata and dependency graphs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from urllib.parse import unquote, urlsplit
|
|
10
|
+
|
|
11
|
+
from docsystem.config import ProjectConfig
|
|
12
|
+
from docsystem.metadata import DocumentMetadata, parse_front_matter
|
|
13
|
+
from docsystem.sections import (
|
|
14
|
+
MarkdownSection,
|
|
15
|
+
navigation_issues,
|
|
16
|
+
parse_sections_result,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
INDEX_NAMES = frozenset({"readme.md", "index.md"})
|
|
20
|
+
INLINE_LINK_PATTERN = re.compile(r"(?<!!)\[[^\]]*]\(\s*(?:<([^>]+)>|([^) \t]+))")
|
|
21
|
+
REFERENCE_DEFINITION_PATTERN = re.compile(
|
|
22
|
+
r"^\s{0,3}\[([^\]]+)]:\s*(?:<([^>]+)>|(\S+))", re.MULTILINE
|
|
23
|
+
)
|
|
24
|
+
REFERENCE_USE_PATTERN = re.compile(r"(?<!!)\[([^\]]+)]\[([^\]]*)]")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ValidationIssue:
|
|
29
|
+
"""A deterministic, human-readable catalog validation result."""
|
|
30
|
+
|
|
31
|
+
path: PurePosixPath
|
|
32
|
+
message: str
|
|
33
|
+
severity: str = "error"
|
|
34
|
+
affects_graph: bool = False
|
|
35
|
+
target_id: str | None = None
|
|
36
|
+
category: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CatalogMembership:
|
|
41
|
+
"""The explicit catalog classification of one Markdown source."""
|
|
42
|
+
|
|
43
|
+
state: str
|
|
44
|
+
path: PurePosixPath
|
|
45
|
+
role: str | None = None
|
|
46
|
+
reason: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class RelationBoundary:
|
|
51
|
+
"""A visible legacy relation that cannot become a graph edge."""
|
|
52
|
+
|
|
53
|
+
source_id: str
|
|
54
|
+
relation: str
|
|
55
|
+
value: str
|
|
56
|
+
reason: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class RelationMigration:
|
|
61
|
+
"""A deterministic legacy path to canonical ID mapping."""
|
|
62
|
+
|
|
63
|
+
source_id: str
|
|
64
|
+
relation: str
|
|
65
|
+
value: str
|
|
66
|
+
target_id: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class MarkdownDocument:
|
|
71
|
+
"""A Markdown source file and its parsed context-engine data."""
|
|
72
|
+
|
|
73
|
+
role: str
|
|
74
|
+
path: PurePosixPath
|
|
75
|
+
links: tuple[PurePosixPath, ...]
|
|
76
|
+
is_index: bool
|
|
77
|
+
content: str
|
|
78
|
+
metadata: DocumentMetadata | None
|
|
79
|
+
sections: tuple[MarkdownSection, ...]
|
|
80
|
+
section_issues: tuple[str, ...]
|
|
81
|
+
metadata_issues: tuple[str, ...]
|
|
82
|
+
graph_issues: tuple[str, ...]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class MarkdownCatalog:
|
|
87
|
+
"""A deterministic snapshot of configured Markdown source files."""
|
|
88
|
+
|
|
89
|
+
documents: tuple[MarkdownDocument, ...]
|
|
90
|
+
memberships: tuple[CatalogMembership, ...] = ()
|
|
91
|
+
legacy_edges: tuple[DependencyEdge, ...] = ()
|
|
92
|
+
relation_boundaries: tuple[RelationBoundary, ...] = ()
|
|
93
|
+
relation_migrations: tuple[RelationMigration, ...] = ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class DependencyEdge:
|
|
98
|
+
"""A typed, normalized edge between stable document IDs."""
|
|
99
|
+
|
|
100
|
+
relation: str
|
|
101
|
+
source_id: str
|
|
102
|
+
target_id: str
|
|
103
|
+
expected_revision: int | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class DependencyGraph:
|
|
108
|
+
"""Deterministically ordered forward and reverse dependency edges."""
|
|
109
|
+
|
|
110
|
+
edges: tuple[DependencyEdge, ...]
|
|
111
|
+
|
|
112
|
+
@cached_property
|
|
113
|
+
def _outgoing_by_source(self) -> dict[str, tuple[DependencyEdge, ...]]:
|
|
114
|
+
grouped: dict[str, list[DependencyEdge]] = {}
|
|
115
|
+
for edge in self.edges:
|
|
116
|
+
grouped.setdefault(edge.source_id, []).append(edge)
|
|
117
|
+
return {source: tuple(edges) for source, edges in grouped.items()}
|
|
118
|
+
|
|
119
|
+
@cached_property
|
|
120
|
+
def _incoming_by_target(self) -> dict[str, tuple[DependencyEdge, ...]]:
|
|
121
|
+
grouped: dict[str, list[DependencyEdge]] = {}
|
|
122
|
+
for edge in self.edges:
|
|
123
|
+
grouped.setdefault(edge.target_id, []).append(edge)
|
|
124
|
+
return {target: tuple(edges) for target, edges in grouped.items()}
|
|
125
|
+
|
|
126
|
+
def outgoing(self, document_id: str) -> tuple[DependencyEdge, ...]:
|
|
127
|
+
return self._outgoing_by_source.get(document_id, ())
|
|
128
|
+
|
|
129
|
+
def incoming(self, document_id: str) -> tuple[DependencyEdge, ...]:
|
|
130
|
+
return self._incoming_by_target.get(document_id, ())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _area_for(path: PurePosixPath, config: ProjectConfig) -> str | None:
|
|
134
|
+
matches = [
|
|
135
|
+
(len(area.parts), role)
|
|
136
|
+
for role, area in config.areas.items()
|
|
137
|
+
if path == area or area in path.parents
|
|
138
|
+
]
|
|
139
|
+
if not matches:
|
|
140
|
+
return None
|
|
141
|
+
return max(matches)[1]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _normalize_link(
|
|
145
|
+
source: PurePosixPath, raw_target: str, documentation_root: Path
|
|
146
|
+
) -> PurePosixPath | None:
|
|
147
|
+
target = urlsplit(raw_target)
|
|
148
|
+
if target.scheme or target.netloc or not target.path:
|
|
149
|
+
return None
|
|
150
|
+
decoded = unquote(target.path).replace("\\", "/")
|
|
151
|
+
candidate = (documentation_root / source.parent / decoded).resolve()
|
|
152
|
+
try:
|
|
153
|
+
relative = candidate.relative_to(documentation_root.resolve())
|
|
154
|
+
except ValueError:
|
|
155
|
+
return None
|
|
156
|
+
if candidate.is_dir():
|
|
157
|
+
for index in sorted(candidate.iterdir(), key=lambda item: item.name.lower()):
|
|
158
|
+
if index.is_file() and index.name.lower() in INDEX_NAMES:
|
|
159
|
+
relative = index.relative_to(documentation_root.resolve())
|
|
160
|
+
break
|
|
161
|
+
return PurePosixPath(relative.as_posix())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _markdown_links(
|
|
165
|
+
content: str, relative: PurePosixPath, root: Path
|
|
166
|
+
) -> tuple[PurePosixPath, ...]:
|
|
167
|
+
definitions = {
|
|
168
|
+
match.group(1).casefold(): match.group(2) or match.group(3)
|
|
169
|
+
for match in REFERENCE_DEFINITION_PATTERN.finditer(content)
|
|
170
|
+
}
|
|
171
|
+
inline_links = (
|
|
172
|
+
match.group(1) or match.group(2)
|
|
173
|
+
for match in INLINE_LINK_PATTERN.finditer(content)
|
|
174
|
+
)
|
|
175
|
+
reference_links = (
|
|
176
|
+
definitions[label.casefold()]
|
|
177
|
+
for match in REFERENCE_USE_PATTERN.finditer(content)
|
|
178
|
+
if (label := match.group(2) or match.group(1)).casefold() in definitions
|
|
179
|
+
)
|
|
180
|
+
links = {
|
|
181
|
+
normalized
|
|
182
|
+
for raw in (*inline_links, *reference_links)
|
|
183
|
+
if (normalized := _normalize_link(relative, raw, root)) is not None
|
|
184
|
+
and normalized.suffix.lower() == ".md"
|
|
185
|
+
}
|
|
186
|
+
return tuple(sorted(links, key=PurePosixPath.as_posix))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _excluded_paths(
|
|
190
|
+
root: Path, patterns: tuple[str, ...]
|
|
191
|
+
) -> dict[PurePosixPath, str]:
|
|
192
|
+
excluded: dict[PurePosixPath, str] = {}
|
|
193
|
+
for pattern in patterns:
|
|
194
|
+
for path in sorted(root.glob(pattern), key=lambda item: item.as_posix()):
|
|
195
|
+
if not path.is_file() or path.suffix.lower() != ".md":
|
|
196
|
+
continue
|
|
197
|
+
relative = PurePosixPath(path.relative_to(root).as_posix())
|
|
198
|
+
excluded.setdefault(relative, pattern)
|
|
199
|
+
return excluded
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def included_source_paths(config: ProjectConfig) -> tuple[PurePosixPath, ...]:
|
|
203
|
+
"""List included Markdown source paths without parsing any content.
|
|
204
|
+
|
|
205
|
+
Applies the same exclusion and area-mapping rules as `build_catalog`, so
|
|
206
|
+
the result is exactly the catalog's document set; it exists so projection
|
|
207
|
+
freshness checks can enumerate sources without paying for Markdown,
|
|
208
|
+
metadata or link parsing.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
root = config.documentation_root
|
|
212
|
+
if not root.is_dir():
|
|
213
|
+
return ()
|
|
214
|
+
excluded = _excluded_paths(root, config.catalog_exclusions)
|
|
215
|
+
included: list[PurePosixPath] = []
|
|
216
|
+
for path in sorted(root.rglob("*"), key=lambda item: item.as_posix()):
|
|
217
|
+
if not path.is_file() or path.suffix.lower() != ".md":
|
|
218
|
+
continue
|
|
219
|
+
relative = PurePosixPath(path.relative_to(root).as_posix())
|
|
220
|
+
if relative in excluded or _area_for(relative, config) is None:
|
|
221
|
+
continue
|
|
222
|
+
included.append(relative)
|
|
223
|
+
return tuple(included)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_catalog(config: ProjectConfig) -> MarkdownCatalog:
|
|
227
|
+
"""Classify every Markdown file and parse included documents."""
|
|
228
|
+
|
|
229
|
+
root = config.documentation_root
|
|
230
|
+
if not root.is_dir():
|
|
231
|
+
return MarkdownCatalog(documents=(), memberships=())
|
|
232
|
+
|
|
233
|
+
documents: list[MarkdownDocument] = []
|
|
234
|
+
memberships: list[CatalogMembership] = []
|
|
235
|
+
excluded_paths = _excluded_paths(root, config.catalog_exclusions)
|
|
236
|
+
for path in sorted(root.rglob("*"), key=lambda item: item.as_posix()):
|
|
237
|
+
if not path.is_file() or path.suffix.lower() != ".md":
|
|
238
|
+
continue
|
|
239
|
+
relative = PurePosixPath(path.relative_to(root).as_posix())
|
|
240
|
+
exclusion = excluded_paths.get(relative)
|
|
241
|
+
if exclusion is not None:
|
|
242
|
+
memberships.append(
|
|
243
|
+
CatalogMembership("excluded", relative, reason=exclusion)
|
|
244
|
+
)
|
|
245
|
+
continue
|
|
246
|
+
role = _area_for(relative, config)
|
|
247
|
+
if role is None:
|
|
248
|
+
memberships.append(
|
|
249
|
+
CatalogMembership(
|
|
250
|
+
"unmapped", relative, reason="no configured area"
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
continue
|
|
254
|
+
memberships.append(CatalogMembership("included", relative, role=role))
|
|
255
|
+
content = path.read_text(encoding="utf-8")
|
|
256
|
+
front_matter = parse_front_matter(content, frozenset(config.identifiers.values()))
|
|
257
|
+
section_result = parse_sections_result(content)
|
|
258
|
+
documents.append(
|
|
259
|
+
MarkdownDocument(
|
|
260
|
+
role=role,
|
|
261
|
+
path=relative,
|
|
262
|
+
links=_markdown_links(content, relative, root),
|
|
263
|
+
is_index=path.name.lower() in INDEX_NAMES,
|
|
264
|
+
content=content,
|
|
265
|
+
metadata=front_matter.metadata,
|
|
266
|
+
sections=section_result.sections,
|
|
267
|
+
section_issues=section_result.issues,
|
|
268
|
+
metadata_issues=front_matter.issues,
|
|
269
|
+
graph_issues=front_matter.graph_issues,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
by_path = {
|
|
273
|
+
(root / document.path).resolve(): document
|
|
274
|
+
for document in documents
|
|
275
|
+
if document.metadata is not None
|
|
276
|
+
}
|
|
277
|
+
legacy_edges: list[DependencyEdge] = []
|
|
278
|
+
boundaries: list[RelationBoundary] = []
|
|
279
|
+
migrations: list[RelationMigration] = []
|
|
280
|
+
for document in documents:
|
|
281
|
+
if document.metadata is None:
|
|
282
|
+
continue
|
|
283
|
+
for relation, value in document.metadata.legacy_references:
|
|
284
|
+
parsed = urlsplit(value)
|
|
285
|
+
external = bool(parsed.scheme or parsed.netloc)
|
|
286
|
+
candidate = (
|
|
287
|
+
root / document.path.parent / unquote(parsed.path)
|
|
288
|
+
).resolve()
|
|
289
|
+
target = by_path.get(candidate) if not external else None
|
|
290
|
+
if target is not None and target.metadata is not None:
|
|
291
|
+
if target.metadata.document_id == document.metadata.document_id:
|
|
292
|
+
boundaries.append(
|
|
293
|
+
RelationBoundary(
|
|
294
|
+
document.metadata.document_id,
|
|
295
|
+
relation,
|
|
296
|
+
value,
|
|
297
|
+
"self reference",
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
continue
|
|
301
|
+
if config.legacy_relation_mode == "resolve-with-warning":
|
|
302
|
+
legacy_edges.append(
|
|
303
|
+
DependencyEdge(
|
|
304
|
+
relation,
|
|
305
|
+
document.metadata.document_id,
|
|
306
|
+
target.metadata.document_id,
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
# `relation_migrations` stays mode-independent: it is the
|
|
310
|
+
# deterministic inventory `migration-report`/`migrate`/
|
|
311
|
+
# `readiness` rely on, even before a project opts into
|
|
312
|
+
# `resolve-with-warning`.
|
|
313
|
+
migrations.append(
|
|
314
|
+
RelationMigration(
|
|
315
|
+
document.metadata.document_id,
|
|
316
|
+
relation,
|
|
317
|
+
value,
|
|
318
|
+
target.metadata.document_id,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
reason = "external URL" if external else "resource/outside catalog"
|
|
323
|
+
boundaries.append(
|
|
324
|
+
RelationBoundary(
|
|
325
|
+
document.metadata.document_id, relation, value, reason
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
return MarkdownCatalog(
|
|
329
|
+
documents=tuple(documents),
|
|
330
|
+
memberships=tuple(memberships),
|
|
331
|
+
legacy_edges=tuple(
|
|
332
|
+
sorted(
|
|
333
|
+
set(legacy_edges),
|
|
334
|
+
key=lambda edge: (edge.relation, edge.source_id, edge.target_id),
|
|
335
|
+
)
|
|
336
|
+
),
|
|
337
|
+
relation_boundaries=tuple(
|
|
338
|
+
sorted(
|
|
339
|
+
boundaries,
|
|
340
|
+
key=lambda item: (item.source_id, item.relation, item.value),
|
|
341
|
+
)
|
|
342
|
+
),
|
|
343
|
+
relation_migrations=tuple(
|
|
344
|
+
sorted(
|
|
345
|
+
migrations,
|
|
346
|
+
key=lambda item: (item.source_id, item.relation, item.value),
|
|
347
|
+
)
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def validate_membership(catalog: MarkdownCatalog) -> tuple[ValidationIssue, ...]:
|
|
353
|
+
"""Reject Markdown that is neither included nor explicitly excluded."""
|
|
354
|
+
|
|
355
|
+
return tuple(
|
|
356
|
+
ValidationIssue(
|
|
357
|
+
membership.path,
|
|
358
|
+
"Markdown is not mapped to a configured area or catalog exclusion",
|
|
359
|
+
affects_graph=True,
|
|
360
|
+
)
|
|
361
|
+
for membership in catalog.memberships
|
|
362
|
+
if membership.state == "unmapped"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def document_section_issues(
|
|
367
|
+
document: MarkdownDocument, config: ProjectConfig
|
|
368
|
+
) -> tuple[str, ...]:
|
|
369
|
+
"""Return parser and navigation-policy diagnostics for one document."""
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
*document.section_issues,
|
|
373
|
+
*navigation_issues(document.sections, config.navigation_extend_through),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def validate_sections(
|
|
378
|
+
catalog: MarkdownCatalog, config: ProjectConfig
|
|
379
|
+
) -> tuple[ValidationIssue, ...]:
|
|
380
|
+
"""Validate stable section addressing and navigation anchors."""
|
|
381
|
+
|
|
382
|
+
return tuple(
|
|
383
|
+
ValidationIssue(document.path, message)
|
|
384
|
+
for document in catalog.documents
|
|
385
|
+
for message in document_section_issues(document, config)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def validate_metadata(catalog: MarkdownCatalog) -> tuple[ValidationIssue, ...]:
|
|
390
|
+
"""Validate stable IDs, revisions and semantic references."""
|
|
391
|
+
|
|
392
|
+
issues = [
|
|
393
|
+
ValidationIssue(
|
|
394
|
+
document.path,
|
|
395
|
+
message,
|
|
396
|
+
affects_graph=message in document.graph_issues,
|
|
397
|
+
)
|
|
398
|
+
for document in catalog.documents
|
|
399
|
+
for message in document.metadata_issues
|
|
400
|
+
]
|
|
401
|
+
documents_by_id: dict[str, list[MarkdownDocument]] = {}
|
|
402
|
+
for document in catalog.documents:
|
|
403
|
+
if document.metadata is not None:
|
|
404
|
+
documents_by_id.setdefault(document.metadata.document_id, []).append(document)
|
|
405
|
+
|
|
406
|
+
for document_id, documents in documents_by_id.items():
|
|
407
|
+
if len(documents) > 1:
|
|
408
|
+
paths = ", ".join(item.path.as_posix() for item in documents)
|
|
409
|
+
issues.extend(
|
|
410
|
+
ValidationIssue(
|
|
411
|
+
document.path,
|
|
412
|
+
f"duplicate document ID {document_id}; also used by: {paths}",
|
|
413
|
+
affects_graph=True,
|
|
414
|
+
)
|
|
415
|
+
for document in documents
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
unique_by_id = {
|
|
419
|
+
document_id: documents[0]
|
|
420
|
+
for document_id, documents in documents_by_id.items()
|
|
421
|
+
if len(documents) == 1
|
|
422
|
+
}
|
|
423
|
+
for document in catalog.documents:
|
|
424
|
+
metadata = document.metadata
|
|
425
|
+
if metadata is None:
|
|
426
|
+
continue
|
|
427
|
+
for reference in metadata.references:
|
|
428
|
+
if reference.target_id == metadata.document_id:
|
|
429
|
+
issues.append(
|
|
430
|
+
ValidationIssue(
|
|
431
|
+
document.path,
|
|
432
|
+
f"metadata.{reference.relation} cannot reference its own ID",
|
|
433
|
+
affects_graph=True,
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
continue
|
|
437
|
+
target = unique_by_id.get(reference.target_id)
|
|
438
|
+
if target is None:
|
|
439
|
+
issues.append(
|
|
440
|
+
ValidationIssue(
|
|
441
|
+
document.path,
|
|
442
|
+
f"metadata.{reference.relation} references unknown ID "
|
|
443
|
+
f"{reference.target_id}",
|
|
444
|
+
affects_graph=True,
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
continue
|
|
448
|
+
if (
|
|
449
|
+
reference.expected_revision is not None
|
|
450
|
+
and target.metadata is not None
|
|
451
|
+
and reference.expected_revision != target.metadata.revision
|
|
452
|
+
):
|
|
453
|
+
issues.append(
|
|
454
|
+
ValidationIssue(
|
|
455
|
+
document.path,
|
|
456
|
+
f"metadata.{reference.relation} pin "
|
|
457
|
+
f"{reference.target_id}@{reference.expected_revision} is stale; "
|
|
458
|
+
f"current revision is {target.metadata.revision}",
|
|
459
|
+
severity="warning",
|
|
460
|
+
target_id=reference.target_id,
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return tuple(
|
|
465
|
+
sorted(
|
|
466
|
+
issues,
|
|
467
|
+
key=lambda issue: (issue.path.as_posix(), issue.severity, issue.message),
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def validate_adoption(
|
|
473
|
+
catalog: MarkdownCatalog, config: ProjectConfig
|
|
474
|
+
) -> tuple[ValidationIssue, ...]:
|
|
475
|
+
"""Expose every opt-in legacy mapping and unresolved boundary.
|
|
476
|
+
|
|
477
|
+
Boundaries (external URLs and resources/paths outside the catalog) are
|
|
478
|
+
never document relations, so they remain non-blocking warnings in both
|
|
479
|
+
`strict` and `resolve-with-warning` mode. A legacy path that resolves to
|
|
480
|
+
an in-catalog document is a real document relation: in
|
|
481
|
+
`resolve-with-warning` mode it is a migratable warning and a graph edge;
|
|
482
|
+
in `strict` mode it remains a blocking error until it is migrated to a
|
|
483
|
+
stable ID (see `docsystem migrate`) or the project opts into
|
|
484
|
+
`resolve-with-warning`.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
paths = {
|
|
488
|
+
document.metadata.document_id: document.path
|
|
489
|
+
for document in catalog.documents
|
|
490
|
+
if document.metadata is not None
|
|
491
|
+
}
|
|
492
|
+
issues: list[ValidationIssue] = []
|
|
493
|
+
for item in catalog.relation_migrations:
|
|
494
|
+
if config.legacy_relation_mode == "strict":
|
|
495
|
+
issues.append(
|
|
496
|
+
ValidationIssue(
|
|
497
|
+
paths[item.source_id],
|
|
498
|
+
f"metadata.{item.relation} entry {item.value!r} must use a "
|
|
499
|
+
"configured stable ID (it resolves to "
|
|
500
|
+
f"{item.target_id}; migrate it with `docsystem migrate` or "
|
|
501
|
+
"enable relations.legacy_paths = resolve-with-warning)",
|
|
502
|
+
severity="error",
|
|
503
|
+
affects_graph=True,
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
else:
|
|
507
|
+
issues.append(
|
|
508
|
+
ValidationIssue(
|
|
509
|
+
paths[item.source_id],
|
|
510
|
+
f"legacy metadata.{item.relation} value {item.value!r} "
|
|
511
|
+
f"resolves to {item.target_id}",
|
|
512
|
+
severity="warning",
|
|
513
|
+
category="adoption-resolved",
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
for item in catalog.relation_boundaries:
|
|
517
|
+
self_reference = item.reason == "self reference"
|
|
518
|
+
issues.append(
|
|
519
|
+
ValidationIssue(
|
|
520
|
+
paths[item.source_id],
|
|
521
|
+
f"legacy metadata.{item.relation} value {item.value!r}: "
|
|
522
|
+
f"{item.reason}",
|
|
523
|
+
severity="error" if self_reference else "warning",
|
|
524
|
+
affects_graph=self_reference,
|
|
525
|
+
category=None if self_reference else "adoption-boundary",
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
return tuple(
|
|
529
|
+
sorted(
|
|
530
|
+
issues,
|
|
531
|
+
key=lambda issue: (issue.path.as_posix(), issue.severity, issue.message),
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def build_dependency_graph(catalog: MarkdownCatalog) -> DependencyGraph:
|
|
537
|
+
"""Build a graph from valid references whose targets are unambiguous."""
|
|
538
|
+
|
|
539
|
+
counts: dict[str, int] = {}
|
|
540
|
+
for document in catalog.documents:
|
|
541
|
+
if document.metadata is not None:
|
|
542
|
+
document_id = document.metadata.document_id
|
|
543
|
+
counts[document_id] = counts.get(document_id, 0) + 1
|
|
544
|
+
known = {document_id for document_id, count in counts.items() if count == 1}
|
|
545
|
+
|
|
546
|
+
edges = {
|
|
547
|
+
DependencyEdge(
|
|
548
|
+
relation=reference.relation,
|
|
549
|
+
source_id=document.metadata.document_id,
|
|
550
|
+
target_id=reference.target_id,
|
|
551
|
+
expected_revision=reference.expected_revision,
|
|
552
|
+
)
|
|
553
|
+
for document in catalog.documents
|
|
554
|
+
if document.metadata is not None
|
|
555
|
+
and document.metadata.document_id in known
|
|
556
|
+
for reference in document.metadata.references
|
|
557
|
+
if reference.target_id in known
|
|
558
|
+
and reference.target_id != document.metadata.document_id
|
|
559
|
+
}
|
|
560
|
+
edges.update(
|
|
561
|
+
edge
|
|
562
|
+
for edge in catalog.legacy_edges
|
|
563
|
+
if edge.source_id in known
|
|
564
|
+
and edge.target_id in known
|
|
565
|
+
and edge.source_id != edge.target_id
|
|
566
|
+
)
|
|
567
|
+
return DependencyGraph(
|
|
568
|
+
tuple(
|
|
569
|
+
sorted(
|
|
570
|
+
edges,
|
|
571
|
+
key=lambda edge: (
|
|
572
|
+
edge.relation,
|
|
573
|
+
edge.source_id,
|
|
574
|
+
edge.target_id,
|
|
575
|
+
edge.expected_revision or 0,
|
|
576
|
+
),
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def find_document(catalog: MarkdownCatalog, document_id: str) -> MarkdownDocument:
|
|
583
|
+
"""Resolve one unique document by stable ID."""
|
|
584
|
+
|
|
585
|
+
matches = [
|
|
586
|
+
document
|
|
587
|
+
for document in catalog.documents
|
|
588
|
+
if document.metadata is not None
|
|
589
|
+
and document.metadata.document_id == document_id
|
|
590
|
+
]
|
|
591
|
+
if not matches:
|
|
592
|
+
raise ValueError(f"document ID not found: {document_id}")
|
|
593
|
+
if len(matches) > 1:
|
|
594
|
+
raise ValueError(f"document ID is not unique: {document_id}")
|
|
595
|
+
return matches[0]
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def validate_reachability(
|
|
599
|
+
catalog: MarkdownCatalog, config: ProjectConfig
|
|
600
|
+
) -> tuple[ValidationIssue, ...]:
|
|
601
|
+
"""Require every document to be listed by its nearest hierarchical index."""
|
|
602
|
+
|
|
603
|
+
indexes_by_directory: dict[PurePosixPath, list[MarkdownDocument]] = {}
|
|
604
|
+
for document in catalog.documents:
|
|
605
|
+
if document.is_index:
|
|
606
|
+
indexes_by_directory.setdefault(document.path.parent, []).append(document)
|
|
607
|
+
|
|
608
|
+
issues: list[ValidationIssue] = []
|
|
609
|
+
for directory, indexes in indexes_by_directory.items():
|
|
610
|
+
if len(indexes) > 1:
|
|
611
|
+
names = ", ".join(index.path.name for index in indexes)
|
|
612
|
+
issues.append(
|
|
613
|
+
ValidationIssue(directory, f"multiple navigation indexes found: {names}")
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
for document in catalog.documents:
|
|
617
|
+
area_root = config.areas[document.role]
|
|
618
|
+
if document.is_index and document.path.parent == area_root:
|
|
619
|
+
continue
|
|
620
|
+
directory = document.path.parent if not document.is_index else document.path.parent.parent
|
|
621
|
+
nearest: MarkdownDocument | None = None
|
|
622
|
+
while directory == area_root or area_root in directory.parents:
|
|
623
|
+
indexes = indexes_by_directory.get(directory, [])
|
|
624
|
+
if len(indexes) == 1:
|
|
625
|
+
nearest = indexes[0]
|
|
626
|
+
break
|
|
627
|
+
if directory == area_root:
|
|
628
|
+
break
|
|
629
|
+
directory = directory.parent
|
|
630
|
+
|
|
631
|
+
if nearest is None:
|
|
632
|
+
issues.append(
|
|
633
|
+
ValidationIssue(
|
|
634
|
+
document.path,
|
|
635
|
+
f"no README.md or index.md found between document and area '{document.role}'",
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
elif document.path not in nearest.links:
|
|
639
|
+
issues.append(
|
|
640
|
+
ValidationIssue(
|
|
641
|
+
document.path,
|
|
642
|
+
f"not linked from nearest index {nearest.path.as_posix()}",
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
return tuple(sorted(issues, key=lambda issue: (issue.path.as_posix(), issue.message)))
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def validate_catalog(
|
|
650
|
+
catalog: MarkdownCatalog, config: ProjectConfig
|
|
651
|
+
) -> tuple[ValidationIssue, ...]:
|
|
652
|
+
"""Validate metadata and hierarchical human navigation."""
|
|
653
|
+
|
|
654
|
+
return tuple(
|
|
655
|
+
sorted(
|
|
656
|
+
(
|
|
657
|
+
*validate_membership(catalog),
|
|
658
|
+
*validate_metadata(catalog),
|
|
659
|
+
*validate_adoption(catalog, config),
|
|
660
|
+
*validate_sections(catalog, config),
|
|
661
|
+
*validate_reachability(catalog, config),
|
|
662
|
+
),
|
|
663
|
+
key=lambda issue: (issue.path.as_posix(), issue.severity, issue.message),
|
|
664
|
+
)
|
|
665
|
+
)
|