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/config.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Project configuration loading and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
|
|
10
|
+
from docsystem.sections import is_valid_anchor
|
|
11
|
+
|
|
12
|
+
CONFIG_FILENAME = ".docsystem.toml"
|
|
13
|
+
PREFIX_PATTERN = re.compile(r"^[A-Z][A-Z0-9]{1,15}$")
|
|
14
|
+
DEFAULT_CONFIG = """\
|
|
15
|
+
version = 1
|
|
16
|
+
|
|
17
|
+
[documentation]
|
|
18
|
+
root = "plan"
|
|
19
|
+
language = "en"
|
|
20
|
+
|
|
21
|
+
[areas]
|
|
22
|
+
foundation = "foundation"
|
|
23
|
+
architecture = "architecture"
|
|
24
|
+
decisions = "decisions"
|
|
25
|
+
roadmap = "roadmap"
|
|
26
|
+
scratch = "scratch"
|
|
27
|
+
reviews = "reviews"
|
|
28
|
+
experiments = "experiments"
|
|
29
|
+
modules = "modules"
|
|
30
|
+
|
|
31
|
+
[identifiers]
|
|
32
|
+
document = "DOC"
|
|
33
|
+
decision = "DEC"
|
|
34
|
+
roadmap = "RM"
|
|
35
|
+
|
|
36
|
+
[catalog]
|
|
37
|
+
exclude = []
|
|
38
|
+
|
|
39
|
+
[navigation]
|
|
40
|
+
extend_through = []
|
|
41
|
+
|
|
42
|
+
[relations]
|
|
43
|
+
legacy_paths = "strict"
|
|
44
|
+
snapshot_types = []
|
|
45
|
+
|
|
46
|
+
[projection]
|
|
47
|
+
format = "sharded-json"
|
|
48
|
+
keep_generations = 2
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ProjectConfig:
|
|
54
|
+
project_root: Path
|
|
55
|
+
documentation_root: Path
|
|
56
|
+
language: str
|
|
57
|
+
areas: dict[str, PurePosixPath]
|
|
58
|
+
identifiers: dict[str, str]
|
|
59
|
+
projection_format: str
|
|
60
|
+
keep_generations: int
|
|
61
|
+
catalog_exclusions: tuple[str, ...] = ()
|
|
62
|
+
navigation_extend_through: tuple[str, ...] = ()
|
|
63
|
+
legacy_relation_mode: str = "strict"
|
|
64
|
+
snapshot_document_types: tuple[str, ...] = ()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _relative_path(value: object, field: str) -> PurePosixPath:
|
|
68
|
+
if not isinstance(value, str) or not value:
|
|
69
|
+
raise ValueError(f"{field} must be a non-empty string")
|
|
70
|
+
path = PurePosixPath(value)
|
|
71
|
+
if path.is_absolute() or ".." in path.parts:
|
|
72
|
+
raise ValueError(f"{field} must be a project-relative path")
|
|
73
|
+
return path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _catalog_exclusions(raw: object) -> tuple[str, ...]:
|
|
77
|
+
if raw is None:
|
|
78
|
+
return ()
|
|
79
|
+
if not isinstance(raw, dict):
|
|
80
|
+
raise ValueError("catalog must be a table")
|
|
81
|
+
values = raw.get("exclude", [])
|
|
82
|
+
if not isinstance(values, list):
|
|
83
|
+
raise ValueError("catalog.exclude must be a list")
|
|
84
|
+
|
|
85
|
+
patterns: list[str] = []
|
|
86
|
+
seen: set[str] = set()
|
|
87
|
+
for index, value in enumerate(values):
|
|
88
|
+
field = f"catalog.exclude[{index}]"
|
|
89
|
+
if not isinstance(value, str) or not value:
|
|
90
|
+
raise ValueError(f"{field} must be a non-empty string")
|
|
91
|
+
if "\\" in value:
|
|
92
|
+
raise ValueError(f"{field} must use POSIX '/' separators")
|
|
93
|
+
path = PurePosixPath(value)
|
|
94
|
+
if path.is_absolute() or ".." in path.parts:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"{field} must be relative to the documentation root"
|
|
97
|
+
)
|
|
98
|
+
normalized = path.as_posix()
|
|
99
|
+
if normalized in seen:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"catalog.exclude contains duplicate normalized pattern "
|
|
102
|
+
f"{normalized!r}"
|
|
103
|
+
)
|
|
104
|
+
seen.add(normalized)
|
|
105
|
+
patterns.append(normalized)
|
|
106
|
+
return tuple(patterns)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _navigation_anchors(raw: object) -> tuple[str, ...]:
|
|
110
|
+
if raw is None:
|
|
111
|
+
return ()
|
|
112
|
+
if not isinstance(raw, dict):
|
|
113
|
+
raise ValueError("navigation must be a table")
|
|
114
|
+
values = raw.get("extend_through", [])
|
|
115
|
+
if not isinstance(values, list):
|
|
116
|
+
raise ValueError("navigation.extend_through must be a list")
|
|
117
|
+
|
|
118
|
+
anchors: list[str] = []
|
|
119
|
+
seen: set[str] = set()
|
|
120
|
+
for index, value in enumerate(values):
|
|
121
|
+
field = f"navigation.extend_through[{index}]"
|
|
122
|
+
if not isinstance(value, str) or not value:
|
|
123
|
+
raise ValueError(f"{field} must be a non-empty string")
|
|
124
|
+
if not is_valid_anchor(value):
|
|
125
|
+
raise ValueError(f"{field} has unsupported anchor syntax")
|
|
126
|
+
if value in seen:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"navigation.extend_through contains duplicate anchor {value!r}"
|
|
129
|
+
)
|
|
130
|
+
seen.add(value)
|
|
131
|
+
anchors.append(value)
|
|
132
|
+
return tuple(anchors)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _relations_policy(raw: object) -> tuple[str, tuple[str, ...]]:
|
|
136
|
+
if raw is None:
|
|
137
|
+
return "strict", ()
|
|
138
|
+
if not isinstance(raw, dict):
|
|
139
|
+
raise ValueError("relations must be a table")
|
|
140
|
+
mode = raw.get("legacy_paths", "strict")
|
|
141
|
+
if mode not in {"strict", "resolve-with-warning"}:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"relations.legacy_paths must be 'strict' or 'resolve-with-warning'"
|
|
144
|
+
)
|
|
145
|
+
types = raw.get("snapshot_types", [])
|
|
146
|
+
if not isinstance(types, list) or any(
|
|
147
|
+
not isinstance(item, str) or not item.strip() for item in types
|
|
148
|
+
):
|
|
149
|
+
raise ValueError("relations.snapshot_types must be a list of non-empty strings")
|
|
150
|
+
if len(set(types)) != len(types):
|
|
151
|
+
raise ValueError("relations.snapshot_types must be unique")
|
|
152
|
+
return mode, tuple(types)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_config(project_root: Path) -> ProjectConfig:
|
|
156
|
+
root = project_root.resolve()
|
|
157
|
+
config_path = root / CONFIG_FILENAME
|
|
158
|
+
if not config_path.is_file():
|
|
159
|
+
raise ValueError(f"configuration not found: {config_path}")
|
|
160
|
+
with config_path.open("rb") as handle:
|
|
161
|
+
raw = tomllib.load(handle)
|
|
162
|
+
|
|
163
|
+
if raw.get("version") != 1:
|
|
164
|
+
raise ValueError("unsupported configuration version")
|
|
165
|
+
documentation = raw.get("documentation")
|
|
166
|
+
areas = raw.get("areas")
|
|
167
|
+
identifiers = raw.get("identifiers")
|
|
168
|
+
projection = raw.get("projection")
|
|
169
|
+
if not all(isinstance(item, dict) for item in (documentation, areas, identifiers, projection)):
|
|
170
|
+
raise ValueError("documentation, areas, identifiers and projection tables are required")
|
|
171
|
+
|
|
172
|
+
documentation_path = _relative_path(documentation.get("root"), "documentation.root")
|
|
173
|
+
language = documentation.get("language")
|
|
174
|
+
if not isinstance(language, str) or not language:
|
|
175
|
+
raise ValueError("documentation.language must be a non-empty string")
|
|
176
|
+
|
|
177
|
+
normalized_areas = {
|
|
178
|
+
str(role): _relative_path(path, f"areas.{role}") for role, path in areas.items()
|
|
179
|
+
}
|
|
180
|
+
if len(set(normalized_areas.values())) != len(normalized_areas):
|
|
181
|
+
raise ValueError("area paths must be unique")
|
|
182
|
+
|
|
183
|
+
normalized_identifiers: dict[str, str] = {}
|
|
184
|
+
for role, prefix in identifiers.items():
|
|
185
|
+
if not isinstance(prefix, str) or not PREFIX_PATTERN.fullmatch(prefix):
|
|
186
|
+
raise ValueError(f"identifiers.{role} has an invalid prefix")
|
|
187
|
+
normalized_identifiers[str(role)] = prefix
|
|
188
|
+
if len(set(normalized_identifiers.values())) != len(normalized_identifiers):
|
|
189
|
+
raise ValueError("identifier prefixes must be unique")
|
|
190
|
+
|
|
191
|
+
catalog_exclusions = _catalog_exclusions(raw.get("catalog"))
|
|
192
|
+
navigation_extend_through = _navigation_anchors(raw.get("navigation"))
|
|
193
|
+
legacy_relation_mode, snapshot_document_types = _relations_policy(
|
|
194
|
+
raw.get("relations")
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
projection_format = projection.get("format")
|
|
198
|
+
if projection_format != "sharded-json":
|
|
199
|
+
raise ValueError("only sharded-json projection is supported")
|
|
200
|
+
keep_generations = projection.get("keep_generations")
|
|
201
|
+
if not isinstance(keep_generations, int) or not 1 <= keep_generations <= 20:
|
|
202
|
+
raise ValueError("projection.keep_generations must be between 1 and 20")
|
|
203
|
+
|
|
204
|
+
return ProjectConfig(
|
|
205
|
+
project_root=root,
|
|
206
|
+
documentation_root=root / documentation_path,
|
|
207
|
+
language=language,
|
|
208
|
+
areas=normalized_areas,
|
|
209
|
+
identifiers=normalized_identifiers,
|
|
210
|
+
projection_format=projection_format,
|
|
211
|
+
keep_generations=keep_generations,
|
|
212
|
+
catalog_exclusions=catalog_exclusions,
|
|
213
|
+
navigation_extend_through=navigation_extend_through,
|
|
214
|
+
legacy_relation_mode=legacy_relation_mode,
|
|
215
|
+
snapshot_document_types=snapshot_document_types,
|
|
216
|
+
)
|
docsystem/mcp_server.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Thin MCP (Model Context Protocol) adapter over the `docsystem` CLI.
|
|
2
|
+
|
|
3
|
+
The adapter translates MCP tool calls into the same CLI invocations the
|
|
4
|
+
agent contract documents, running them in a subprocess so it can never
|
|
5
|
+
bypass the core's validation, projection-fallback or output contracts.
|
|
6
|
+
Only read-only commands are exposed; mutating operations (`init`,
|
|
7
|
+
`migrate --apply`, `index --write`) stay with the human or calling system,
|
|
8
|
+
matching `docs/agent-contract.md`.
|
|
9
|
+
|
|
10
|
+
Structured (object) tools surface any successful-exit CLI stderr -- most
|
|
11
|
+
importantly the `projection stale/corrupt; using direct Markdown` fallback
|
|
12
|
+
warning -- under a `diagnostics` key, so a client never loses that signal.
|
|
13
|
+
Text tools (`read_document`, `impact`) keep returning the CLI stdout unchanged
|
|
14
|
+
for compatibility. Their packet variants (`read_document_packet`,
|
|
15
|
+
`impact_packet`) expose the same stdout together with successful-exit
|
|
16
|
+
diagnostics in a structured envelope.
|
|
17
|
+
|
|
18
|
+
The `mcp` package is an optional dependency: install `documentation-engine[mcp]`
|
|
19
|
+
to run the server (`docsystem-mcp` or `python -m docsystem.mcp_server`). The
|
|
20
|
+
tool functions themselves are plain Python and work without it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _invoke(
|
|
31
|
+
arguments: list[str], *, allow_failure_payload: bool = False
|
|
32
|
+
) -> tuple[str, str]:
|
|
33
|
+
"""Run a CLI command, returning its stdout and stderr.
|
|
34
|
+
|
|
35
|
+
A non-zero exit raises `RuntimeError` carrying the CLI diagnostics, so a
|
|
36
|
+
client never mistakes a failure for data. The one exception is a command
|
|
37
|
+
that prints a payload while exiting non-zero (for example `readiness`
|
|
38
|
+
reporting a legitimate "not ready" state): with `allow_failure_payload`
|
|
39
|
+
that payload is returned instead of raising.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
[sys.executable, "-m", "docsystem", *arguments],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
text=True,
|
|
47
|
+
)
|
|
48
|
+
if result.returncode != 0 and not (allow_failure_payload and result.stdout.strip()):
|
|
49
|
+
message = result.stderr.strip() or result.stdout.strip()
|
|
50
|
+
raise RuntimeError(message or f"docsystem {' '.join(arguments)} failed")
|
|
51
|
+
return result.stdout, result.stderr
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_cli(arguments: list[str], *, allow_failure_payload: bool = False) -> str:
|
|
55
|
+
stdout, _ = _invoke(arguments, allow_failure_payload=allow_failure_payload)
|
|
56
|
+
return stdout
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _text_packet(arguments: list[str]) -> dict:
|
|
60
|
+
"""Run a text CLI command and preserve successful-exit diagnostics."""
|
|
61
|
+
|
|
62
|
+
stdout, stderr = _invoke(arguments)
|
|
63
|
+
payload = {"schema_version": 1, "text": stdout}
|
|
64
|
+
diagnostics = _diagnostics(stderr)
|
|
65
|
+
if diagnostics:
|
|
66
|
+
payload["diagnostics"] = diagnostics
|
|
67
|
+
return payload
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _diagnostics(stderr: str) -> list[str]:
|
|
71
|
+
"""Return non-empty successful-exit stderr lines, in CLI order."""
|
|
72
|
+
|
|
73
|
+
return [line for line in stderr.splitlines() if line.strip()]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _json_tool(
|
|
77
|
+
arguments: list[str], *, allow_failure_payload: bool = False
|
|
78
|
+
) -> dict:
|
|
79
|
+
"""Run a `--json` CLI command and decode its structured payload.
|
|
80
|
+
|
|
81
|
+
On a successful exit the CLI can still print diagnostics to stderr -- most
|
|
82
|
+
importantly the `WARNING: projection ...; using direct Markdown` note when
|
|
83
|
+
a `context`/read command falls back from a stale or corrupt projection.
|
|
84
|
+
Those lines would otherwise be lost, so they are surfaced deterministically
|
|
85
|
+
under a `diagnostics` key. The key is present only when the CLI emitted
|
|
86
|
+
such diagnostics, keeping every other payload byte-identical to the CLI's
|
|
87
|
+
`--json` output.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
stdout, stderr = _invoke(arguments, allow_failure_payload=allow_failure_payload)
|
|
91
|
+
payload = json.loads(stdout)
|
|
92
|
+
diagnostics = _diagnostics(stderr)
|
|
93
|
+
if diagnostics:
|
|
94
|
+
payload["diagnostics"] = diagnostics
|
|
95
|
+
return payload
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def readiness(project: str) -> dict:
|
|
99
|
+
"""Report adoption readiness for a project as a structured object.
|
|
100
|
+
|
|
101
|
+
Read-only. `ready` is false while blocking errors remain; `next_command`
|
|
102
|
+
names the single safe next step. Always pass the project root explicitly.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
return _json_tool(["readiness", project, "--json"], allow_failure_payload=True)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def catalog(project: str, explain: bool = False) -> dict:
|
|
109
|
+
"""List cataloged Markdown documents and their logical roles.
|
|
110
|
+
|
|
111
|
+
Read-only. With `explain`, classifies every Markdown source under the
|
|
112
|
+
documentation root as included, excluded or unmapped.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
arguments = ["catalog", project, "--json"]
|
|
116
|
+
if explain:
|
|
117
|
+
arguments.insert(2, "--explain")
|
|
118
|
+
return _json_tool(arguments)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def migration_report(project: str) -> dict:
|
|
122
|
+
"""Report resolvable legacy relation migrations and explicit boundaries.
|
|
123
|
+
|
|
124
|
+
Read-only dry run: `resolved` rows are safe path-to-ID migration
|
|
125
|
+
candidates; `boundaries` (external URLs, resources) are human decisions
|
|
126
|
+
and must never be converted into invented document IDs.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
return _json_tool(["migration-report", project, "--json"])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def changes(project: str) -> dict:
|
|
133
|
+
"""Report documents and sections changed since the selected projection."""
|
|
134
|
+
|
|
135
|
+
return _json_tool(["changes", project, "--json"])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def context(
|
|
139
|
+
project: str,
|
|
140
|
+
document_id: str,
|
|
141
|
+
depth: int = 1,
|
|
142
|
+
include_related: bool = False,
|
|
143
|
+
include: list[str] | None = None,
|
|
144
|
+
anchor: str | None = None,
|
|
145
|
+
outline: bool = False,
|
|
146
|
+
assume_known: list[str] | None = None,
|
|
147
|
+
since: str | None = None,
|
|
148
|
+
) -> dict:
|
|
149
|
+
"""Build a deterministic, inspectable context packet for a document.
|
|
150
|
+
|
|
151
|
+
Read-only. The packet reports exactly what was included and omitted —
|
|
152
|
+
never a silent token-budget truncation. Expand coverage with `depth`,
|
|
153
|
+
`include_related` or explicit `include` selections (`ID` or `ID#anchor`)
|
|
154
|
+
instead of assuming omitted material is irrelevant. Set `outline` for a
|
|
155
|
+
map-first packet of per-section `lines`/`bytes` sizes with no document
|
|
156
|
+
content, to budget tokens before fetching; `outline` cannot combine with
|
|
157
|
+
`anchor` or `include`. Declare documents already held with
|
|
158
|
+
`assume_known` (`ID@REV`, repeatable): a document at the declared
|
|
159
|
+
revision has its content omitted, a mismatch keeps it. Request a delta
|
|
160
|
+
against a retained projection generation with `since` (full hash or
|
|
161
|
+
unique >=12-char prefix): unchanged documents are omitted and changed
|
|
162
|
+
ones carry only their changed sections. `since` cannot combine with
|
|
163
|
+
`assume_known`, and neither combines with `outline`. If the CLI serves
|
|
164
|
+
the packet by falling back from a stale or corrupt projection to direct
|
|
165
|
+
Markdown, the fallback warning is surfaced under `diagnostics`.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
arguments = ["context", document_id, project, "--depth", str(depth), "--json"]
|
|
169
|
+
if include_related:
|
|
170
|
+
arguments.append("--include-related")
|
|
171
|
+
if anchor is not None:
|
|
172
|
+
arguments.extend(["--anchor", anchor])
|
|
173
|
+
for item in include or []:
|
|
174
|
+
arguments.extend(["--include", item])
|
|
175
|
+
if outline:
|
|
176
|
+
arguments.append("--outline")
|
|
177
|
+
for item in assume_known or []:
|
|
178
|
+
arguments.extend(["--assume-known", item])
|
|
179
|
+
if since is not None:
|
|
180
|
+
arguments.extend(["--since", since])
|
|
181
|
+
return _json_tool(arguments)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def read_document(
|
|
185
|
+
project: str,
|
|
186
|
+
document_id: str,
|
|
187
|
+
anchor: str | None = None,
|
|
188
|
+
navigation: bool = False,
|
|
189
|
+
list_sections: bool = False,
|
|
190
|
+
) -> str:
|
|
191
|
+
"""Read a Markdown document, navigation prefix or section by stable ID.
|
|
192
|
+
|
|
193
|
+
Read-only. `anchor` returns one section; `navigation` returns the
|
|
194
|
+
navigation prefix; `list_sections` returns tab-separated section rows
|
|
195
|
+
(`anchor`, `Hn`, `start:end`, `title`) in document order.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
arguments = ["read", document_id, project]
|
|
199
|
+
if anchor is not None:
|
|
200
|
+
arguments.extend(["--anchor", anchor])
|
|
201
|
+
elif navigation:
|
|
202
|
+
arguments.append("--navigation")
|
|
203
|
+
elif list_sections:
|
|
204
|
+
arguments.append("--list")
|
|
205
|
+
return _run_cli(arguments)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def read_document_packet(
|
|
209
|
+
project: str,
|
|
210
|
+
document_id: str,
|
|
211
|
+
anchor: str | None = None,
|
|
212
|
+
navigation: bool = False,
|
|
213
|
+
list_sections: bool = False,
|
|
214
|
+
) -> dict:
|
|
215
|
+
"""Read a document and return text plus successful-exit diagnostics.
|
|
216
|
+
|
|
217
|
+
This is the structured counterpart to `read_document`: it preserves the
|
|
218
|
+
exact CLI stdout under `text` and adds `diagnostics` when the CLI emitted
|
|
219
|
+
non-fatal stderr, such as projection fallback warnings.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
arguments = ["read", document_id, project]
|
|
223
|
+
if anchor is not None:
|
|
224
|
+
arguments.extend(["--anchor", anchor])
|
|
225
|
+
elif navigation:
|
|
226
|
+
arguments.append("--navigation")
|
|
227
|
+
elif list_sections:
|
|
228
|
+
arguments.append("--list")
|
|
229
|
+
return _text_packet(arguments)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def dependencies(
|
|
233
|
+
project: str, document_id: str, reverse: bool = False
|
|
234
|
+
) -> list[dict]:
|
|
235
|
+
"""List forward (or, with `reverse`, incoming) semantic dependency edges.
|
|
236
|
+
|
|
237
|
+
Read-only. Fails closed with an error instead of returning a silently
|
|
238
|
+
incomplete graph when metadata errors affect the answer.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
arguments = ["dependencies", document_id, project]
|
|
242
|
+
if reverse:
|
|
243
|
+
arguments.append("--reverse")
|
|
244
|
+
rows: list[dict] = []
|
|
245
|
+
for line in _run_cli(arguments).splitlines():
|
|
246
|
+
relation, peer_id, expected = line.split("\t")
|
|
247
|
+
rows.append(
|
|
248
|
+
{
|
|
249
|
+
"relation": relation,
|
|
250
|
+
"peer_id": peer_id,
|
|
251
|
+
"expected_revision": None if expected == "-" else int(expected),
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
return rows
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def impact(project: str, document_id: str) -> str:
|
|
258
|
+
"""Report reverse metadata impact for a document as a Markdown table.
|
|
259
|
+
|
|
260
|
+
Read-only. Distinguishes semantic, related-navigation, freshness-pin and
|
|
261
|
+
configured historical-snapshot relations.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
return _run_cli(["impact", document_id, project])
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def impact_packet(project: str, document_id: str) -> dict:
|
|
268
|
+
"""Report reverse metadata impact with text plus non-fatal diagnostics."""
|
|
269
|
+
|
|
270
|
+
return _text_packet(["impact", document_id, project])
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def agent_instructions(project: str) -> dict:
|
|
274
|
+
"""Return the deterministic agent-rules snippet for AGENTS.md/CLAUDE.md.
|
|
275
|
+
|
|
276
|
+
Read-only: derived from the project's `.docsystem.toml` plus the
|
|
277
|
+
engine's stable agent contract, never from parsing
|
|
278
|
+
`docs/setup-guide.md`, so the pasted snippet cannot drift from the
|
|
279
|
+
project's actually configured areas and identifiers. Works even when
|
|
280
|
+
the documentation root itself is missing, since only configuration is
|
|
281
|
+
read.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
return _json_tool(["agent-instructions", project, "--json"])
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_TOOLS = (
|
|
288
|
+
readiness,
|
|
289
|
+
catalog,
|
|
290
|
+
migration_report,
|
|
291
|
+
changes,
|
|
292
|
+
context,
|
|
293
|
+
read_document,
|
|
294
|
+
read_document_packet,
|
|
295
|
+
dependencies,
|
|
296
|
+
impact,
|
|
297
|
+
impact_packet,
|
|
298
|
+
agent_instructions,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def build_server():
|
|
303
|
+
"""Create the FastMCP server with every read-only tool registered."""
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
from mcp.server.fastmcp import FastMCP
|
|
307
|
+
except ImportError as error:
|
|
308
|
+
raise RuntimeError(
|
|
309
|
+
"the MCP adapter requires the optional 'mcp' dependency; "
|
|
310
|
+
"install it with: pip install 'documentation-engine[mcp]'"
|
|
311
|
+
) from error
|
|
312
|
+
server = FastMCP("docsystem")
|
|
313
|
+
for tool in _TOOLS:
|
|
314
|
+
server.tool()(tool)
|
|
315
|
+
return server
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def main() -> int:
|
|
319
|
+
build_server().run()
|
|
320
|
+
return 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
raise SystemExit(main())
|