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 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
@@ -0,0 +1,4 @@
1
+ from docsystem.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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
+ )