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/cli.py ADDED
@@ -0,0 +1,2573 @@
1
+ """Command-line interface for Documentation Engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import sys
10
+ from collections import deque
11
+ from dataclasses import dataclass
12
+ from pathlib import Path, PurePosixPath
13
+
14
+ from docsystem import __version__
15
+ from docsystem.catalog import (
16
+ MarkdownCatalog,
17
+ RelationBoundary,
18
+ RelationMigration,
19
+ ValidationIssue,
20
+ build_catalog,
21
+ build_dependency_graph,
22
+ document_section_issues,
23
+ find_document,
24
+ validate_adoption,
25
+ validate_catalog,
26
+ validate_membership,
27
+ validate_metadata,
28
+ )
29
+ from docsystem.config import CONFIG_FILENAME, DEFAULT_CONFIG, ProjectConfig, load_config
30
+ from docsystem.migration import apply_migration_plan, build_migration_plan, validate_plan
31
+ from docsystem.projection import (
32
+ LoadedProjection,
33
+ build_projection,
34
+ evaluate_changes,
35
+ load_verified_projection,
36
+ projection_status,
37
+ resolve_generation_manifest,
38
+ write_projection,
39
+ )
40
+ from docsystem.projection import (
41
+ changes as projection_changes,
42
+ )
43
+ from docsystem.readiness import evaluate_readiness
44
+ from docsystem.sections import MarkdownSection, extract_navigation, extract_section
45
+
46
+ # Version of every `--json` root object. Bump only on a breaking change to
47
+ # an existing field; adding new fields is compatible and does not bump it.
48
+ JSON_SCHEMA_VERSION = 1
49
+
50
+ REPORT_TYPES = {
51
+ "runtime-report": "Runtime Report",
52
+ "adoption-finding": "Adoption Finding",
53
+ "core-bug": "Core Bug",
54
+ "docs-pattern-request": "Docs Pattern Request",
55
+ }
56
+ REPORT_SOURCES = ("codex", "claude", "vscode", "other")
57
+ REPORT_COMPONENTS = (
58
+ "catalog",
59
+ "metadata",
60
+ "sections",
61
+ "relations",
62
+ "graph",
63
+ "navigation",
64
+ "anchors",
65
+ "projection",
66
+ "context",
67
+ "mcp",
68
+ "cli",
69
+ "adoption",
70
+ "profiles",
71
+ "readiness",
72
+ "reporting",
73
+ "setup",
74
+ "local-state",
75
+ "privacy",
76
+ )
77
+
78
+
79
+ def _print_json(payload: dict[str, object]) -> None:
80
+ print(
81
+ json.dumps(
82
+ {"schema_version": JSON_SCHEMA_VERSION, **payload},
83
+ ensure_ascii=False,
84
+ sort_keys=True,
85
+ indent=2,
86
+ )
87
+ )
88
+
89
+
90
+ def _write_text_or_stdout(text: str, output: Path | None) -> int:
91
+ if output is None:
92
+ sys.stdout.write(text)
93
+ return 0
94
+ try:
95
+ output.write_text(text, encoding="utf-8")
96
+ except OSError as error:
97
+ print(f"ERROR: failed to write report draft: {error}", file=sys.stderr)
98
+ return 1
99
+ print(f"Report draft written: {output}")
100
+ return 0
101
+
102
+
103
+ def _label_slug(value: str) -> str:
104
+ slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
105
+ return slug or "unknown"
106
+
107
+
108
+ def _migration_json(item: RelationMigration) -> dict[str, object]:
109
+ return {
110
+ "source_id": item.source_id,
111
+ "relation": item.relation,
112
+ "value": item.value,
113
+ "target_id": item.target_id,
114
+ }
115
+
116
+
117
+ def _boundary_json(item: RelationBoundary) -> dict[str, object]:
118
+ return {
119
+ "source_id": item.source_id,
120
+ "relation": item.relation,
121
+ "value": item.value,
122
+ "reason": item.reason,
123
+ }
124
+
125
+
126
+ def _print_validation_issues(
127
+ issues: tuple[ValidationIssue, ...],
128
+ *,
129
+ verbose_adoption: bool,
130
+ ) -> bool:
131
+ adoption_counts = {
132
+ "adoption-resolved": 0,
133
+ "adoption-boundary": 0,
134
+ }
135
+ visible: list[ValidationIssue] = []
136
+ for issue in issues:
137
+ compactable = (
138
+ issue.severity == "warning" and issue.category in adoption_counts
139
+ )
140
+ if compactable and not verbose_adoption:
141
+ adoption_counts[issue.category] += 1
142
+ else:
143
+ visible.append(issue)
144
+
145
+ summaries = (
146
+ (
147
+ adoption_counts["adoption-resolved"],
148
+ "legacy relation values resolve to stable IDs",
149
+ ),
150
+ (
151
+ adoption_counts["adoption-boundary"],
152
+ "legacy relation values remain resource/outside boundaries",
153
+ ),
154
+ )
155
+ for count, description in summaries:
156
+ if count:
157
+ print(
158
+ f"WARNING: {count} {description}; run "
159
+ "`docsystem migration-report PROJECT` for row-level details.",
160
+ file=sys.stderr,
161
+ )
162
+ for issue in visible:
163
+ level = "WARNING" if issue.severity == "warning" else "ERROR"
164
+ print(
165
+ f"{level}: {issue.path.as_posix()}: {issue.message}",
166
+ file=sys.stderr,
167
+ )
168
+ return any(issue.severity != "warning" for issue in issues)
169
+
170
+
171
+ def initialize(project_root: Path) -> int:
172
+ root = project_root.resolve()
173
+ root.mkdir(parents=True, exist_ok=True)
174
+ config_path = root / CONFIG_FILENAME
175
+ if config_path.exists():
176
+ print(f"Refusing to overwrite existing configuration: {config_path}", file=sys.stderr)
177
+ return 1
178
+ config_path.write_text(DEFAULT_CONFIG, encoding="utf-8")
179
+ config = load_config(root)
180
+ config.documentation_root.mkdir(parents=True, exist_ok=True)
181
+ print(f"Created {config_path}")
182
+ print(f"Created documentation root: {config.documentation_root}")
183
+ return 0
184
+
185
+
186
+ def doctor(project_root: Path, *, verbose_adoption: bool = False) -> int:
187
+ try:
188
+ config = load_config(project_root)
189
+ except ValueError as error:
190
+ print(f"ERROR: {error}", file=sys.stderr)
191
+ return 1
192
+ if not config.documentation_root.is_dir():
193
+ print(
194
+ f"ERROR: documentation root does not exist: "
195
+ f"{config.documentation_root}",
196
+ file=sys.stderr,
197
+ )
198
+ return 1
199
+ issues = validate_catalog(build_catalog(config), config)
200
+ if _print_validation_issues(
201
+ issues, verbose_adoption=verbose_adoption
202
+ ):
203
+ return 1
204
+ print("Configuration is valid.")
205
+ print(f"Documentation root: {config.documentation_root}")
206
+ print(f"Language: {config.language}")
207
+ print(f"Projection: {config.projection_format}")
208
+ return 0
209
+
210
+
211
+ def catalog(project_root: Path, *, explain: bool = False, json_output: bool = False) -> int:
212
+ try:
213
+ config = load_config(project_root)
214
+ except ValueError as error:
215
+ print(f"ERROR: {error}", file=sys.stderr)
216
+ return 1
217
+ markdown_catalog = build_catalog(config)
218
+ if explain:
219
+ if json_output:
220
+ _print_json(
221
+ {
222
+ "memberships": [
223
+ {
224
+ "state": membership.state,
225
+ "path": membership.path.as_posix(),
226
+ "role": membership.role,
227
+ "reason": membership.reason,
228
+ }
229
+ for membership in markdown_catalog.memberships
230
+ ]
231
+ }
232
+ )
233
+ return 0
234
+ for membership in markdown_catalog.memberships:
235
+ detail = membership.role or membership.reason or "-"
236
+ print(f"{membership.state}\t{detail}\t{membership.path.as_posix()}")
237
+ return 0
238
+ if json_output:
239
+ _print_json(
240
+ {
241
+ "documents": [
242
+ {"role": document.role, "path": document.path.as_posix()}
243
+ for document in markdown_catalog.documents
244
+ ]
245
+ }
246
+ )
247
+ return 0
248
+ for document in markdown_catalog.documents:
249
+ print(f"{document.role}\t{document.path.as_posix()}")
250
+ return 0
251
+
252
+
253
+ def validate(project_root: Path, *, verbose_adoption: bool = False) -> int:
254
+ try:
255
+ config = load_config(project_root)
256
+ except ValueError as error:
257
+ print(f"ERROR: {error}", file=sys.stderr)
258
+ return 1
259
+ issues = validate_catalog(build_catalog(config), config)
260
+ if _print_validation_issues(
261
+ issues, verbose_adoption=verbose_adoption
262
+ ):
263
+ return 1
264
+ print("Markdown navigation is valid.")
265
+ return 0
266
+
267
+
268
+ def read_document(
269
+ project_root: Path,
270
+ document_id: str,
271
+ *,
272
+ anchor: str | None = None,
273
+ navigation: bool = False,
274
+ list_sections: bool = False,
275
+ ) -> int:
276
+ try:
277
+ config = load_config(project_root)
278
+ views, _, catalog_value = _load_views(config)
279
+ if catalog_value is not None:
280
+ document = find_document(catalog_value, document_id)
281
+ section_issues = document_section_issues(document, config)
282
+ if section_issues:
283
+ for message in section_issues:
284
+ print(
285
+ f"ERROR: {document.path.as_posix()}: {message}",
286
+ file=sys.stderr,
287
+ )
288
+ return 1
289
+ view = views.get(document_id)
290
+ if view is None:
291
+ raise ValueError(f"document ID not found: {document_id}")
292
+ if list_sections:
293
+ output = "".join(
294
+ f"{section.anchor}\tH{section.level}\t"
295
+ f"{section.start_line}:{section.end_line}\t{section.title}\n"
296
+ for section in view.sections
297
+ )
298
+ elif anchor is not None:
299
+ section = next(
300
+ (item for item in view.sections if item.anchor == anchor), None
301
+ )
302
+ if section is None:
303
+ raise ValueError(f"anchor not found in {document_id}: {anchor}")
304
+ output = extract_section(view.content, section)
305
+ elif navigation:
306
+ output = extract_navigation(
307
+ view.content,
308
+ view.sections,
309
+ config.navigation_extend_through,
310
+ )
311
+ else:
312
+ output = (
313
+ view.content
314
+ if view.content.endswith("\n")
315
+ else f"{view.content}\n"
316
+ )
317
+ except ValueError as error:
318
+ print(f"ERROR: {error}", file=sys.stderr)
319
+ return 1
320
+ sys.stdout.write(output)
321
+ return 0
322
+
323
+
324
+ @dataclass(frozen=True)
325
+ class _EdgeView:
326
+ """One dependency edge as served to a read command."""
327
+
328
+ relation: str
329
+ peer_id: str
330
+ expected_revision: int | None
331
+
332
+
333
+ @dataclass(frozen=True)
334
+ class _DocumentView:
335
+ """Document data required by `read`, `context` and `impact`.
336
+
337
+ Both the direct-Markdown path and the verified-projection path reduce to
338
+ this shape, so command output is byte-identical regardless of which path
339
+ served it. `migrations` and `boundaries` are `(relation, value, target)`
340
+ and `(relation, value, reason)` triples; `related_values` preserves the
341
+ document-order raw values used by the "Related omitted" note.
342
+ """
343
+
344
+ document_id: str
345
+ path: PurePosixPath
346
+ content: str
347
+ sections: tuple[MarkdownSection, ...]
348
+ revision: int
349
+ document_type: str | None
350
+ status: str | None
351
+ outgoing: tuple[_EdgeView, ...]
352
+ migrations: tuple[tuple[str, str, str], ...]
353
+ boundaries: tuple[tuple[str, str, str], ...]
354
+ related_values: tuple[str, ...]
355
+
356
+
357
+ _Views = dict[str, _DocumentView]
358
+ _Incoming = dict[str, tuple[_EdgeView, ...]]
359
+
360
+
361
+ def _freshness_rows(
362
+ config,
363
+ views: _Views,
364
+ ordered: list[str],
365
+ ) -> list[dict[str, object]]:
366
+ rows: list[dict[str, object]] = []
367
+ for selected_id in ordered:
368
+ view = views[selected_id]
369
+ for edge in view.outgoing:
370
+ if edge.expected_revision is None:
371
+ continue
372
+ dependency = views.get(edge.peer_id)
373
+ if dependency is None or dependency.revision == edge.expected_revision:
374
+ continue
375
+ rows.append(
376
+ {
377
+ "source_id": selected_id,
378
+ "target_id": edge.peer_id,
379
+ "pinned_revision": edge.expected_revision,
380
+ "current_revision": dependency.revision,
381
+ "classification": (
382
+ "historical snapshot"
383
+ if view.document_type in config.snapshot_document_types
384
+ else "stale"
385
+ ),
386
+ }
387
+ )
388
+ return rows
389
+
390
+
391
+ def _context_selection(
392
+ views: _Views,
393
+ document_id: str,
394
+ *,
395
+ depth: int,
396
+ include_related: bool,
397
+ ) -> dict[str, set[str]]:
398
+ included: dict[str, set[str]] = {document_id: {"target"}}
399
+ queue = deque([(document_id, 0)])
400
+ expanded: set[str] = set()
401
+ allowed = {"derived_from", "depends_on", "validated_against"}
402
+ if include_related:
403
+ allowed.update({"related", "supersedes"})
404
+ while queue:
405
+ source_id, current_depth = queue.popleft()
406
+ if source_id in expanded or current_depth >= depth:
407
+ continue
408
+ expanded.add(source_id)
409
+ for edge in views[source_id].outgoing:
410
+ if edge.relation not in allowed:
411
+ continue
412
+ included.setdefault(edge.peer_id, set()).add(edge.relation)
413
+ queue.append((edge.peer_id, current_depth + 1))
414
+ return included
415
+
416
+
417
+ def _ordered_selection(included: dict[str, set[str]], document_id: str) -> list[str]:
418
+ return [document_id, *sorted(item for item in included if item != document_id)]
419
+
420
+
421
+ def _views_from_catalog(catalog_value: MarkdownCatalog) -> tuple[_Views, _Incoming]:
422
+ graph = build_dependency_graph(catalog_value)
423
+ migrations: dict[str, list[tuple[str, str, str]]] = {}
424
+ for item in catalog_value.relation_migrations:
425
+ migrations.setdefault(item.source_id, []).append(
426
+ (item.relation, item.value, item.target_id)
427
+ )
428
+ boundaries: dict[str, list[tuple[str, str, str]]] = {}
429
+ for item in catalog_value.relation_boundaries:
430
+ boundaries.setdefault(item.source_id, []).append(
431
+ (item.relation, item.value, item.reason)
432
+ )
433
+ views: _Views = {}
434
+ incoming: _Incoming = {}
435
+ for document in catalog_value.documents:
436
+ metadata = document.metadata
437
+ if metadata is None:
438
+ continue
439
+ document_id = metadata.document_id
440
+ related_values = [
441
+ value
442
+ for relation, value in metadata.legacy_references
443
+ if relation == "related"
444
+ ]
445
+ related_values.extend(
446
+ reference.target_id
447
+ for reference in metadata.references
448
+ if reference.relation == "related"
449
+ )
450
+ views[document_id] = _DocumentView(
451
+ document_id=document_id,
452
+ path=document.path,
453
+ content=document.content,
454
+ sections=document.sections,
455
+ revision=metadata.revision,
456
+ document_type=metadata.document_type,
457
+ status=metadata.status,
458
+ outgoing=tuple(
459
+ _EdgeView(edge.relation, edge.target_id, edge.expected_revision)
460
+ for edge in graph.outgoing(document_id)
461
+ ),
462
+ migrations=tuple(migrations.get(document_id, ())),
463
+ boundaries=tuple(boundaries.get(document_id, ())),
464
+ related_values=tuple(related_values),
465
+ )
466
+ incoming[document_id] = tuple(
467
+ _EdgeView(edge.relation, edge.source_id, edge.expected_revision)
468
+ for edge in graph.incoming(document_id)
469
+ )
470
+ return views, incoming
471
+
472
+
473
+ def _views_from_projection(loaded: LoadedProjection) -> tuple[_Views, _Incoming]:
474
+ views: _Views = {}
475
+ incoming: _Incoming = {}
476
+ for document_id, shard in loaded.documents.items():
477
+ # Shard JSON is written with sorted keys, so section order is
478
+ # restored from line numbers rather than mapping order.
479
+ sections = tuple(
480
+ MarkdownSection(
481
+ title=str(record["title"]),
482
+ anchor=anchor,
483
+ level=int(record["level"]),
484
+ start_line=int(record["start_line"]),
485
+ end_line=int(record["end_line"]),
486
+ )
487
+ for anchor, record in sorted(
488
+ shard["sections"].items(),
489
+ key=lambda item: item[1]["start_line"],
490
+ )
491
+ )
492
+ path = str(shard["path"])
493
+ views[document_id] = _DocumentView(
494
+ document_id=document_id,
495
+ path=PurePosixPath(path),
496
+ content=loaded.contents[path],
497
+ sections=sections,
498
+ revision=int(shard["revision"]),
499
+ document_type=shard.get("type"),
500
+ status=shard.get("status"),
501
+ outgoing=tuple(
502
+ _EdgeView(
503
+ record["relation"], record["target"], record.get("expected_revision")
504
+ )
505
+ for record in shard.get("dependencies", ())
506
+ ),
507
+ migrations=tuple(
508
+ (record["relation"], record["value"], record["target"])
509
+ for record in shard.get("migrations", ())
510
+ ),
511
+ boundaries=tuple(
512
+ (record["relation"], record["value"], record["reason"])
513
+ for record in shard.get("boundaries", ())
514
+ ),
515
+ related_values=tuple(str(value) for value in shard.get("related_values", ())),
516
+ )
517
+ incoming[document_id] = tuple(
518
+ _EdgeView(
519
+ record["relation"], record["source"], record.get("expected_revision")
520
+ )
521
+ for record in loaded.reverse.get(document_id, ())
522
+ )
523
+ return views, incoming
524
+
525
+
526
+ def _load_views(config) -> tuple[_Views, _Incoming, MarkdownCatalog | None]:
527
+ """Serve reads from the verified projection, else direct Markdown.
528
+
529
+ Returns the catalog only on the direct path; callers use its presence to
530
+ run the validation that a verified projection already guarantees (a
531
+ generation is only written for a tree with no blocking errors, and the
532
+ loader proves the sources are byte-identical to that tree).
533
+ """
534
+
535
+ loaded, reason = load_verified_projection(config)
536
+ if loaded is not None:
537
+ views, incoming = _views_from_projection(loaded)
538
+ return views, incoming, None
539
+ print(f"WARNING: {reason}; using direct Markdown", file=sys.stderr)
540
+ catalog_value = build_catalog(config)
541
+ views, incoming = _views_from_catalog(catalog_value)
542
+ return views, incoming, catalog_value
543
+
544
+
545
+ def _selection(raw: str) -> tuple[str, str | None]:
546
+ document_id, separator, anchor = raw.partition("#")
547
+ if not document_id or (separator and not anchor):
548
+ raise ValueError(f"invalid include selection: {raw!r}")
549
+ return document_id, anchor if separator else None
550
+
551
+
552
+ def _section_size_maps(view: _DocumentView) -> list[dict[str, object]]:
553
+ """Return per-section `{anchor, title, level, lines, bytes}` in order.
554
+
555
+ `bytes` is the exact UTF-8 size of the slice `extract_section` hashes
556
+ (before its trailing-newline normalization), so the value is identical
557
+ on both serving paths regardless of trailing whitespace handling.
558
+ """
559
+
560
+ lines = view.content.splitlines()
561
+ maps: list[dict[str, object]] = []
562
+ for section in view.sections:
563
+ slice_text = "\n".join(lines[section.start_line - 1 : section.end_line])
564
+ maps.append(
565
+ {
566
+ "anchor": section.anchor,
567
+ "title": section.title,
568
+ "level": section.level,
569
+ "lines": section.end_line - section.start_line + 1,
570
+ "bytes": len(slice_text.encode("utf-8")),
571
+ }
572
+ )
573
+ return maps
574
+
575
+
576
+ def _source_sha(view: _DocumentView) -> str:
577
+ """Return the sha256 of a document's full source, matching the manifest."""
578
+
579
+ return hashlib.sha256(view.content.encode()).hexdigest()
580
+
581
+
582
+ def _section_sha(view: _DocumentView, section: MarkdownSection) -> str:
583
+ """Return a section's sha256 over the exact slice the manifest hashes."""
584
+
585
+ lines = view.content.splitlines()
586
+ slice_text = "\n".join(lines[section.start_line - 1 : section.end_line])
587
+ return hashlib.sha256(slice_text.encode()).hexdigest()
588
+
589
+
590
+ def _changed_section_anchors(
591
+ view: _DocumentView, previous_sections: dict[str, object]
592
+ ) -> tuple[str, ...]:
593
+ """Return every anchor whose content changed since a generation, in doc order.
594
+
595
+ A section is changed when its per-section sha256 differs from the recorded
596
+ one or when the section is new — any level, no filtering. This is the
597
+ complete truth signal reported as `changed_sections`: an H1's slice spans
598
+ everything beneath it and an H2's slice spans its H3+ descendants, so a
599
+ change anywhere always bubbles up through every enclosing anchor as well.
600
+ Which of these anchors are actually re-emitted as `### Changed section`
601
+ content blocks is decided separately in `_packet_sections`, since the H1
602
+ and any `navigation.extend_through` H2 are already served by navigation.
603
+ """
604
+
605
+ return tuple(
606
+ section.anchor
607
+ for section in view.sections
608
+ if not isinstance(previous_sections.get(section.anchor), dict)
609
+ or previous_sections[section.anchor].get("sha256") != _section_sha(view, section)
610
+ )
611
+
612
+
613
+ def _removed_section_anchors(
614
+ view: _DocumentView, previous_sections: dict[str, object]
615
+ ) -> tuple[str, ...]:
616
+ """Return removed anchors in their previous document order."""
617
+
618
+ current = {section.anchor for section in view.sections}
619
+
620
+ def previous_line(item: tuple[str, object]) -> tuple[int, str]:
621
+ anchor, record = item
622
+ if isinstance(record, dict) and isinstance(record.get("start_line"), int):
623
+ return int(record["start_line"]), anchor
624
+ return sys.maxsize, anchor
625
+
626
+ return tuple(
627
+ anchor
628
+ for anchor, _ in sorted(previous_sections.items(), key=previous_line)
629
+ if anchor not in current
630
+ )
631
+
632
+
633
+ def _metadata_changes(
634
+ view: _DocumentView, previous: dict[str, object]
635
+ ) -> tuple[tuple[str, object, object], ...]:
636
+ """Return deterministic semantic projection-field changes."""
637
+
638
+ current: dict[str, object] = {
639
+ "path": view.path.as_posix(),
640
+ "revision": view.revision,
641
+ "type": view.document_type,
642
+ "status": view.status,
643
+ "dependencies": [
644
+ {
645
+ "relation": edge.relation,
646
+ "target": edge.peer_id,
647
+ "expected_revision": edge.expected_revision,
648
+ }
649
+ for edge in view.outgoing
650
+ ],
651
+ "boundaries": [
652
+ {"relation": relation, "value": value, "reason": reason}
653
+ for relation, value, reason in view.boundaries
654
+ ],
655
+ "migrations": [
656
+ {"relation": relation, "value": value, "target": target}
657
+ for relation, value, target in view.migrations
658
+ ],
659
+ "related_values": list(view.related_values),
660
+ }
661
+ return tuple(
662
+ (field, previous.get(field), value)
663
+ for field, value in current.items()
664
+ if previous.get(field) != value
665
+ )
666
+
667
+
668
+ @dataclass(frozen=True)
669
+ class _DocPlan:
670
+ """Per-document rendering plan for `--assume-known` / `--since` packets.
671
+
672
+ Documents without a plan (neither flag active for them) render exactly as
673
+ before, so the flagless packet stays byte-identical. `coverage_state`
674
+ selects the coverage-line wording; `content_omitted` is the JSON marker
675
+ and is present precisely when navigation is omitted; `changed_sections`
676
+ is the complete truth signal — every anchor at any level whose slice
677
+ changed — reported verbatim as the JSON `changed_sections` key; only the
678
+ subset that `_packet_sections` selects (changed H2s outside
679
+ `navigation.extend_through`) is actually rendered as content.
680
+ `removed_sections` and `metadata_changes` make non-current-section
681
+ changes explicit instead of forcing a client to infer them from an empty
682
+ changed-section list.
683
+ """
684
+
685
+ omit_navigation: bool = False
686
+ content_omitted: dict[str, object] | None = None
687
+ coverage_state: str = "normal"
688
+ declared_revision: int | None = None
689
+ generation_short: str | None = None
690
+ changed_sections: tuple[str, ...] = ()
691
+ removed_sections: tuple[str, ...] = ()
692
+ metadata_changes: tuple[tuple[str, object, object], ...] = ()
693
+ source_changed_outside_sections: bool = False
694
+ changed_document: bool = False
695
+
696
+
697
+ def _build_packet_plans(
698
+ views: _Views,
699
+ ordered: list[str],
700
+ *,
701
+ assumed: dict[str, int],
702
+ since_manifest: dict[str, object] | None,
703
+ generation_short: str | None,
704
+ ) -> tuple[dict[str, _DocPlan], list[dict[str, object]], list[str], int]:
705
+ """Compute per-document plans plus shared diagnostics for the new flags.
706
+
707
+ Returns `(plans, mismatches, notes, assumed_known_omitted)`. `mismatches`
708
+ feeds the JSON `assume_known_mismatches`; `notes` are extra text
709
+ diagnostics (revision mismatches, `new since` and the delta summary).
710
+ """
711
+
712
+ plans: dict[str, _DocPlan] = {}
713
+ mismatches: list[dict[str, object]] = []
714
+ notes: list[str] = []
715
+ assumed_known_omitted = 0
716
+ changed_count = 0
717
+ unchanged_omitted_count = 0
718
+ for selected_id in ordered:
719
+ view = views[selected_id]
720
+ if since_manifest is not None:
721
+ manifest_documents = since_manifest["documents"]
722
+ previous = manifest_documents.get(selected_id)
723
+ if not isinstance(previous, dict):
724
+ plans[selected_id] = _DocPlan(
725
+ generation_short=generation_short,
726
+ changed_sections=_changed_section_anchors(view, {}),
727
+ changed_document=True,
728
+ )
729
+ notes.append(f"{selected_id}: new since {generation_short}")
730
+ changed_count += 1
731
+ elif _source_sha(view) == previous.get("source_sha256"):
732
+ plans[selected_id] = _DocPlan(
733
+ omit_navigation=True,
734
+ content_omitted={
735
+ "reason": "unchanged-since",
736
+ "generation": generation_short,
737
+ },
738
+ coverage_state="unchanged-since",
739
+ generation_short=generation_short,
740
+ )
741
+ unchanged_omitted_count += 1
742
+ else:
743
+ previous_sections = previous.get("sections", {})
744
+ if not isinstance(previous_sections, dict):
745
+ previous_sections = {}
746
+ changed_sections = _changed_section_anchors(view, previous_sections)
747
+ removed_sections = _removed_section_anchors(view, previous_sections)
748
+ metadata_changes = _metadata_changes(view, previous)
749
+ plans[selected_id] = _DocPlan(
750
+ generation_short=generation_short,
751
+ changed_sections=changed_sections,
752
+ removed_sections=removed_sections,
753
+ metadata_changes=metadata_changes,
754
+ source_changed_outside_sections=(
755
+ not changed_sections and not removed_sections
756
+ ),
757
+ changed_document=True,
758
+ )
759
+ if removed_sections:
760
+ notes.append(
761
+ f"{selected_id}: removed sections since {generation_short}: "
762
+ + ", ".join(removed_sections)
763
+ )
764
+ for field, before, after in metadata_changes:
765
+ before_json = json.dumps(
766
+ before, ensure_ascii=False, sort_keys=True, separators=(",", ":")
767
+ )
768
+ after_json = json.dumps(
769
+ after, ensure_ascii=False, sort_keys=True, separators=(",", ":")
770
+ )
771
+ notes.append(
772
+ f"{selected_id}: metadata {field} changed: "
773
+ f"{before_json} -> {after_json}"
774
+ )
775
+ if not changed_sections and not removed_sections:
776
+ notes.append(
777
+ f"{selected_id}: source changed outside addressable sections"
778
+ )
779
+ changed_count += 1
780
+ elif selected_id in assumed:
781
+ declared = assumed[selected_id]
782
+ if view.revision == declared:
783
+ plans[selected_id] = _DocPlan(
784
+ omit_navigation=True,
785
+ content_omitted={
786
+ "reason": "assumed-known",
787
+ "declared_revision": declared,
788
+ },
789
+ coverage_state="assumed-known",
790
+ declared_revision=declared,
791
+ )
792
+ assumed_known_omitted += 1
793
+ else:
794
+ mismatches.append(
795
+ {
796
+ "id": selected_id,
797
+ "declared_revision": declared,
798
+ "current_revision": view.revision,
799
+ }
800
+ )
801
+ notes.append(
802
+ f"{selected_id}: assumed known at revision {declared}, "
803
+ f"current {view.revision} — content included"
804
+ )
805
+ if since_manifest is not None:
806
+ notes.append(
807
+ f"Delta vs generation {generation_short}: {changed_count} changed, "
808
+ f"{unchanged_omitted_count} unchanged omitted"
809
+ )
810
+ return plans, mismatches, notes, assumed_known_omitted
811
+
812
+
813
+ def _packet_sections(
814
+ config,
815
+ view: _DocumentView,
816
+ user_selected: list[str],
817
+ plan: _DocPlan | None,
818
+ ) -> tuple[list[str], set[str], list[str]]:
819
+ """Return `(explicit_anchors, changed_set, omitted)` for one document.
820
+
821
+ `explicit_anchors` is the ordered, de-duplicated set of section blocks to
822
+ render: user `--anchor`/`--include` selections first, then auto-added
823
+ `--since` changed sections. Auto-added anchors are restricted to changed
824
+ H2s that are not already inside the navigation prefix
825
+ (`navigation.extend_through`) — an H1 is always covered by the lead-in
826
+ navigation serves, and an `extend_through` H2 is already inside it, so
827
+ re-emitting either would duplicate content navigation already sent.
828
+ `changed_set` marks the auto-added delta anchors so the text form can
829
+ title them `### Changed section`. `omitted` is the usual coverage list of
830
+ H2 anchors that are neither navigation extensions nor shown, computed
831
+ against what the document actually renders, which makes it truthful by
832
+ construction: every changed H2 is either emitted here or listed there.
833
+ """
834
+
835
+ user = list(dict.fromkeys(user_selected))
836
+ changed = plan.changed_sections if plan is not None else ()
837
+ h2_anchors = {item.anchor for item in view.sections if item.level == 2}
838
+ changed_blocks = [
839
+ anchor
840
+ for anchor in changed
841
+ if anchor in h2_anchors and anchor not in config.navigation_extend_through
842
+ ]
843
+ extra = [anchor for anchor in changed_blocks if anchor not in user]
844
+ explicit_anchors = user + extra
845
+ omitted = [
846
+ item.anchor
847
+ for item in view.sections
848
+ if item.level == 2
849
+ and item.anchor not in config.navigation_extend_through
850
+ and item.anchor not in explicit_anchors
851
+ ]
852
+ return explicit_anchors, set(extra), omitted
853
+
854
+
855
+ def _coverage_line(
856
+ plan: _DocPlan | None, explicit_anchors: list[str], omitted: list[str]
857
+ ) -> str:
858
+ """Build the per-document `_Coverage_` line, honouring omitted content."""
859
+
860
+ omitted_text = ", ".join(omitted) if omitted else "none"
861
+ if plan is not None and plan.coverage_state == "assumed-known":
862
+ body = (
863
+ f"content omitted — declared known at revision "
864
+ f"{plan.declared_revision} (current)"
865
+ )
866
+ elif plan is not None and plan.coverage_state == "unchanged-since":
867
+ body = f"content omitted — unchanged since {plan.generation_short}"
868
+ else:
869
+ body = "navigation" + (" + explicit sections" if explicit_anchors else "")
870
+ return f"_Coverage: {body}. Omitted H2: {omitted_text}._"
871
+
872
+
873
+ def _context_migrations_json(
874
+ views: _Views, ordered: list[str]
875
+ ) -> list[dict[str, object]]:
876
+ return [
877
+ {
878
+ "source_id": selected_id,
879
+ "relation": relation,
880
+ "value": value,
881
+ "target_id": target_id,
882
+ }
883
+ for selected_id in ordered
884
+ for relation, value, target_id in views[selected_id].migrations
885
+ ]
886
+
887
+
888
+ def _context_boundaries_json(
889
+ views: _Views, ordered: list[str]
890
+ ) -> list[dict[str, object]]:
891
+ return [
892
+ {
893
+ "source_id": selected_id,
894
+ "relation": relation,
895
+ "value": value,
896
+ "reason": reason,
897
+ }
898
+ for selected_id in ordered
899
+ for relation, value, reason in views[selected_id].boundaries
900
+ ]
901
+
902
+
903
+ def _context_related_omitted_json(
904
+ views: _Views, document_id: str, *, include_related: bool
905
+ ) -> list[str]:
906
+ return [] if include_related else list(views[document_id].related_values)
907
+
908
+
909
+ def _emit_context_json(
910
+ config,
911
+ views: _Views,
912
+ included: dict[str, set[str]],
913
+ selected_anchors: dict[str, list[str]],
914
+ ordered: list[str],
915
+ document_id: str,
916
+ *,
917
+ depth: int,
918
+ include_related: bool,
919
+ plans: dict[str, _DocPlan] | None = None,
920
+ mismatches: list[dict[str, object]] | None = None,
921
+ assumed_known_omitted: int = 0,
922
+ assume_known_used: bool = False,
923
+ ) -> int:
924
+ """Print the context packet as one structured JSON object.
925
+
926
+ The JSON form carries the same selection, coverage and diagnostics data
927
+ as the Markdown packet, but structured (typed lists instead of prose
928
+ notes) so a machine client never parses packet text. Declared-cache and
929
+ delta documents drop `navigation` for a typed `content_omitted` marker;
930
+ `--since` changed documents additionally carry `changed_sections`. Extra
931
+ top-level keys and stats appear only when the matching flag is used, so
932
+ every flagless payload stays byte-identical.
933
+ """
934
+
935
+ plans = plans or {}
936
+ documents: list[dict[str, object]] = []
937
+ explicit_count = 0
938
+ omitted_count = 0
939
+ for selected_id in ordered:
940
+ view = views[selected_id]
941
+ plan = plans.get(selected_id)
942
+ explicit_anchors, _, omitted = _packet_sections(
943
+ config, view, selected_anchors[selected_id], plan
944
+ )
945
+ explicit_sections = []
946
+ for selected_anchor in explicit_anchors:
947
+ section = next(
948
+ item for item in view.sections if item.anchor == selected_anchor
949
+ )
950
+ explicit_sections.append(
951
+ {
952
+ "anchor": selected_anchor,
953
+ "content": extract_section(view.content, section).rstrip(),
954
+ }
955
+ )
956
+ explicit_count += len(explicit_sections)
957
+ omitted_count += len(omitted)
958
+ entry: dict[str, object] = {
959
+ "id": selected_id,
960
+ "path": view.path.as_posix(),
961
+ "revision": view.revision,
962
+ "relations": sorted(included[selected_id]),
963
+ "explicit_sections": explicit_sections,
964
+ "omitted_h2": omitted,
965
+ "sections": _section_size_maps(view),
966
+ }
967
+ if plan is not None and plan.omit_navigation:
968
+ entry["content_omitted"] = plan.content_omitted
969
+ else:
970
+ entry["navigation"] = extract_navigation(
971
+ view.content,
972
+ view.sections,
973
+ config.navigation_extend_through,
974
+ ).rstrip()
975
+ if plan is not None and plan.changed_document:
976
+ entry["changed_sections"] = list(plan.changed_sections)
977
+ entry["removed_sections"] = list(plan.removed_sections)
978
+ entry["metadata_changes"] = [
979
+ {"field": field, "before": before, "after": after}
980
+ for field, before, after in plan.metadata_changes
981
+ ]
982
+ entry["source_changed_outside_sections"] = (
983
+ plan.source_changed_outside_sections
984
+ )
985
+ documents.append(entry)
986
+ freshness = _freshness_rows(config, views, ordered)
987
+ stats: dict[str, object] = {
988
+ "included_documents": len(ordered),
989
+ "explicit_sections": explicit_count,
990
+ "omitted_h2_sections": omitted_count,
991
+ }
992
+ if assume_known_used:
993
+ stats["assumed_known_omitted"] = assumed_known_omitted
994
+ payload: dict[str, object] = {
995
+ "target": document_id,
996
+ "depth": depth,
997
+ "include_related": include_related,
998
+ "outline": False,
999
+ "documents": documents,
1000
+ "freshness": freshness,
1001
+ "migrations": _context_migrations_json(views, ordered),
1002
+ "boundaries": _context_boundaries_json(views, ordered),
1003
+ "related_omitted": _context_related_omitted_json(
1004
+ views, document_id, include_related=include_related
1005
+ ),
1006
+ "stats": stats,
1007
+ }
1008
+ if assume_known_used:
1009
+ payload["assume_known_mismatches"] = mismatches or []
1010
+ _print_json(payload)
1011
+ return 0
1012
+
1013
+
1014
+ def _emit_context_outline_json(
1015
+ config,
1016
+ views: _Views,
1017
+ included: dict[str, set[str]],
1018
+ ordered: list[str],
1019
+ document_id: str,
1020
+ *,
1021
+ depth: int,
1022
+ include_related: bool,
1023
+ ) -> int:
1024
+ """Print the map-first outline packet: section sizes, no content.
1025
+
1026
+ Shares the same root shape as the full `context --json` packet
1027
+ (target/depth/include_related/diagnostics), but `documents[]` entries
1028
+ carry only `id`, `path`, `revision`, `relations` and `sections`, and
1029
+ `stats` counts listed sections and their total byte size instead of
1030
+ explicit/omitted H2 counts, so a client can budget a follow-up `--include`
1031
+ fetch while retaining the revision needed by `--assume-known`.
1032
+ """
1033
+
1034
+ documents: list[dict[str, object]] = []
1035
+ listed_sections = 0
1036
+ total_section_bytes = 0
1037
+ for selected_id in ordered:
1038
+ view = views[selected_id]
1039
+ sections = _section_size_maps(view)
1040
+ listed_sections += len(sections)
1041
+ total_section_bytes += sum(int(item["bytes"]) for item in sections)
1042
+ documents.append(
1043
+ {
1044
+ "id": selected_id,
1045
+ "path": view.path.as_posix(),
1046
+ "revision": view.revision,
1047
+ "relations": sorted(included[selected_id]),
1048
+ "sections": sections,
1049
+ }
1050
+ )
1051
+ freshness = _freshness_rows(config, views, ordered)
1052
+ _print_json(
1053
+ {
1054
+ "target": document_id,
1055
+ "depth": depth,
1056
+ "include_related": include_related,
1057
+ "outline": True,
1058
+ "documents": documents,
1059
+ "freshness": freshness,
1060
+ "migrations": _context_migrations_json(views, ordered),
1061
+ "boundaries": _context_boundaries_json(views, ordered),
1062
+ "related_omitted": _context_related_omitted_json(
1063
+ views, document_id, include_related=include_related
1064
+ ),
1065
+ "stats": {
1066
+ "included_documents": len(ordered),
1067
+ "listed_sections": listed_sections,
1068
+ "total_section_bytes": total_section_bytes,
1069
+ },
1070
+ }
1071
+ )
1072
+ return 0
1073
+
1074
+
1075
+ def _context_diagnostic_notes(
1076
+ config,
1077
+ views: _Views,
1078
+ ordered: list[str],
1079
+ document_id: str,
1080
+ *,
1081
+ include_related: bool,
1082
+ extra_notes: list[str] | None = None,
1083
+ ) -> list[str]:
1084
+ """Return the sorted "Diagnostics and boundaries" note lines.
1085
+
1086
+ Shared between the full-content packet and `--outline`, which prints
1087
+ exactly the same notes ahead of its own closing action line. `extra_notes`
1088
+ carries declared-cache and delta-packet notes; it is empty for `--outline`
1089
+ (which cannot combine with `--assume-known`/`--since`), keeping that
1090
+ packet byte-identical.
1091
+ """
1092
+
1093
+ notes: list[str] = list(extra_notes or [])
1094
+ freshness_found = False
1095
+ for row in _freshness_rows(config, views, ordered):
1096
+ mode = (
1097
+ "historical snapshot"
1098
+ if row["classification"] == "historical snapshot"
1099
+ else "STALE"
1100
+ )
1101
+ notes.append(
1102
+ f"{row['source_id']}: {row['target_id']}@"
1103
+ f"{row['pinned_revision']}, current "
1104
+ f"{row['current_revision']} — {mode}"
1105
+ )
1106
+ freshness_found = True
1107
+ if not freshness_found:
1108
+ notes.append("No stale revision pins among included documents.")
1109
+ for selected_id in ordered:
1110
+ for relation, value, target_id in views[selected_id].migrations:
1111
+ notes.append(f"{selected_id}: {relation} {value} -> {target_id}")
1112
+ boundary_found = False
1113
+ for selected_id in ordered:
1114
+ for relation, value, reason in views[selected_id].boundaries:
1115
+ notes.append(
1116
+ f"{selected_id}: unresolved/resource {relation} "
1117
+ f"{value} ({reason})"
1118
+ )
1119
+ boundary_found = True
1120
+ if not boundary_found:
1121
+ notes.append(
1122
+ "No unresolved/resource boundaries among included documents."
1123
+ )
1124
+ if not include_related:
1125
+ related = list(views[document_id].related_values)
1126
+ if related:
1127
+ notes.append("Related omitted: " + ", ".join(related))
1128
+ return sorted(set(notes))
1129
+
1130
+
1131
+ def _emit_context_outline_text(
1132
+ config,
1133
+ views: _Views,
1134
+ included: dict[str, set[str]],
1135
+ ordered: list[str],
1136
+ document_id: str,
1137
+ *,
1138
+ depth: int,
1139
+ include_related: bool,
1140
+ ) -> int:
1141
+ """Print the map-first outline: section size tables, no content."""
1142
+
1143
+ out: list[str] = []
1144
+ out.append(f"# Context outline: {document_id}")
1145
+ out.append("")
1146
+ out.append(f"- Dependency depth: {depth}")
1147
+ out.append(f"- Related traversal: {'included' if include_related else 'omitted'}")
1148
+ listed_sections = 0
1149
+ total_bytes = 0
1150
+ for selected_id in ordered:
1151
+ view = views[selected_id]
1152
+ out.append("")
1153
+ out.append(f"## {selected_id} — {view.path.as_posix()}")
1154
+ out.append("")
1155
+ out.append(f"Relations: {', '.join(sorted(included[selected_id]))}.")
1156
+ out.append("")
1157
+ out.append("| Anchor | Level | Lines | Bytes | Title |")
1158
+ out.append("|---|---|---|---|---|")
1159
+ for section_map in _section_size_maps(view):
1160
+ out.append(
1161
+ f"| `{section_map['anchor']}` | H{section_map['level']} | "
1162
+ f"{section_map['lines']} | {section_map['bytes']} | "
1163
+ f"{section_map['title']} |"
1164
+ )
1165
+ listed_sections += 1
1166
+ total_bytes += int(section_map["bytes"])
1167
+ out.append("")
1168
+ out.append("## Diagnostics and boundaries")
1169
+ out.append("")
1170
+ for note in _context_diagnostic_notes(
1171
+ config, views, ordered, document_id, include_related=include_related
1172
+ ):
1173
+ out.append(f"- {note}")
1174
+ out.append(
1175
+ "- Fetch content with --include ID#anchor, or drop --outline for full navigation."
1176
+ )
1177
+ out.append("")
1178
+ out.append("## Packet stats")
1179
+ out.append("")
1180
+ out.append(f"- Included documents: {len(ordered)}")
1181
+ out.append(f"- Listed sections: {listed_sections}")
1182
+ out.append(f"- Total section bytes: {total_bytes}")
1183
+ sys.stdout.write("\n".join(out) + "\n")
1184
+ return 0
1185
+
1186
+
1187
+ def context(
1188
+ project_root: Path,
1189
+ document_id: str,
1190
+ *,
1191
+ anchor: str | None = None,
1192
+ depth: int = 1,
1193
+ include_related: bool = False,
1194
+ includes: list[str] | None = None,
1195
+ json_output: bool = False,
1196
+ outline: bool = False,
1197
+ assume_known: list[str] | None = None,
1198
+ since: str | None = None,
1199
+ ) -> int:
1200
+ if outline and (anchor is not None or includes):
1201
+ print(
1202
+ "ERROR: cannot combine --outline with --anchor or --include",
1203
+ file=sys.stderr,
1204
+ )
1205
+ return 1
1206
+ if outline and (assume_known or since is not None):
1207
+ print(
1208
+ "ERROR: cannot combine --outline with --assume-known or --since",
1209
+ file=sys.stderr,
1210
+ )
1211
+ return 1
1212
+ if since is not None and assume_known:
1213
+ print(
1214
+ "ERROR: cannot combine --since with --assume-known",
1215
+ file=sys.stderr,
1216
+ )
1217
+ return 1
1218
+ assumed: dict[str, int] = {}
1219
+ for raw in assume_known or []:
1220
+ document, separator, revision = raw.partition("@")
1221
+ if (
1222
+ not separator
1223
+ or not document
1224
+ or not (revision.isascii() and revision.isdigit())
1225
+ or int(revision) <= 0
1226
+ ):
1227
+ print(f"ERROR: invalid --assume-known value: {raw!r}", file=sys.stderr)
1228
+ return 1
1229
+ declared = int(revision)
1230
+ if document in assumed and assumed[document] != declared:
1231
+ print(
1232
+ f"ERROR: conflicting --assume-known declarations for {document}",
1233
+ file=sys.stderr,
1234
+ )
1235
+ return 1
1236
+ assumed[document] = declared
1237
+ since_manifest: dict[str, object] | None = None
1238
+ generation_short: str | None = None
1239
+ try:
1240
+ config = load_config(project_root)
1241
+ if since is not None:
1242
+ resolved = resolve_generation_manifest(config, since)
1243
+ if resolved is None:
1244
+ print(
1245
+ f"ERROR: unknown projection generation: {since}",
1246
+ file=sys.stderr,
1247
+ )
1248
+ return 1
1249
+ generation, since_manifest = resolved
1250
+ generation_short = generation[:12]
1251
+ views, _, catalog_value = _load_views(config)
1252
+ if catalog_value is not None:
1253
+ find_document(catalog_value, document_id)
1254
+ elif document_id not in views:
1255
+ raise ValueError(f"document ID not found: {document_id}")
1256
+ included = _context_selection(
1257
+ views,
1258
+ document_id,
1259
+ depth=depth,
1260
+ include_related=include_related,
1261
+ )
1262
+ forced: dict[str, list[str]] = {}
1263
+ for raw in includes or []:
1264
+ selected_id, selected_anchor = _selection(raw)
1265
+ if catalog_value is not None:
1266
+ find_document(catalog_value, selected_id)
1267
+ elif selected_id not in views:
1268
+ raise ValueError(f"document ID not found: {selected_id}")
1269
+ included.setdefault(selected_id, set()).add("explicit")
1270
+ if selected_anchor:
1271
+ forced.setdefault(selected_id, []).append(selected_anchor)
1272
+ for assumed_id in assumed:
1273
+ # A declared document is validated even when it does not enter the
1274
+ # packet, so a stale declaration fails closed instead of silently
1275
+ # doing nothing. Declaring an ID never forces its inclusion.
1276
+ if catalog_value is not None:
1277
+ find_document(catalog_value, assumed_id)
1278
+ elif assumed_id not in views:
1279
+ raise ValueError(f"document ID not found: {assumed_id}")
1280
+ selected_anchors = {
1281
+ selected_id: [
1282
+ *([anchor] if selected_id == document_id and anchor else []),
1283
+ *forced.get(selected_id, []),
1284
+ ]
1285
+ for selected_id in included
1286
+ }
1287
+ if catalog_value is not None:
1288
+ # A verified projection is only ever written for a tree with no
1289
+ # blocking errors, so this validation runs on the direct path only.
1290
+ by_id = {
1291
+ document.metadata.document_id: document
1292
+ for document in catalog_value.documents
1293
+ if document.metadata is not None
1294
+ }
1295
+ relevant_paths = {by_id[item].path for item in included}
1296
+ blockers = [
1297
+ issue
1298
+ for issue in (
1299
+ *validate_metadata(catalog_value),
1300
+ *validate_adoption(catalog_value, config),
1301
+ )
1302
+ if issue.affects_graph
1303
+ and issue.severity != "warning"
1304
+ and issue.path in relevant_paths
1305
+ ]
1306
+ for selected_id in included:
1307
+ document = by_id[selected_id]
1308
+ blockers.extend(
1309
+ ValidationIssue(document.path, message)
1310
+ for message in document_section_issues(document, config)
1311
+ )
1312
+ if blockers:
1313
+ for issue in blockers:
1314
+ print(
1315
+ f"ERROR: {issue.path.as_posix()}: {issue.message}",
1316
+ file=sys.stderr,
1317
+ )
1318
+ return 1
1319
+ for selected_id in included:
1320
+ known_anchors = {section.anchor for section in views[selected_id].sections}
1321
+ for selected_anchor in selected_anchors[selected_id]:
1322
+ if selected_anchor not in known_anchors:
1323
+ raise ValueError(
1324
+ f"anchor not found in {selected_id}: {selected_anchor}"
1325
+ )
1326
+ except ValueError as error:
1327
+ print(f"ERROR: {error}", file=sys.stderr)
1328
+ return 1
1329
+
1330
+ ordered = _ordered_selection(included, document_id)
1331
+ if outline:
1332
+ if json_output:
1333
+ return _emit_context_outline_json(
1334
+ config,
1335
+ views,
1336
+ included,
1337
+ ordered,
1338
+ document_id,
1339
+ depth=depth,
1340
+ include_related=include_related,
1341
+ )
1342
+ return _emit_context_outline_text(
1343
+ config,
1344
+ views,
1345
+ included,
1346
+ ordered,
1347
+ document_id,
1348
+ depth=depth,
1349
+ include_related=include_related,
1350
+ )
1351
+ plans, mismatches, extra_notes, assumed_known_omitted = _build_packet_plans(
1352
+ views,
1353
+ ordered,
1354
+ assumed=assumed,
1355
+ since_manifest=since_manifest,
1356
+ generation_short=generation_short,
1357
+ )
1358
+ if json_output:
1359
+ return _emit_context_json(
1360
+ config,
1361
+ views,
1362
+ included,
1363
+ selected_anchors,
1364
+ ordered,
1365
+ document_id,
1366
+ depth=depth,
1367
+ include_related=include_related,
1368
+ plans=plans,
1369
+ mismatches=mismatches,
1370
+ assumed_known_omitted=assumed_known_omitted,
1371
+ assume_known_used=bool(assume_known),
1372
+ )
1373
+ out: list[str] = []
1374
+ explicit_count = 0
1375
+ omitted_count = 0
1376
+ out.append(f"# Context packet: {document_id}")
1377
+ out.append("")
1378
+ out.append(f"- Dependency depth: {depth}")
1379
+ out.append(f"- Related traversal: {'included' if include_related else 'omitted'}")
1380
+ for selected_id in ordered:
1381
+ view = views[selected_id]
1382
+ plan = plans.get(selected_id)
1383
+ out.append("")
1384
+ out.append(f"## {selected_id} — {view.path.as_posix()}")
1385
+ out.append("")
1386
+ out.append(f"Relations: {', '.join(sorted(included[selected_id]))}.")
1387
+ explicit_anchors, changed_set, omitted = _packet_sections(
1388
+ config, view, selected_anchors[selected_id], plan
1389
+ )
1390
+ if plan is None or not plan.omit_navigation:
1391
+ out.append("")
1392
+ out.append(
1393
+ extract_navigation(
1394
+ view.content,
1395
+ view.sections,
1396
+ config.navigation_extend_through,
1397
+ ).rstrip()
1398
+ )
1399
+ for selected_anchor in explicit_anchors:
1400
+ section = next(
1401
+ (
1402
+ item
1403
+ for item in view.sections
1404
+ if item.anchor == selected_anchor
1405
+ ),
1406
+ None,
1407
+ )
1408
+ explicit_count += 1
1409
+ title = (
1410
+ "Changed section"
1411
+ if selected_anchor in changed_set
1412
+ else "Explicit section"
1413
+ )
1414
+ out.append("")
1415
+ out.append(f"### {title} `{selected_anchor}`")
1416
+ out.append("")
1417
+ out.append(extract_section(view.content, section).rstrip())
1418
+ omitted_count += len(omitted)
1419
+ out.append("")
1420
+ out.append(_coverage_line(plan, explicit_anchors, omitted))
1421
+ out.append("")
1422
+ out.append("## Diagnostics and boundaries")
1423
+ out.append("")
1424
+ for note in _context_diagnostic_notes(
1425
+ config,
1426
+ views,
1427
+ ordered,
1428
+ document_id,
1429
+ include_related=include_related,
1430
+ extra_notes=extra_notes,
1431
+ ):
1432
+ out.append(f"- {note}")
1433
+ out.append("- Expand with --depth, --include-related, or --include ID#anchor.")
1434
+ body = "\n".join(out) + "\n"
1435
+ line_count = body.count("\n")
1436
+ byte_count = len(body.encode("utf-8"))
1437
+ sys.stdout.write(body)
1438
+ print()
1439
+ print("## Packet stats")
1440
+ print()
1441
+ print(f"- Included documents: {len(ordered)}")
1442
+ print(f"- Explicit sections: {explicit_count}")
1443
+ print(f"- Omitted H2 sections: {omitted_count}")
1444
+ if assume_known:
1445
+ print(f"- Content omitted (assumed known): {assumed_known_omitted}")
1446
+ print(f"- Body size: {line_count} lines, {byte_count} UTF-8 bytes")
1447
+ return 0
1448
+
1449
+
1450
+ def impact(project_root: Path, document_id: str) -> int:
1451
+ try:
1452
+ config = load_config(project_root)
1453
+ views, incoming, catalog_value = _load_views(config)
1454
+ if catalog_value is not None:
1455
+ find_document(catalog_value, document_id)
1456
+ blockers = [
1457
+ issue
1458
+ for issue in (
1459
+ *validate_membership(catalog_value),
1460
+ *validate_metadata(catalog_value),
1461
+ *validate_adoption(catalog_value, config),
1462
+ )
1463
+ if issue.affects_graph and issue.severity != "warning"
1464
+ ]
1465
+ if blockers:
1466
+ for issue in blockers:
1467
+ print(
1468
+ f"ERROR: {issue.path.as_posix()}: {issue.message}",
1469
+ file=sys.stderr,
1470
+ )
1471
+ return 1
1472
+ target = views.get(document_id)
1473
+ if target is None:
1474
+ raise ValueError(f"document ID not found: {document_id}")
1475
+ except ValueError as error:
1476
+ print(f"ERROR: {error}", file=sys.stderr)
1477
+ return 1
1478
+ print(f"# Impact analysis: {document_id}")
1479
+ print()
1480
+ print(f"- Path: `{target.path.as_posix()}`")
1481
+ print(f"- Type/status: {target.document_type} / {target.status}")
1482
+ print(f"- Current revision: {target.revision}")
1483
+ print()
1484
+ print("| Downstream | Relation | Pin | Classification |")
1485
+ print("|---|---|---|---|")
1486
+ edges = incoming.get(document_id, ())
1487
+ for edge in edges:
1488
+ source_type = views[edge.peer_id].document_type
1489
+ if edge.relation == "related":
1490
+ classification = "related navigation"
1491
+ elif edge.relation == "validated_against":
1492
+ if source_type in config.snapshot_document_types:
1493
+ classification = "historical snapshot"
1494
+ elif edge.expected_revision == target.revision:
1495
+ classification = "freshness pin (current)"
1496
+ else:
1497
+ classification = "freshness pin (already stale)"
1498
+ elif edge.relation == "supersedes":
1499
+ classification = "lineage"
1500
+ else:
1501
+ classification = "semantic"
1502
+ pin = str(edge.expected_revision) if edge.expected_revision else "—"
1503
+ print(
1504
+ f"| `{edge.peer_id}` | {edge.relation} | {pin} | "
1505
+ f"{classification} |"
1506
+ )
1507
+ if not edges:
1508
+ print("| — | — | — | no reverse metadata dependencies |")
1509
+ return 0
1510
+
1511
+
1512
+ def migration_report(project_root: Path, *, json_output: bool = False) -> int:
1513
+ try:
1514
+ config = load_config(project_root)
1515
+ catalog_value = build_catalog(config)
1516
+ except ValueError as error:
1517
+ print(f"ERROR: {error}", file=sys.stderr)
1518
+ return 1
1519
+ if json_output:
1520
+ _print_json(
1521
+ {
1522
+ "resolved": [
1523
+ _migration_json(item)
1524
+ for item in catalog_value.relation_migrations
1525
+ ],
1526
+ "boundaries": [
1527
+ _boundary_json(item)
1528
+ for item in catalog_value.relation_boundaries
1529
+ ],
1530
+ }
1531
+ )
1532
+ return 0
1533
+ for item in catalog_value.relation_migrations:
1534
+ print(
1535
+ f"resolved\t{item.source_id}\t{item.relation}\t"
1536
+ f"{item.value}\t{item.target_id}"
1537
+ )
1538
+ for item in catalog_value.relation_boundaries:
1539
+ print(
1540
+ f"boundary\t{item.source_id}\t{item.relation}\t"
1541
+ f"{item.value}\t{item.reason}"
1542
+ )
1543
+ return 0
1544
+
1545
+
1546
+ def migrate(project_root: Path, *, apply: bool = False) -> int:
1547
+ """Preview, by default, or (with `apply`) write resolved legacy relations.
1548
+
1549
+ Preview is entirely read-only. `apply` computes the same plan, validates
1550
+ it against a scratch copy of the documentation tree, and only then
1551
+ rewrites the affected Markdown files atomically.
1552
+ """
1553
+
1554
+ try:
1555
+ config = load_config(project_root)
1556
+ except ValueError as error:
1557
+ print(f"ERROR: {error}", file=sys.stderr)
1558
+ return 1
1559
+ if not config.documentation_root.is_dir():
1560
+ print(
1561
+ f"ERROR: documentation root does not exist: {config.documentation_root}",
1562
+ file=sys.stderr,
1563
+ )
1564
+ return 1
1565
+ try:
1566
+ catalog_value = build_catalog(config)
1567
+ plan = build_migration_plan(config, catalog_value)
1568
+ problems = validate_plan(config, plan)
1569
+ except (OSError, UnicodeError, ValueError) as error:
1570
+ print(f"ERROR: failed to build migration plan: {error}", file=sys.stderr)
1571
+ return 1
1572
+ if problems:
1573
+ for problem in problems:
1574
+ print(f"ERROR: {problem}", file=sys.stderr)
1575
+ return 1
1576
+ if not plan.changes:
1577
+ print("No resolvable legacy relation migrations found.")
1578
+ return 0
1579
+
1580
+ for change in plan.changes:
1581
+ print(
1582
+ f"would-migrate\t{change.source_id}\t{change.relation}\t"
1583
+ f"{change.old_value}\t{change.new_value}\t{change.path.as_posix()}"
1584
+ )
1585
+ if apply:
1586
+ try:
1587
+ apply_migration_plan(config, plan)
1588
+ except (OSError, ValueError) as error:
1589
+ print(f"ERROR: failed to apply migration: {error}", file=sys.stderr)
1590
+ return 1
1591
+ print(
1592
+ f"Applied {len(plan.changes)} legacy relation migration(s) across "
1593
+ f"{len(plan.updated_contents)} file(s)."
1594
+ )
1595
+ else:
1596
+ print(
1597
+ f"Preview only; {len(plan.changes)} legacy relation migration(s) across "
1598
+ f"{len(plan.updated_contents)} file(s). Re-run with --apply to write."
1599
+ )
1600
+ return 0
1601
+
1602
+
1603
+ def _issue_json(issue: ValidationIssue) -> dict[str, object]:
1604
+ return {
1605
+ "path": issue.path.as_posix(),
1606
+ "message": issue.message,
1607
+ "severity": issue.severity,
1608
+ "target_id": issue.target_id,
1609
+ }
1610
+
1611
+
1612
+ def readiness(project_root: Path, *, json_output: bool = False) -> int:
1613
+ """Report, read-only, whether an existing project is adoption-ready.
1614
+
1615
+ Stable summary data (counts, projection state, the next safe command)
1616
+ goes to stdout; ERROR/WARNING diagnostics go to stderr, matching
1617
+ `validate`, `doctor` and `migrate`. `--json` prints one deterministic
1618
+ object carrying the same data in full instead of counts, so a consumer
1619
+ never has to parse the stderr diagnostics.
1620
+ """
1621
+
1622
+ try:
1623
+ config = load_config(project_root)
1624
+ except ValueError as error:
1625
+ print(f"ERROR: {error}", file=sys.stderr)
1626
+ return 1
1627
+ catalog_value = build_catalog(config)
1628
+ report = evaluate_readiness(config, catalog_value)
1629
+ next_command = report.next_command(str(project_root))
1630
+
1631
+ if json_output:
1632
+ # One payload shape for every project state: a missing documentation
1633
+ # root reports empty categories rather than a shorter object, so a
1634
+ # consumer never has to branch on which keys exist.
1635
+ _print_json(
1636
+ {
1637
+ "documentation_root_exists": report.documentation_root_exists,
1638
+ "ready": report.ready,
1639
+ "blocking": [_issue_json(issue) for issue in report.blocking],
1640
+ "resolvable_migrations": [
1641
+ _migration_json(item) for item in report.resolvable_migrations
1642
+ ],
1643
+ "boundaries": [_boundary_json(item) for item in report.boundaries],
1644
+ "stale_pins": [_issue_json(issue) for issue in report.stale_pins],
1645
+ "projection": {
1646
+ "state": report.projection_state,
1647
+ "reason": report.projection_reason,
1648
+ },
1649
+ "next_command": next_command,
1650
+ }
1651
+ )
1652
+ return 0 if report.ready else 1
1653
+
1654
+ if not report.documentation_root_exists:
1655
+ print(f"# Adoption readiness: {project_root}")
1656
+ print()
1657
+ print(
1658
+ f"ERROR: documentation root does not exist: {config.documentation_root}",
1659
+ file=sys.stderr,
1660
+ )
1661
+ print(f"- Next safe command: {next_command}")
1662
+ return 1
1663
+
1664
+ print(f"# Adoption readiness: {project_root}")
1665
+ print()
1666
+ print(f"- Blocking structural/configuration errors: {len(report.blocking)}")
1667
+ for issue in report.blocking:
1668
+ level = "WARNING" if issue.severity == "warning" else "ERROR"
1669
+ print(f" {level}: {issue.path.as_posix()}: {issue.message}", file=sys.stderr)
1670
+ print(f"- Resolvable legacy relation migrations: {len(report.resolvable_migrations)}")
1671
+ print(f"- Explicit unresolved/resource boundaries: {len(report.boundaries)}")
1672
+ print(f"- Stale freshness pins: {len(report.stale_pins)}")
1673
+ for issue in report.stale_pins:
1674
+ print(f" WARNING: {issue.path.as_posix()}: {issue.message}", file=sys.stderr)
1675
+ print(f"- Projection: {report.projection_state} ({report.projection_reason})")
1676
+ print()
1677
+ print(
1678
+ "Run `docsystem migration-report` or `docsystem validate --verbose-adoption` "
1679
+ "for row-level migration and boundary detail."
1680
+ )
1681
+ print(f"- Next safe command: {next_command}")
1682
+ return 0 if report.ready else 1
1683
+
1684
+
1685
+ def index_projection(project_root: Path, *, write: bool = False) -> int:
1686
+ try:
1687
+ config = load_config(project_root)
1688
+ catalog_value = build_catalog(config)
1689
+ errors = [
1690
+ issue
1691
+ for issue in validate_catalog(catalog_value, config)
1692
+ if issue.severity != "warning"
1693
+ ]
1694
+ if errors:
1695
+ for issue in errors:
1696
+ print(
1697
+ f"ERROR: {issue.path.as_posix()}: {issue.message}",
1698
+ file=sys.stderr,
1699
+ )
1700
+ return 1
1701
+ current = build_projection(catalog_value, config)
1702
+ valid, reason = projection_status(config, current)
1703
+ if write:
1704
+ generation = write_projection(config, current)
1705
+ print(f"Projection generation written: {generation}")
1706
+ return 0
1707
+ if not valid:
1708
+ print(f"ERROR: {reason}", file=sys.stderr)
1709
+ return 1
1710
+ print("Projection is current.")
1711
+ return 0
1712
+ except (OSError, ValueError) as error:
1713
+ print(f"ERROR: {error}", file=sys.stderr)
1714
+ return 1
1715
+
1716
+
1717
+ def changes(project_root: Path, *, json_output: bool = False) -> int:
1718
+ try:
1719
+ config = load_config(project_root)
1720
+ catalog_value = build_catalog(config)
1721
+ errors = [
1722
+ issue
1723
+ for issue in validate_catalog(catalog_value, config)
1724
+ if issue.severity != "warning"
1725
+ ]
1726
+ if errors:
1727
+ for issue in errors:
1728
+ print(
1729
+ f"ERROR: {issue.path.as_posix()}: {issue.message}",
1730
+ file=sys.stderr,
1731
+ )
1732
+ return 1
1733
+ current = build_projection(catalog_value, config)
1734
+ if json_output:
1735
+ report = evaluate_changes(config, current)
1736
+ _print_json(
1737
+ {
1738
+ "status": report.status,
1739
+ "changes": [
1740
+ {
1741
+ "document_id": change.document_id,
1742
+ "kind": change.kind,
1743
+ "sections": list(change.sections),
1744
+ }
1745
+ for change in report.changes
1746
+ ],
1747
+ }
1748
+ )
1749
+ return 0
1750
+ for line in projection_changes(config, current):
1751
+ print(line)
1752
+ return 0
1753
+ except (OSError, ValueError) as error:
1754
+ print(f"ERROR: {error}", file=sys.stderr)
1755
+ return 1
1756
+
1757
+
1758
+ def _validation_summary(issues: tuple[ValidationIssue, ...]) -> dict[str, int]:
1759
+ summary = {
1760
+ "errors": 0,
1761
+ "warnings": 0,
1762
+ "adoption_resolved": 0,
1763
+ "adoption_boundaries": 0,
1764
+ "stale_pins": 0,
1765
+ }
1766
+ for issue in issues:
1767
+ if issue.severity == "warning":
1768
+ summary["warnings"] += 1
1769
+ else:
1770
+ summary["errors"] += 1
1771
+ if issue.category == "adoption-resolved":
1772
+ summary["adoption_resolved"] += 1
1773
+ elif issue.category == "adoption-boundary":
1774
+ summary["adoption_boundaries"] += 1
1775
+ elif issue.target_id is not None:
1776
+ summary["stale_pins"] += 1
1777
+ return summary
1778
+
1779
+
1780
+ def _sanitize_local_error(error: Exception, project_root: Path) -> str:
1781
+ message = str(error)
1782
+ try:
1783
+ resolved = project_root.resolve().as_posix()
1784
+ except OSError:
1785
+ return message
1786
+ return message.replace(resolved, project_root.as_posix())
1787
+
1788
+
1789
+ def _readiness_diagnostics(
1790
+ config,
1791
+ catalog_value: MarkdownCatalog,
1792
+ project_root: Path,
1793
+ ) -> list[str]:
1794
+ report = evaluate_readiness(config, catalog_value)
1795
+ diagnostics = [
1796
+ f"documentation_root_exists={report.documentation_root_exists}",
1797
+ f"ready={report.ready}",
1798
+ f"blocking_errors={len(report.blocking)}",
1799
+ f"resolvable_migrations={len(report.resolvable_migrations)}",
1800
+ f"boundaries={len(report.boundaries)}",
1801
+ f"stale_pins={len(report.stale_pins)}",
1802
+ f"projection={report.projection_state} ({report.projection_reason})",
1803
+ f"next_command={report.next_command(str(project_root))}",
1804
+ ]
1805
+ return diagnostics
1806
+
1807
+
1808
+ def _report_body(
1809
+ *,
1810
+ project_root: Path,
1811
+ project_name: str,
1812
+ report_type: str,
1813
+ source: str,
1814
+ component: str | None,
1815
+ ) -> str:
1816
+ labels = [
1817
+ report_type,
1818
+ "triage",
1819
+ f"project:{_label_slug(project_name)}",
1820
+ f"source:{source}",
1821
+ ]
1822
+ if component:
1823
+ labels.append(f"component:{component}")
1824
+
1825
+ diagnostics: list[str] = []
1826
+ config_excerpt = "not available"
1827
+ affected: list[str] = []
1828
+ runtime_changes = "none; report draft is read-only"
1829
+ try:
1830
+ config = load_config(project_root)
1831
+ catalog_value = build_catalog(config)
1832
+ issues = validate_catalog(catalog_value, config)
1833
+ summary = _validation_summary(issues)
1834
+ diagnostics.extend(_readiness_diagnostics(config, catalog_value, project_root))
1835
+ diagnostics.append(
1836
+ "validation="
1837
+ f"{summary['errors']} error(s), {summary['warnings']} warning(s)"
1838
+ )
1839
+ diagnostics.append(
1840
+ "adoption="
1841
+ f"{summary['adoption_resolved']} resolved mapping(s), "
1842
+ f"{summary['adoption_boundaries']} boundary row(s)"
1843
+ )
1844
+ diagnostics.append(f"stale_pins={summary['stale_pins']}")
1845
+ views, _ = _views_from_catalog(catalog_value)
1846
+ ordered_ids = sorted(views)
1847
+ freshness = _freshness_rows(config, views, ordered_ids)
1848
+ historical_count = sum(
1849
+ 1
1850
+ for item in freshness
1851
+ if item["classification"] == "historical snapshot"
1852
+ )
1853
+ current_stale_count = sum(
1854
+ 1 for item in freshness if item["classification"] == "stale"
1855
+ )
1856
+ diagnostics.append(
1857
+ "freshness_classification="
1858
+ f"{current_stale_count} stale, {historical_count} historical snapshot"
1859
+ )
1860
+ current = build_projection(catalog_value, config)
1861
+ changes_report = evaluate_changes(config, current)
1862
+ diagnostics.append(
1863
+ f"changes={changes_report.status}, {len(changes_report.changes)} change(s)"
1864
+ )
1865
+ documentation_root = config.documentation_root.relative_to(
1866
+ config.project_root
1867
+ ).as_posix()
1868
+ config_excerpt = "\n".join(
1869
+ (
1870
+ f'documentation.root = "{documentation_root}"',
1871
+ f'documentation.language = "{config.language}"',
1872
+ "areas = "
1873
+ + json.dumps(
1874
+ {role: path.as_posix() for role, path in config.areas.items()},
1875
+ ensure_ascii=False,
1876
+ sort_keys=True,
1877
+ ),
1878
+ "identifiers = "
1879
+ + json.dumps(config.identifiers, ensure_ascii=False, sort_keys=True),
1880
+ "catalog.exclude = "
1881
+ + json.dumps(
1882
+ list(config.catalog_exclusions),
1883
+ ensure_ascii=False,
1884
+ sort_keys=True,
1885
+ ),
1886
+ "navigation.extend_through = "
1887
+ + json.dumps(
1888
+ list(config.navigation_extend_through),
1889
+ ensure_ascii=False,
1890
+ sort_keys=True,
1891
+ ),
1892
+ f'relations.legacy_paths = "{config.legacy_relation_mode}"',
1893
+ "relations.snapshot_types = "
1894
+ + json.dumps(
1895
+ list(config.snapshot_document_types),
1896
+ ensure_ascii=False,
1897
+ sort_keys=True,
1898
+ ),
1899
+ f'projection.format = "{config.projection_format}"',
1900
+ )
1901
+ )
1902
+ affected.extend(
1903
+ sorted(
1904
+ {
1905
+ issue.path.as_posix()
1906
+ for issue in issues
1907
+ if issue.severity != "warning" or issue.target_id is not None
1908
+ }
1909
+ )
1910
+ )
1911
+ except (OSError, ValueError) as error:
1912
+ diagnostics.append(
1913
+ f"configuration_error={_sanitize_local_error(error, project_root)}"
1914
+ )
1915
+
1916
+ title = REPORT_TYPES[report_type]
1917
+ component_text = component or "not selected"
1918
+ affected_text = "\n".join(f"- `{item}`" for item in affected) or "- none captured"
1919
+ diagnostics_text = "\n".join(f"- {item}" for item in diagnostics) or "- none captured"
1920
+ labels_text = ", ".join(f"`{label}`" for label in labels)
1921
+ command = (
1922
+ f"docsystem report draft {project_root} --project-name "
1923
+ f"{project_name!r} --type {report_type} --source {source}"
1924
+ + (f" --component {component}" if component else "")
1925
+ )
1926
+ return f"""# {title}: {project_name}
1927
+
1928
+ ## Labels
1929
+
1930
+ {labels_text}
1931
+
1932
+ ## Project
1933
+
1934
+ - Project/adopter name: {project_name}
1935
+ - Source host or agent: {source}
1936
+ - DocumentationEngine version: {__version__}
1937
+ - DocumentationEngine commit: <!-- fill if different from installed version -->
1938
+ - Component: {component_text}
1939
+
1940
+ ## Commands run and exit codes
1941
+
1942
+ ```bash
1943
+ {command}
1944
+ exit: 0
1945
+ ```
1946
+
1947
+ ## Compact diagnostics
1948
+
1949
+ {diagnostics_text}
1950
+
1951
+ ## Sanitized config/profile excerpt
1952
+
1953
+ ```text
1954
+ {config_excerpt}
1955
+ ```
1956
+
1957
+ ## Affected IDs, anchors, or paths
1958
+
1959
+ {affected_text}
1960
+
1961
+ ## Expected behavior
1962
+
1963
+ <!-- Fill in the expected behavior. -->
1964
+
1965
+ ## Actual behavior
1966
+
1967
+ <!-- Fill in the observed behavior or compatibility gap. -->
1968
+
1969
+ ## Runtime or local-state changes made
1970
+
1971
+ {runtime_changes}
1972
+
1973
+ ## Requested DocumentationEngine action
1974
+
1975
+ <!-- Fill in the requested fix, clarification, migration support, or pattern. -->
1976
+
1977
+ ## Privacy and sanitization checklist
1978
+
1979
+ - [ ] Private document bodies are omitted or sanitized.
1980
+ - [ ] Private scratch, review, roadmap, or planning content is omitted.
1981
+ - [ ] Config/profile excerpts are sanitized and contain no secrets.
1982
+ - [ ] Local artifact paths, if any, are pointers only.
1983
+ - [ ] A minimal public/synthetic fixture is included when this is a core bug.
1984
+ """
1985
+
1986
+
1987
+ def report_draft(
1988
+ project_root: Path,
1989
+ *,
1990
+ project_name: str,
1991
+ report_type: str,
1992
+ source: str,
1993
+ component: str | None = None,
1994
+ output: Path | None = None,
1995
+ ) -> int:
1996
+ if component is not None and component not in REPORT_COMPONENTS:
1997
+ print(f"ERROR: unsupported report component: {component}", file=sys.stderr)
1998
+ return 1
1999
+ text = _report_body(
2000
+ project_root=project_root,
2001
+ project_name=project_name,
2002
+ report_type=report_type,
2003
+ source=source,
2004
+ component=component,
2005
+ )
2006
+ return _write_text_or_stdout(text, output)
2007
+
2008
+
2009
+ def finish(
2010
+ project_root: Path,
2011
+ document_id: str,
2012
+ *,
2013
+ depth: int = 1,
2014
+ include_related: bool = False,
2015
+ json_output: bool = False,
2016
+ ) -> int:
2017
+ try:
2018
+ config = load_config(project_root)
2019
+ views, _, catalog_value = _load_views(config)
2020
+ if catalog_value is not None:
2021
+ find_document(catalog_value, document_id)
2022
+ elif document_id not in views:
2023
+ raise ValueError(f"document ID not found: {document_id}")
2024
+ included = _context_selection(
2025
+ views,
2026
+ document_id,
2027
+ depth=depth,
2028
+ include_related=include_related,
2029
+ )
2030
+ ordered = _ordered_selection(included, document_id)
2031
+ target = views[document_id]
2032
+ omitted_by_document = {
2033
+ selected_id: [
2034
+ section.anchor
2035
+ for section in views[selected_id].sections
2036
+ if section.level == 2
2037
+ and section.anchor not in config.navigation_extend_through
2038
+ ]
2039
+ for selected_id in ordered
2040
+ }
2041
+ freshness = _freshness_rows(config, views, ordered)
2042
+ except ValueError as error:
2043
+ print(f"ERROR: {error}", file=sys.stderr)
2044
+ return 1
2045
+
2046
+ migrations = [
2047
+ {
2048
+ "source_id": selected_id,
2049
+ "relation": relation,
2050
+ "value": value,
2051
+ "target_id": target_id,
2052
+ }
2053
+ for selected_id in ordered
2054
+ for relation, value, target_id in views[selected_id].migrations
2055
+ ]
2056
+ boundaries = [
2057
+ {
2058
+ "source_id": selected_id,
2059
+ "relation": relation,
2060
+ "value": value,
2061
+ "reason": reason,
2062
+ }
2063
+ for selected_id in ordered
2064
+ for relation, value, reason in views[selected_id].boundaries
2065
+ ]
2066
+ if json_output:
2067
+ _print_json(
2068
+ {
2069
+ "target": document_id,
2070
+ "path": target.path.as_posix(),
2071
+ "depth": depth,
2072
+ "include_related": include_related,
2073
+ "included_documents": [
2074
+ {
2075
+ "id": selected_id,
2076
+ "path": views[selected_id].path.as_posix(),
2077
+ "relations": sorted(included[selected_id]),
2078
+ "omitted_h2": omitted_by_document[selected_id],
2079
+ }
2080
+ for selected_id in ordered
2081
+ ],
2082
+ "freshness": freshness,
2083
+ "migrations": migrations,
2084
+ "boundaries": boundaries,
2085
+ }
2086
+ )
2087
+ return 0
2088
+
2089
+ print(f"# Finish handoff: {document_id}")
2090
+ print()
2091
+ print(f"- Path: `{target.path.as_posix()}`")
2092
+ print(f"- Type/status: {target.document_type} / {target.status}")
2093
+ print(f"- Revision: {target.revision}")
2094
+ print(f"- Dependency depth summarized: {depth}")
2095
+ print(f"- Related traversal: {'included' if include_related else 'omitted'}")
2096
+ print()
2097
+ print("## Included context")
2098
+ print()
2099
+ for selected_id in ordered:
2100
+ relation_text = ", ".join(sorted(included[selected_id]))
2101
+ omitted = omitted_by_document[selected_id]
2102
+ print(
2103
+ f"- `{selected_id}` — `{views[selected_id].path.as_posix()}` "
2104
+ f"({relation_text}); omitted H2: "
2105
+ f"{', '.join(omitted) if omitted else 'none'}"
2106
+ )
2107
+ print()
2108
+ print("## Freshness and snapshot pins")
2109
+ print()
2110
+ if freshness:
2111
+ for row in freshness:
2112
+ print(
2113
+ f"- `{row['source_id']}` pins `{row['target_id']}@"
2114
+ f"{row['pinned_revision']}`; current revision is "
2115
+ f"{row['current_revision']} — {row['classification']}"
2116
+ )
2117
+ else:
2118
+ print("- No stale or historical snapshot pins among included documents.")
2119
+ print()
2120
+ print("## Migration and boundary notes")
2121
+ print()
2122
+ if migrations:
2123
+ for item in migrations:
2124
+ print(
2125
+ f"- `{item['source_id']}` {item['relation']} "
2126
+ f"`{item['value']}` -> `{item['target_id']}`"
2127
+ )
2128
+ else:
2129
+ print("- No resolved legacy relation mappings among included documents.")
2130
+ if boundaries:
2131
+ for item in boundaries:
2132
+ print(
2133
+ f"- `{item['source_id']}` unresolved/resource "
2134
+ f"{item['relation']} `{item['value']}` ({item['reason']})"
2135
+ )
2136
+ else:
2137
+ print("- No unresolved/resource boundaries among included documents.")
2138
+ print()
2139
+ print("## Return protocol")
2140
+ print()
2141
+ print("- Re-run `docsystem validate PROJECT` before handing work back.")
2142
+ print("- Re-run `docsystem changes PROJECT` after writing a projection.")
2143
+ print("- File adopter reports with `docsystem report draft` when reusable gaps remain.")
2144
+ return 0
2145
+
2146
+
2147
+ def dependencies(project_root: Path, document_id: str, *, reverse: bool = False) -> int:
2148
+ try:
2149
+ config = load_config(project_root)
2150
+ markdown_catalog = build_catalog(config)
2151
+ document = find_document(markdown_catalog, document_id)
2152
+ metadata_issues = validate_metadata(markdown_catalog)
2153
+ relevant_issues = (
2154
+ (*validate_membership(markdown_catalog), *metadata_issues)
2155
+ if reverse
2156
+ else tuple(
2157
+ issue for issue in metadata_issues if issue.path == document.path
2158
+ )
2159
+ )
2160
+ graph_errors = [
2161
+ issue
2162
+ for issue in relevant_issues
2163
+ if issue.affects_graph and issue.severity != "warning"
2164
+ ]
2165
+ for issue in relevant_issues:
2166
+ related_warning = (
2167
+ issue.severity == "warning"
2168
+ and (not reverse or issue.target_id == document_id)
2169
+ )
2170
+ if related_warning or issue in graph_errors:
2171
+ level = "WARNING" if issue.severity == "warning" else "ERROR"
2172
+ print(
2173
+ f"{level}: {issue.path.as_posix()}: {issue.message}",
2174
+ file=sys.stderr,
2175
+ )
2176
+ if graph_errors:
2177
+ return 1
2178
+ graph = build_dependency_graph(markdown_catalog)
2179
+ except ValueError as error:
2180
+ print(f"ERROR: {error}", file=sys.stderr)
2181
+ return 1
2182
+
2183
+ edges = graph.incoming(document_id) if reverse else graph.outgoing(document_id)
2184
+ for edge in sorted(
2185
+ edges,
2186
+ key=lambda item: (
2187
+ item.relation,
2188
+ item.source_id if reverse else item.target_id,
2189
+ ),
2190
+ ):
2191
+ peer = edge.source_id if reverse else edge.target_id
2192
+ expected_revision = (
2193
+ str(edge.expected_revision)
2194
+ if edge.expected_revision is not None
2195
+ else "-"
2196
+ )
2197
+ print(f"{edge.relation}\t{peer}\t{expected_revision}")
2198
+ return 0
2199
+
2200
+
2201
+ def show_config(project_root: Path) -> int:
2202
+ try:
2203
+ config = load_config(project_root)
2204
+ except ValueError as error:
2205
+ print(f"ERROR: {error}", file=sys.stderr)
2206
+ return 1
2207
+ print(f"documentation_root={config.documentation_root}")
2208
+ print(f"language={config.language}")
2209
+ for role, path in sorted(config.areas.items()):
2210
+ print(f"area.{role}={path}")
2211
+ for role, prefix in sorted(config.identifiers.items()):
2212
+ print(f"identifier.{role}={prefix}")
2213
+ for pattern in config.catalog_exclusions:
2214
+ print(f"catalog.exclude={pattern}")
2215
+ for anchor in config.navigation_extend_through:
2216
+ print(f"navigation.extend_through={anchor}")
2217
+ print(f"relations.legacy_paths={config.legacy_relation_mode}")
2218
+ for document_type in config.snapshot_document_types:
2219
+ print(f"relations.snapshot_type={document_type}")
2220
+ print(f"projection.format={config.projection_format}")
2221
+ print(f"projection.keep_generations={config.keep_generations}")
2222
+ return 0
2223
+
2224
+
2225
+ def _agent_instructions_text(project_root: Path, config: ProjectConfig) -> str:
2226
+ doc_root = config.documentation_root.relative_to(config.project_root).as_posix()
2227
+ out: list[str] = []
2228
+ out.append("## Documentation with Documentation Engine")
2229
+ out.append("")
2230
+ out.append(
2231
+ "This project uses `docsystem` for structured Markdown documentation "
2232
+ f"rooted at `{doc_root}` (language: {config.language})."
2233
+ )
2234
+ out.append("")
2235
+ out.append("Configured areas and identifier namespaces:")
2236
+ out.append("")
2237
+ for role, path in sorted(config.areas.items()):
2238
+ out.append(f"- {role} -> {path.as_posix()}")
2239
+ for role, prefix in sorted(config.identifiers.items()):
2240
+ out.append(f"- {prefix} ({role})")
2241
+ out.append("")
2242
+ out.append("Agent rules:")
2243
+ out.append("")
2244
+ out.append(
2245
+ "- Always pass the project root explicitly; do not rely on the "
2246
+ "current working directory matching the intended project."
2247
+ )
2248
+ out.append(
2249
+ "- Start read-only with `docsystem readiness "
2250
+ f"{project_root} --json` and follow its `next_command` field."
2251
+ )
2252
+ out.append(
2253
+ "- Prefer `--json` on commands that support it instead of parsing "
2254
+ "human-readable text output."
2255
+ )
2256
+ out.append(
2257
+ "- Expand context with `--depth`, `--include` or `--include-related` "
2258
+ "instead of assuming an omitted document or section is irrelevant."
2259
+ )
2260
+ out.append(
2261
+ "- Never run `docsystem init`, `docsystem migrate --apply` or "
2262
+ "`docsystem index --write` without explicit approval."
2263
+ )
2264
+ out.append(
2265
+ "- Before mutating ignored/local-only documentation state, follow "
2266
+ "this project's local backup policy if one exists."
2267
+ )
2268
+ out.append("")
2269
+ out.append(
2270
+ "See `docs/agent-contract.md` in the Documentation Engine repository "
2271
+ "for the full agent contract."
2272
+ )
2273
+ return "\n".join(out) + "\n"
2274
+
2275
+
2276
+ def agent_instructions(project_root: Path, *, json_output: bool = False) -> int:
2277
+ """Print a deterministic agent-rules snippet for AGENTS.md/CLAUDE.md.
2278
+
2279
+ Read-only: the snippet is derived from the project's `.docsystem.toml`
2280
+ plus the engine's stable agent contract, never from parsing
2281
+ `docs/setup-guide.md`, so a pasted snippet cannot silently drift from the
2282
+ project's actually configured areas and identifiers. Works even when the
2283
+ documentation root itself is missing, since only configuration is read.
2284
+ """
2285
+ try:
2286
+ config = load_config(project_root)
2287
+ except ValueError as error:
2288
+ print(f"ERROR: {error}", file=sys.stderr)
2289
+ return 1
2290
+
2291
+ text = _agent_instructions_text(project_root, config)
2292
+ if json_output:
2293
+ _print_json({"text": text})
2294
+ return 0
2295
+ sys.stdout.write(text)
2296
+ return 0
2297
+
2298
+
2299
+ def build_parser() -> argparse.ArgumentParser:
2300
+ parser = argparse.ArgumentParser(description=__doc__)
2301
+ subparsers = parser.add_subparsers(dest="command", required=True)
2302
+ for command, help_text in (
2303
+ ("init", "Create configuration and the documentation root."),
2304
+ ("doctor", "Validate project configuration and filesystem state."),
2305
+ ("show-config", "Print the normalized project configuration."),
2306
+ ("catalog", "List Markdown files and their logical area roles."),
2307
+ ("validate", "Validate hierarchical Markdown navigation."),
2308
+ ):
2309
+ command_parser = subparsers.add_parser(command, help=help_text)
2310
+ command_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2311
+ if command == "catalog":
2312
+ command_parser.add_argument(
2313
+ "--explain",
2314
+ action="store_true",
2315
+ help="Classify every Markdown source under the documentation root.",
2316
+ )
2317
+ command_parser.add_argument(
2318
+ "--json",
2319
+ action="store_true",
2320
+ dest="json_output",
2321
+ help="Print a deterministic JSON object instead of tab-separated text.",
2322
+ )
2323
+ if command in {"doctor", "validate"}:
2324
+ command_parser.add_argument(
2325
+ "--verbose-adoption",
2326
+ action="store_true",
2327
+ help="Print every legacy adoption warning instead of summaries.",
2328
+ )
2329
+
2330
+ read_parser = subparsers.add_parser(
2331
+ "read", help="Read a Markdown document or section by stable ID."
2332
+ )
2333
+ read_parser.add_argument("document_id")
2334
+ read_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2335
+ selection = read_parser.add_mutually_exclusive_group()
2336
+ selection.add_argument("--anchor")
2337
+ selection.add_argument("--navigation", action="store_true")
2338
+ selection.add_argument("--list", action="store_true", dest="list_sections")
2339
+
2340
+ dependencies_parser = subparsers.add_parser(
2341
+ "dependencies", help="List forward or reverse semantic dependencies."
2342
+ )
2343
+ dependencies_parser.add_argument("document_id")
2344
+ dependencies_parser.add_argument(
2345
+ "project", nargs="?", type=Path, default=Path.cwd()
2346
+ )
2347
+ dependencies_parser.add_argument("--reverse", action="store_true")
2348
+
2349
+ context_parser = subparsers.add_parser("context", help="Build an inspectable context packet.")
2350
+ context_parser.add_argument("document_id")
2351
+ context_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2352
+ context_parser.add_argument("--anchor")
2353
+ context_parser.add_argument("--depth", type=int, choices=range(0, 6), default=1)
2354
+ context_parser.add_argument("--include-related", action="store_true")
2355
+ context_parser.add_argument("--include", action="append", default=[])
2356
+ context_parser.add_argument(
2357
+ "--json",
2358
+ action="store_true",
2359
+ dest="json_output",
2360
+ help="Print a deterministic JSON object instead of the Markdown packet.",
2361
+ )
2362
+ context_parser.add_argument(
2363
+ "--outline",
2364
+ action="store_true",
2365
+ help=(
2366
+ "Print section size maps instead of content; combine with --json "
2367
+ "for the structured form. Cannot combine with --anchor or --include."
2368
+ ),
2369
+ )
2370
+ context_parser.add_argument(
2371
+ "--assume-known",
2372
+ action="append",
2373
+ default=[],
2374
+ dest="assume_known",
2375
+ metavar="ID@REV",
2376
+ help=(
2377
+ "Declare a document already held at revision REV (repeatable). When "
2378
+ "it enters the packet at that exact revision its content is omitted; "
2379
+ "a revision mismatch includes content with a diagnostics note."
2380
+ ),
2381
+ )
2382
+ context_parser.add_argument(
2383
+ "--since",
2384
+ metavar="GENERATION",
2385
+ help=(
2386
+ "Emit a delta packet against a retained projection generation "
2387
+ "(full hash or unique >=12-char prefix): unchanged documents are "
2388
+ "omitted, changed documents add their changed sections."
2389
+ ),
2390
+ )
2391
+
2392
+ impact_parser = subparsers.add_parser("impact", help="Show reverse metadata impact.")
2393
+ impact_parser.add_argument("document_id")
2394
+ impact_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2395
+
2396
+ report_parser = subparsers.add_parser(
2397
+ "migration-report", help="Report legacy relation adoption mappings."
2398
+ )
2399
+ report_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2400
+ report_parser.add_argument(
2401
+ "--json",
2402
+ action="store_true",
2403
+ dest="json_output",
2404
+ help="Print a deterministic JSON object instead of tab-separated text.",
2405
+ )
2406
+
2407
+ migrate_parser = subparsers.add_parser(
2408
+ "migrate",
2409
+ help="Preview (default) or apply resolvable legacy relation migrations.",
2410
+ )
2411
+ migrate_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2412
+ migrate_parser.add_argument(
2413
+ "--apply",
2414
+ action="store_true",
2415
+ help="Write resolved values into Markdown source. Default is a "
2416
+ "non-mutating preview.",
2417
+ )
2418
+
2419
+ readiness_parser = subparsers.add_parser(
2420
+ "readiness", help="Report adoption readiness without writing source."
2421
+ )
2422
+ readiness_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2423
+ readiness_parser.add_argument(
2424
+ "--json",
2425
+ action="store_true",
2426
+ dest="json_output",
2427
+ help="Print a deterministic JSON object instead of a text summary.",
2428
+ )
2429
+
2430
+ index_parser = subparsers.add_parser("index", help="Check or write the projection.")
2431
+ index_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2432
+ index_parser.add_argument("--write", action="store_true")
2433
+
2434
+ changes_parser = subparsers.add_parser("changes", help="Show changes since projection.")
2435
+ changes_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2436
+ changes_parser.add_argument(
2437
+ "--json",
2438
+ action="store_true",
2439
+ dest="json_output",
2440
+ help="Print a deterministic JSON object instead of tab-separated text.",
2441
+ )
2442
+
2443
+ finish_parser = subparsers.add_parser(
2444
+ "finish",
2445
+ help="Build a compact handoff packet for returning work to a parent context.",
2446
+ )
2447
+ finish_parser.add_argument("document_id")
2448
+ finish_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2449
+ finish_parser.add_argument("--depth", type=int, choices=range(0, 6), default=1)
2450
+ finish_parser.add_argument("--include-related", action="store_true")
2451
+ finish_parser.add_argument(
2452
+ "--json",
2453
+ action="store_true",
2454
+ dest="json_output",
2455
+ help="Print a deterministic JSON object instead of the Markdown packet.",
2456
+ )
2457
+
2458
+ report_parser = subparsers.add_parser(
2459
+ "report",
2460
+ help="Create privacy-safe adopter report drafts.",
2461
+ )
2462
+ report_subparsers = report_parser.add_subparsers(
2463
+ dest="report_command", required=True
2464
+ )
2465
+ draft_parser = report_subparsers.add_parser(
2466
+ "draft",
2467
+ help="Draft a GitHub issue body from compact local diagnostics.",
2468
+ )
2469
+ draft_parser.add_argument("project", nargs="?", type=Path, default=Path.cwd())
2470
+ draft_parser.add_argument("--project-name", required=True)
2471
+ draft_parser.add_argument(
2472
+ "--type",
2473
+ required=True,
2474
+ choices=tuple(REPORT_TYPES),
2475
+ dest="report_type",
2476
+ )
2477
+ draft_parser.add_argument(
2478
+ "--source",
2479
+ required=True,
2480
+ choices=REPORT_SOURCES,
2481
+ )
2482
+ draft_parser.add_argument("--component")
2483
+ draft_parser.add_argument("--output", type=Path)
2484
+
2485
+ agent_instructions_parser = subparsers.add_parser(
2486
+ "agent-instructions",
2487
+ help="Print a deterministic agent-rules snippet for AGENTS.md/CLAUDE.md.",
2488
+ )
2489
+ agent_instructions_parser.add_argument(
2490
+ "project", nargs="?", type=Path, default=Path.cwd()
2491
+ )
2492
+ agent_instructions_parser.add_argument(
2493
+ "--json",
2494
+ action="store_true",
2495
+ dest="json_output",
2496
+ help="Print a deterministic JSON object instead of the Markdown snippet.",
2497
+ )
2498
+ return parser
2499
+
2500
+
2501
+ def main() -> int:
2502
+ args = build_parser().parse_args()
2503
+ if args.command == "read":
2504
+ return read_document(
2505
+ args.project,
2506
+ args.document_id,
2507
+ anchor=args.anchor,
2508
+ navigation=args.navigation,
2509
+ list_sections=args.list_sections,
2510
+ )
2511
+ if args.command == "dependencies":
2512
+ return dependencies(args.project, args.document_id, reverse=args.reverse)
2513
+ if args.command == "catalog":
2514
+ return catalog(args.project, explain=args.explain, json_output=args.json_output)
2515
+ if args.command == "context":
2516
+ return context(
2517
+ args.project,
2518
+ args.document_id,
2519
+ anchor=args.anchor,
2520
+ depth=args.depth,
2521
+ include_related=args.include_related,
2522
+ includes=args.include,
2523
+ json_output=args.json_output,
2524
+ outline=args.outline,
2525
+ assume_known=args.assume_known,
2526
+ since=args.since,
2527
+ )
2528
+ if args.command == "impact":
2529
+ return impact(args.project, args.document_id)
2530
+ if args.command == "migration-report":
2531
+ return migration_report(args.project, json_output=args.json_output)
2532
+ if args.command == "migrate":
2533
+ return migrate(args.project, apply=args.apply)
2534
+ if args.command == "readiness":
2535
+ return readiness(args.project, json_output=args.json_output)
2536
+ if args.command == "index":
2537
+ return index_projection(args.project, write=args.write)
2538
+ if args.command == "changes":
2539
+ return changes(args.project, json_output=args.json_output)
2540
+ if args.command == "finish":
2541
+ return finish(
2542
+ args.project,
2543
+ args.document_id,
2544
+ depth=args.depth,
2545
+ include_related=args.include_related,
2546
+ json_output=args.json_output,
2547
+ )
2548
+ if args.command == "agent-instructions":
2549
+ return agent_instructions(args.project, json_output=args.json_output)
2550
+ if args.command == "report":
2551
+ if args.report_command == "draft":
2552
+ return report_draft(
2553
+ args.project,
2554
+ project_name=args.project_name,
2555
+ report_type=args.report_type,
2556
+ source=args.source,
2557
+ component=args.component,
2558
+ output=args.output,
2559
+ )
2560
+ raise AssertionError(f"unknown report command: {args.report_command}")
2561
+ if args.command == "doctor":
2562
+ return doctor(
2563
+ args.project, verbose_adoption=args.verbose_adoption
2564
+ )
2565
+ if args.command == "validate":
2566
+ return validate(
2567
+ args.project, verbose_adoption=args.verbose_adoption
2568
+ )
2569
+ handlers = {
2570
+ "init": initialize,
2571
+ "show-config": show_config,
2572
+ }
2573
+ return handlers[args.command](args.project)