documentation-engine 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docsystem/__init__.py +22 -0
- docsystem/__main__.py +4 -0
- docsystem/catalog.py +665 -0
- docsystem/cli.py +2573 -0
- docsystem/config.py +216 -0
- docsystem/mcp_server.py +324 -0
- docsystem/metadata.py +307 -0
- docsystem/migration.py +309 -0
- docsystem/projection.py +609 -0
- docsystem/readiness.py +132 -0
- docsystem/sections.py +252 -0
- documentation_engine-0.1.2.dist-info/METADATA +417 -0
- documentation_engine-0.1.2.dist-info/RECORD +16 -0
- documentation_engine-0.1.2.dist-info/WHEEL +4 -0
- documentation_engine-0.1.2.dist-info/entry_points.txt +3 -0
- documentation_engine-0.1.2.dist-info/licenses/LICENSE +21 -0
docsystem/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)
|