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/readiness.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Read-only adoption readiness evaluation for existing Markdown projects.
|
|
2
|
+
|
|
3
|
+
This module never writes to the source tree or the projection cache; it only
|
|
4
|
+
re-reads catalog and projection state already produced by `catalog.py` and
|
|
5
|
+
`projection.py` and groups it into the categories an adopting project needs to
|
|
6
|
+
tell apart before running any mutating command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from docsystem.catalog import (
|
|
14
|
+
MarkdownCatalog,
|
|
15
|
+
RelationBoundary,
|
|
16
|
+
RelationMigration,
|
|
17
|
+
ValidationIssue,
|
|
18
|
+
validate_membership,
|
|
19
|
+
validate_metadata,
|
|
20
|
+
validate_reachability,
|
|
21
|
+
validate_sections,
|
|
22
|
+
)
|
|
23
|
+
from docsystem.config import ProjectConfig
|
|
24
|
+
from docsystem.projection import build_projection, projection_status
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ReadinessReport:
|
|
29
|
+
"""A deterministic snapshot of whether a project can adopt the engine."""
|
|
30
|
+
|
|
31
|
+
documentation_root_exists: bool
|
|
32
|
+
blocking: tuple[ValidationIssue, ...]
|
|
33
|
+
resolvable_migrations: tuple[RelationMigration, ...]
|
|
34
|
+
boundaries: tuple[RelationBoundary, ...]
|
|
35
|
+
stale_pins: tuple[ValidationIssue, ...]
|
|
36
|
+
projection_present: bool
|
|
37
|
+
projection_current: bool
|
|
38
|
+
projection_reason: str
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def ready(self) -> bool:
|
|
42
|
+
"""Whether the project is free of blocking structural errors."""
|
|
43
|
+
|
|
44
|
+
return self.documentation_root_exists and not self.blocking
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def projection_state(self) -> str:
|
|
48
|
+
if not self.projection_present:
|
|
49
|
+
return "absent"
|
|
50
|
+
if self.projection_current:
|
|
51
|
+
return "current"
|
|
52
|
+
return "stale"
|
|
53
|
+
|
|
54
|
+
def next_command(self, project: str) -> str:
|
|
55
|
+
"""The single safe next command; never a source-mutating default."""
|
|
56
|
+
|
|
57
|
+
if not self.documentation_root_exists:
|
|
58
|
+
return f"docsystem init {project}"
|
|
59
|
+
if self.blocking:
|
|
60
|
+
return f"docsystem doctor {project}"
|
|
61
|
+
if self.resolvable_migrations:
|
|
62
|
+
return f"docsystem migrate {project}"
|
|
63
|
+
if self.projection_state != "current":
|
|
64
|
+
return f"docsystem index {project} --write"
|
|
65
|
+
return f"docsystem context DOCUMENT_ID {project}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def evaluate_readiness(
|
|
69
|
+
config: ProjectConfig, catalog: MarkdownCatalog
|
|
70
|
+
) -> ReadinessReport:
|
|
71
|
+
"""Evaluate readiness without writing Markdown, cache or configuration."""
|
|
72
|
+
|
|
73
|
+
if not config.documentation_root.is_dir():
|
|
74
|
+
return ReadinessReport(
|
|
75
|
+
documentation_root_exists=False,
|
|
76
|
+
blocking=(),
|
|
77
|
+
resolvable_migrations=(),
|
|
78
|
+
boundaries=(),
|
|
79
|
+
stale_pins=(),
|
|
80
|
+
projection_present=False,
|
|
81
|
+
projection_current=False,
|
|
82
|
+
projection_reason="documentation root does not exist",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
paths = {
|
|
86
|
+
document.metadata.document_id: document.path
|
|
87
|
+
for document in catalog.documents
|
|
88
|
+
if document.metadata is not None
|
|
89
|
+
}
|
|
90
|
+
metadata_issues = validate_metadata(catalog)
|
|
91
|
+
self_reference_errors = tuple(
|
|
92
|
+
ValidationIssue(
|
|
93
|
+
paths[item.source_id],
|
|
94
|
+
f"legacy metadata.{item.relation} value {item.value!r}: {item.reason}",
|
|
95
|
+
affects_graph=True,
|
|
96
|
+
)
|
|
97
|
+
for item in catalog.relation_boundaries
|
|
98
|
+
if item.reason == "self reference"
|
|
99
|
+
)
|
|
100
|
+
blocking = tuple(
|
|
101
|
+
sorted(
|
|
102
|
+
(
|
|
103
|
+
*validate_membership(catalog),
|
|
104
|
+
*(issue for issue in metadata_issues if issue.severity != "warning"),
|
|
105
|
+
*validate_sections(catalog, config),
|
|
106
|
+
*validate_reachability(catalog, config),
|
|
107
|
+
*self_reference_errors,
|
|
108
|
+
),
|
|
109
|
+
key=lambda issue: (issue.path.as_posix(), issue.severity, issue.message),
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
stale_pins = tuple(
|
|
113
|
+
issue
|
|
114
|
+
for issue in metadata_issues
|
|
115
|
+
if issue.severity == "warning" and issue.target_id is not None
|
|
116
|
+
)
|
|
117
|
+
boundaries = tuple(
|
|
118
|
+
item for item in catalog.relation_boundaries if item.reason != "self reference"
|
|
119
|
+
)
|
|
120
|
+
current_projection = build_projection(catalog, config)
|
|
121
|
+
valid, reason = projection_status(config, current_projection)
|
|
122
|
+
projection_present = reason != "projection absent"
|
|
123
|
+
return ReadinessReport(
|
|
124
|
+
documentation_root_exists=True,
|
|
125
|
+
blocking=blocking,
|
|
126
|
+
resolvable_migrations=catalog.relation_migrations,
|
|
127
|
+
boundaries=boundaries,
|
|
128
|
+
stale_pins=stale_pins,
|
|
129
|
+
projection_present=projection_present,
|
|
130
|
+
projection_current=valid,
|
|
131
|
+
projection_reason=reason,
|
|
132
|
+
)
|
docsystem/sections.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Deterministic Markdown ATX heading and section extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
ATX_HEADING_PATTERN = re.compile(
|
|
9
|
+
r"^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$"
|
|
10
|
+
)
|
|
11
|
+
FENCE_PATTERN = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
|
12
|
+
EXPLICIT_ANCHOR_PATTERN = re.compile(
|
|
13
|
+
r"^ {0,3}<a\s+(?:id|name)=([\"'])([^\"']+)\1\s*></a>\s*$",
|
|
14
|
+
re.IGNORECASE,
|
|
15
|
+
)
|
|
16
|
+
ANCHOR_TAG_START_PATTERN = re.compile(r"^\s*<a\b", re.IGNORECASE)
|
|
17
|
+
ATTRIBUTE_NAME_PATTERN = re.compile(r"(?:^|\s)([^\s=/>]+)(?=\s|=|/|$)")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class MarkdownSection:
|
|
22
|
+
"""A stable, addressable section in a Markdown source."""
|
|
23
|
+
|
|
24
|
+
title: str
|
|
25
|
+
anchor: str
|
|
26
|
+
level: int
|
|
27
|
+
start_line: int
|
|
28
|
+
end_line: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class SectionParseResult:
|
|
33
|
+
"""Addressable sections plus deterministic parser diagnostics."""
|
|
34
|
+
|
|
35
|
+
sections: tuple[MarkdownSection, ...]
|
|
36
|
+
issues: tuple[str, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_valid_anchor(value: str) -> bool:
|
|
40
|
+
"""Return whether a canonical anchor uses the supported stable syntax."""
|
|
41
|
+
|
|
42
|
+
return bool(value) and value[0].isalnum() and all(
|
|
43
|
+
character.isalnum() or character in "-_.:" for character in value
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def heading_anchor(title: str) -> str:
|
|
48
|
+
"""Create a deterministic GitHub-like Unicode heading anchor."""
|
|
49
|
+
|
|
50
|
+
characters = [
|
|
51
|
+
character.casefold()
|
|
52
|
+
for character in title
|
|
53
|
+
if character.isalnum() or character in {" ", "-", "_", "\t"}
|
|
54
|
+
]
|
|
55
|
+
return re.sub(r"\s+", "-", "".join(characters)).strip("-")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_explicit_anchor_candidate(line: str) -> bool:
|
|
59
|
+
"""Detect id/name attribute tokens without inspecting quoted values."""
|
|
60
|
+
|
|
61
|
+
start = ANCHOR_TAG_START_PATTERN.match(line)
|
|
62
|
+
if start is None:
|
|
63
|
+
return False
|
|
64
|
+
opening: list[str] = []
|
|
65
|
+
quote: str | None = None
|
|
66
|
+
for character in line[start.end() :]:
|
|
67
|
+
if quote is not None:
|
|
68
|
+
if character == quote:
|
|
69
|
+
quote = None
|
|
70
|
+
opening.append(" ")
|
|
71
|
+
elif character in {'"', "'"}:
|
|
72
|
+
quote = character
|
|
73
|
+
opening.append(" ")
|
|
74
|
+
elif character == ">":
|
|
75
|
+
break
|
|
76
|
+
else:
|
|
77
|
+
opening.append(character)
|
|
78
|
+
names = {
|
|
79
|
+
match.group(1).casefold()
|
|
80
|
+
for match in ATTRIBUTE_NAME_PATTERN.finditer("".join(opening))
|
|
81
|
+
}
|
|
82
|
+
return bool(names & {"id", "name"})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_sections_result(text: str) -> SectionParseResult:
|
|
86
|
+
"""Parse ATX headings and explicit anchors without repairing ambiguity."""
|
|
87
|
+
|
|
88
|
+
lines = text.splitlines()
|
|
89
|
+
headings: list[tuple[str, str, int, int]] = []
|
|
90
|
+
issues: list[str] = []
|
|
91
|
+
anchor_counts: dict[str, int] = {}
|
|
92
|
+
anchor_owners: dict[str, tuple[str, int]] = {}
|
|
93
|
+
explicit_owners: dict[str, int] = {}
|
|
94
|
+
pending_anchors: list[tuple[str, int]] = []
|
|
95
|
+
fence_character: str | None = None
|
|
96
|
+
fence_length = 0
|
|
97
|
+
|
|
98
|
+
def orphan_pending() -> None:
|
|
99
|
+
for anchor, anchor_line in pending_anchors:
|
|
100
|
+
issues.append(
|
|
101
|
+
f"orphaned explicit anchor {anchor!r} at line {anchor_line}; "
|
|
102
|
+
"it must directly precede an ATX heading"
|
|
103
|
+
)
|
|
104
|
+
pending_anchors.clear()
|
|
105
|
+
|
|
106
|
+
for line_number, line in enumerate(lines, start=1):
|
|
107
|
+
fence = FENCE_PATTERN.match(line)
|
|
108
|
+
if fence_character is None and fence:
|
|
109
|
+
orphan_pending()
|
|
110
|
+
marker = fence.group(1)
|
|
111
|
+
if marker[0] == "`" and "`" in line[fence.end() :]:
|
|
112
|
+
continue
|
|
113
|
+
fence_character = marker[0]
|
|
114
|
+
fence_length = len(marker)
|
|
115
|
+
continue
|
|
116
|
+
if fence_character is not None:
|
|
117
|
+
if (
|
|
118
|
+
fence is not None
|
|
119
|
+
and fence.group(1)[0] == fence_character
|
|
120
|
+
and len(fence.group(1)) >= fence_length
|
|
121
|
+
and not line[fence.end() :].strip()
|
|
122
|
+
):
|
|
123
|
+
fence_character = None
|
|
124
|
+
fence_length = 0
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
explicit_match = EXPLICIT_ANCHOR_PATTERN.match(line)
|
|
128
|
+
if explicit_match is not None:
|
|
129
|
+
value = explicit_match.group(2)
|
|
130
|
+
if not is_valid_anchor(value):
|
|
131
|
+
orphan_pending()
|
|
132
|
+
issues.append(
|
|
133
|
+
f"malformed explicit anchor {value!r} at line {line_number}"
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
pending_anchors.append((value, line_number))
|
|
137
|
+
continue
|
|
138
|
+
if _is_explicit_anchor_candidate(line):
|
|
139
|
+
orphan_pending()
|
|
140
|
+
issues.append(f"malformed explicit anchor at line {line_number}")
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
match = ATX_HEADING_PATTERN.match(line)
|
|
144
|
+
if match is None:
|
|
145
|
+
orphan_pending()
|
|
146
|
+
continue
|
|
147
|
+
title = match.group(2).strip()
|
|
148
|
+
explicit = len(pending_anchors) == 1
|
|
149
|
+
if len(pending_anchors) > 1:
|
|
150
|
+
rendered = ", ".join(
|
|
151
|
+
f"{anchor!r} at line {anchor_line}"
|
|
152
|
+
for anchor, anchor_line in pending_anchors
|
|
153
|
+
)
|
|
154
|
+
issues.append(
|
|
155
|
+
f"multiple explicit anchors before heading at line {line_number}: "
|
|
156
|
+
f"{rendered}"
|
|
157
|
+
)
|
|
158
|
+
if explicit:
|
|
159
|
+
anchor, anchor_line = pending_anchors[0]
|
|
160
|
+
else:
|
|
161
|
+
base_anchor = heading_anchor(title)
|
|
162
|
+
occurrence = anchor_counts.get(base_anchor, 0)
|
|
163
|
+
anchor_counts[base_anchor] = occurrence + 1
|
|
164
|
+
anchor = (
|
|
165
|
+
base_anchor if occurrence == 0 else f"{base_anchor}-{occurrence}"
|
|
166
|
+
)
|
|
167
|
+
anchor_line = line_number
|
|
168
|
+
pending_anchors.clear()
|
|
169
|
+
|
|
170
|
+
owner = anchor_owners.get(anchor)
|
|
171
|
+
if explicit and anchor in explicit_owners:
|
|
172
|
+
issues.append(
|
|
173
|
+
f"duplicate explicit anchor {anchor!r} at line {anchor_line}; "
|
|
174
|
+
f"first used at line {explicit_owners[anchor]}"
|
|
175
|
+
)
|
|
176
|
+
elif owner is not None:
|
|
177
|
+
owner_kind, owner_line = owner
|
|
178
|
+
current_kind = "explicit" if explicit else "generated"
|
|
179
|
+
issues.append(
|
|
180
|
+
f"anchor collision {anchor!r}: {owner_kind} anchor at line "
|
|
181
|
+
f"{owner_line} and {current_kind} anchor at line {anchor_line}"
|
|
182
|
+
)
|
|
183
|
+
if explicit:
|
|
184
|
+
explicit_owners.setdefault(anchor, anchor_line)
|
|
185
|
+
anchor_owners.setdefault(
|
|
186
|
+
anchor, ("explicit" if explicit else "generated", anchor_line)
|
|
187
|
+
)
|
|
188
|
+
headings.append((title, anchor, len(match.group(1)), line_number))
|
|
189
|
+
orphan_pending()
|
|
190
|
+
|
|
191
|
+
sections: list[MarkdownSection] = []
|
|
192
|
+
for index, (title, anchor, level, start_line) in enumerate(headings):
|
|
193
|
+
end_line = len(lines)
|
|
194
|
+
for _, _, next_level, next_line in headings[index + 1 :]:
|
|
195
|
+
if next_level <= level:
|
|
196
|
+
end_line = next_line - 1
|
|
197
|
+
break
|
|
198
|
+
sections.append(
|
|
199
|
+
MarkdownSection(title, anchor, level, start_line, end_line)
|
|
200
|
+
)
|
|
201
|
+
return SectionParseResult(tuple(sections), tuple(issues))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def parse_sections(text: str) -> tuple[MarkdownSection, ...]:
|
|
205
|
+
"""Parse addressable sections while preserving the original public API."""
|
|
206
|
+
|
|
207
|
+
return parse_sections_result(text).sections
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def extract_section(text: str, section: MarkdownSection) -> str:
|
|
211
|
+
"""Return one section including its nested subsections."""
|
|
212
|
+
|
|
213
|
+
lines = text.splitlines()
|
|
214
|
+
return "\n".join(lines[section.start_line - 1 : section.end_line]).rstrip() + "\n"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def navigation_issues(
|
|
218
|
+
sections: tuple[MarkdownSection, ...], extend_through: tuple[str, ...]
|
|
219
|
+
) -> tuple[str, ...]:
|
|
220
|
+
"""Validate document-specific navigation extension anchors."""
|
|
221
|
+
|
|
222
|
+
issues: list[str] = []
|
|
223
|
+
for anchor in extend_through:
|
|
224
|
+
for section in sections:
|
|
225
|
+
if section.anchor == anchor and section.level != 2:
|
|
226
|
+
issues.append(
|
|
227
|
+
f"navigation.extend_through anchor {anchor!r} resolves to "
|
|
228
|
+
f"H{section.level}, expected H2"
|
|
229
|
+
)
|
|
230
|
+
return tuple(issues)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def extract_navigation(
|
|
234
|
+
text: str,
|
|
235
|
+
sections: tuple[MarkdownSection, ...],
|
|
236
|
+
extend_through: tuple[str, ...] = (),
|
|
237
|
+
) -> str:
|
|
238
|
+
"""Return the default prefix or extend it through configured H2 sections."""
|
|
239
|
+
|
|
240
|
+
first_h2 = next((section for section in sections if section.level == 2), None)
|
|
241
|
+
lines = text.splitlines()
|
|
242
|
+
matching = [
|
|
243
|
+
section
|
|
244
|
+
for section in sections
|
|
245
|
+
if section.level == 2 and section.anchor in extend_through
|
|
246
|
+
]
|
|
247
|
+
if matching:
|
|
248
|
+
end_line = max(section.end_line for section in matching)
|
|
249
|
+
return "\n".join(lines[:end_line]) + "\n"
|
|
250
|
+
if first_h2 is None:
|
|
251
|
+
return text if text.endswith("\n") else f"{text}\n"
|
|
252
|
+
return "\n".join(lines[: first_h2.start_line - 1]).rstrip() + "\n"
|