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/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
+ )
@@ -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())