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/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"