plan-doc 0.1.0__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.
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plan-doc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed parser for plan-file YAML frontmatter (schema v1) — strict validation, first-block-only extraction, foreign-document discrimination
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=9.0.3; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# plan-doc
|
|
23
|
+
|
|
24
|
+
Typed parser for plan-file YAML frontmatter (schema v1). Extracts the
|
|
25
|
+
machine-readable fields a plan-driven-PR workflow needs — `template`,
|
|
26
|
+
`disposal`, `branch`, a planner-handoff `dossier` block — from markdown
|
|
27
|
+
plan documents, with strict validation and loud failures.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from plan_doc import PlanDoc, PlanDocError, NonDraftPrFrontmatterError
|
|
32
|
+
|
|
33
|
+
doc = PlanDoc.from_path(Path("docs/plans/my-plan.md"))
|
|
34
|
+
if doc.disposal == "delete-on-merge":
|
|
35
|
+
...
|
|
36
|
+
if doc.dossier is not None:
|
|
37
|
+
files = doc.dossier.get("files", [])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## What it enforces
|
|
41
|
+
|
|
42
|
+
- **Schema v1.** `plan_schema: 1` is required; `template`, `disposal`,
|
|
43
|
+
`phase`, `cleanup_trigger`, `ticket`, `branch`, `tdd_mode`, `dossier`
|
|
44
|
+
are the only other top-level keys. Unknown keys raise `PlanDocError` —
|
|
45
|
+
this is the typo guard (`dispozal` fails loud).
|
|
46
|
+
- **First-block-only parsing.** Only a `---`...`---` block at the very
|
|
47
|
+
top of the file is frontmatter. Body-level `---` horizontal rules are
|
|
48
|
+
never split on.
|
|
49
|
+
- **Foreign-document discrimination.** Valid frontmatter with no
|
|
50
|
+
`plan_schema` and no plan indicator fields raises the typed subclass
|
|
51
|
+
`NonDraftPrFrontmatterError`, so callers can treat non-plan documents
|
|
52
|
+
leniently without swallowing real validation errors.
|
|
53
|
+
- **No frontmatter at all** raises `PlanDocError` with an actionable
|
|
54
|
+
message (add `plan_schema: 1`).
|
|
55
|
+
|
|
56
|
+
## Dependency note
|
|
57
|
+
|
|
58
|
+
`pyyaml` is a hard dependency, and the module *also* keeps a minimal
|
|
59
|
+
no-yaml fallback parser (`_parse_yaml_minimal`) for simple `key: value`
|
|
60
|
+
frontmatter. Both halves are deliberate: the upstream source of this
|
|
61
|
+
module runs in a zero-pip environment and relies on the fallback, while
|
|
62
|
+
the package declares full functionality. The fallback is covered by
|
|
63
|
+
tests here — don't remove either half.
|
|
64
|
+
|
|
65
|
+
## Provenance
|
|
66
|
+
|
|
67
|
+
Extracted verbatim (code byte-identical, module docstring reframed) from
|
|
68
|
+
the draft-pr skill of the m0j0d portfolio plugin, where the vendored copy
|
|
69
|
+
remains the operational source of truth. This package mirrors it at
|
|
70
|
+
release points.
|
|
71
|
+
|
|
72
|
+
Pool purity rule: transport-free, LLM-agnostic, no secrets. Pure parsing;
|
|
73
|
+
the only I/O is `Path.read_text` in `PlanDoc.from_path`.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
plan_doc.py,sha256=_mt8zPveaop9xNGy74j4swpFBCwW7F65x8jCvUzM9cA,19116
|
|
2
|
+
plan_doc-0.1.0.dist-info/licenses/LICENSE,sha256=AHOl9dkJ8tFtExkZkApUx1vccbMmXPw7i8AhZqSAl6U,1062
|
|
3
|
+
plan_doc-0.1.0.dist-info/METADATA,sha256=m7vaRGgHuVRtR_koYSUQy6KKzP0CgOcjjyuzs7xvv_g,3023
|
|
4
|
+
plan_doc-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
plan_doc-0.1.0.dist-info/top_level.txt,sha256=2kQZdRpU55PG0m2UVh0zLsr9ZqTkGEkK5cS-CdxjS9o,9
|
|
6
|
+
plan_doc-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 m0j0d
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
plan_doc
|
plan_doc.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""plan_doc — typed parser for plan-file YAML frontmatter (schema v1).
|
|
2
|
+
|
|
3
|
+
Provenance: extracted verbatim from the draft-pr skill of the m0j0d
|
|
4
|
+
portfolio plugin (``plugin/skills/draft-pr/scripts/plan_doc.py``), where it
|
|
5
|
+
is the single source of truth for extracting structured fields from
|
|
6
|
+
plan-driven-PR documents. The vendored plugin copy remains operational
|
|
7
|
+
there; this package mirrors it at release points.
|
|
8
|
+
|
|
9
|
+
Design decisions:
|
|
10
|
+
- YAML frontmatter for machine-readable fields; markdown body for human content.
|
|
11
|
+
- First-`---`-block-only parser: extracts ONLY the first ``---``...``---`` block
|
|
12
|
+
at the very top of the file. Does NOT split on body-level ``---`` horizontal
|
|
13
|
+
rules (common as horizontal rules in real-world plan bodies).
|
|
14
|
+
- When NO frontmatter block is present, raises ``PlanDocError`` with an actionable
|
|
15
|
+
message directing the author to add a ``plan_schema: 1`` frontmatter block.
|
|
16
|
+
Unmigrated files fail loud.
|
|
17
|
+
- When a frontmatter block IS present, validation is strict: missing required
|
|
18
|
+
fields and unknown fields both raise ``PlanDocError``.
|
|
19
|
+
|
|
20
|
+
Schema v1 fields:
|
|
21
|
+
plan_schema int required when block present; must equal 1
|
|
22
|
+
template str optional; values: "minimal", "standard"
|
|
23
|
+
disposal str optional; values: "delete-on-merge", "deferred"
|
|
24
|
+
phase str optional
|
|
25
|
+
cleanup_trigger str optional
|
|
26
|
+
ticket str optional
|
|
27
|
+
branch str optional
|
|
28
|
+
tdd_mode str optional
|
|
29
|
+
dossier dict optional; Thinker-Doer handoff block (schema
|
|
30
|
+
defined by the source portfolio's
|
|
31
|
+
thinker-doer-dossier-handoff pattern). Lightly
|
|
32
|
+
validated: must be a mapping; recognized sub-keys
|
|
33
|
+
(files, symbols, patterns, exclusions, not_in_scope,
|
|
34
|
+
open_questions, escape_log, hot_spans,
|
|
35
|
+
thinker_tokens_used) must have the right container
|
|
36
|
+
type when present.
|
|
37
|
+
|
|
38
|
+
Ad-hoc top-level key policy:
|
|
39
|
+
ONLY the fields listed above are allowed as top-level frontmatter keys.
|
|
40
|
+
Ad-hoc keys like ``owner``, ``created``, ``revised``, ``sequencing``,
|
|
41
|
+
``problem_one_liner``, ``status`` etc. are NOT whitelisted and raise
|
|
42
|
+
``PlanDocError``. Rationale: preserves the typo guard (``dispozal`` must
|
|
43
|
+
still raise) and the "plans don't invent top-level keys" discipline.
|
|
44
|
+
The ``dossier:`` block is the designed mechanism for planner metadata;
|
|
45
|
+
ad-hoc keys belong inside it or in the plan body.
|
|
46
|
+
|
|
47
|
+
Usage::
|
|
48
|
+
|
|
49
|
+
from plan_doc import PlanDoc, PlanDocError, NonDraftPrFrontmatterError
|
|
50
|
+
|
|
51
|
+
doc = PlanDoc.from_path(Path("docs/plans/my-plan.md"))
|
|
52
|
+
if doc.template == "minimal":
|
|
53
|
+
...
|
|
54
|
+
if doc.disposal == "delete-on-merge":
|
|
55
|
+
...
|
|
56
|
+
if doc.dossier is not None:
|
|
57
|
+
files = doc.dossier.get("files", [])
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
from dataclasses import dataclass, field
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import Optional
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
import yaml as _yaml
|
|
68
|
+
_YAML_AVAILABLE = True
|
|
69
|
+
except ImportError:
|
|
70
|
+
_YAML_AVAILABLE = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Error types
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PlanDocError(ValueError):
|
|
79
|
+
"""Raised when a plan frontmatter block is present but malformed or invalid."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class NonDraftPrFrontmatterError(PlanDocError):
|
|
83
|
+
"""Raised when frontmatter is present but belongs to a foreign (non-draft-pr)
|
|
84
|
+
document schema — i.e. it has NO ``plan_schema`` key AND contains NONE of the
|
|
85
|
+
known draft-pr indicator fields.
|
|
86
|
+
|
|
87
|
+
Callers catch this specific subclass and construct a field-less ``PlanDoc``
|
|
88
|
+
(``template=None``, ``disposal=None``, body intact) — foreign documents get
|
|
89
|
+
standard-template treatment and no disposal step. All other ``PlanDocError``
|
|
90
|
+
subclasses propagate as strict validation failures.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Schema constants
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
_SUPPORTED_SCHEMA_VERSIONS = {1}
|
|
99
|
+
_ALLOWED_DISPOSAL_VALUES = {"delete-on-merge", "deferred"}
|
|
100
|
+
_KNOWN_FIELDS = frozenset({
|
|
101
|
+
"plan_schema",
|
|
102
|
+
"template",
|
|
103
|
+
"disposal",
|
|
104
|
+
"phase",
|
|
105
|
+
"cleanup_trigger",
|
|
106
|
+
"ticket",
|
|
107
|
+
"branch",
|
|
108
|
+
"tdd_mode",
|
|
109
|
+
# Thinker-Doer handoff block (atlas/patterns/thinker-doer-dossier-handoff.md).
|
|
110
|
+
# Recognized and lightly validated; see _validate_dossier().
|
|
111
|
+
"dossier",
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
# Sub-keys of the dossier block that must be lists when present.
|
|
115
|
+
# Extra sub-keys beyond this set are allowed (lenient validation — the pattern
|
|
116
|
+
# permits keys like hot_spans, thinker_tokens_used without a schema version bump).
|
|
117
|
+
_DOSSIER_LIST_KEYS = frozenset({
|
|
118
|
+
"files",
|
|
119
|
+
"symbols",
|
|
120
|
+
"patterns",
|
|
121
|
+
"exclusions",
|
|
122
|
+
"not_in_scope",
|
|
123
|
+
"open_questions",
|
|
124
|
+
"escape_log",
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
# Fields (excluding plan_schema itself) that positively identify a draft-pr
|
|
128
|
+
# document. A frontmatter block that has NONE of these AND no plan_schema is
|
|
129
|
+
# treated as a "foreign" document (infra-099-style, loki-exec-light-style) and
|
|
130
|
+
# raises NonDraftPrFrontmatterError, which callers tolerate as a field-less PlanDoc.
|
|
131
|
+
_DRAFT_PR_INDICATOR_FIELDS = frozenset({
|
|
132
|
+
"template",
|
|
133
|
+
"disposal",
|
|
134
|
+
"phase",
|
|
135
|
+
"cleanup_trigger",
|
|
136
|
+
"ticket",
|
|
137
|
+
"branch",
|
|
138
|
+
"tdd_mode",
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# PlanDoc dataclass
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class PlanDoc:
|
|
149
|
+
"""Typed representation of a /draft-pr plan document.
|
|
150
|
+
|
|
151
|
+
Fields are populated from YAML frontmatter. When no frontmatter block is
|
|
152
|
+
present, ``from_text`` / ``from_path`` raise ``PlanDocError`` with an
|
|
153
|
+
actionable message directing the author to add ``plan_schema: 1``.
|
|
154
|
+
|
|
155
|
+
Foreign-schema docs (valid YAML, no ``plan_schema``, no draft-pr indicator
|
|
156
|
+
fields) raise ``NonDraftPrFrontmatterError`` — callers catch this specific
|
|
157
|
+
subclass and construct a field-less ``PlanDoc(template=None, disposal=None,
|
|
158
|
+
body=text)`` for standard-template treatment with no disposal step.
|
|
159
|
+
|
|
160
|
+
The raw markdown body (everything after the closing frontmatter ``---``) is
|
|
161
|
+
available as ``.body``.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
# Schema version — required when frontmatter block is present
|
|
165
|
+
plan_schema: Optional[int] = None
|
|
166
|
+
|
|
167
|
+
# Core lifecycle fields
|
|
168
|
+
template: Optional[str] = None
|
|
169
|
+
disposal: Optional[str] = None
|
|
170
|
+
|
|
171
|
+
# Optional metadata
|
|
172
|
+
phase: Optional[str] = None
|
|
173
|
+
cleanup_trigger: Optional[str] = None
|
|
174
|
+
ticket: Optional[str] = None
|
|
175
|
+
branch: Optional[str] = None
|
|
176
|
+
tdd_mode: Optional[str] = None
|
|
177
|
+
|
|
178
|
+
# Thinker-Doer handoff block (atlas/patterns/thinker-doer-dossier-handoff.md).
|
|
179
|
+
# None when not present in frontmatter; populated dict when present.
|
|
180
|
+
dossier: Optional[dict] = field(default=None, repr=False)
|
|
181
|
+
|
|
182
|
+
# Raw body text (everything after the frontmatter block, or full text for
|
|
183
|
+
# legacy plans)
|
|
184
|
+
body: str = field(default="", repr=False)
|
|
185
|
+
|
|
186
|
+
# Internal flag: True when loaded from frontmatter (strict mode),
|
|
187
|
+
# False when constructed directly (e.g. field-less PlanDoc for foreign docs)
|
|
188
|
+
_from_frontmatter: bool = field(default=False, repr=False)
|
|
189
|
+
|
|
190
|
+
# -----------------------------------------------------------------------
|
|
191
|
+
# Factory methods
|
|
192
|
+
# -----------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_path(cls, path: Path) -> PlanDoc:
|
|
196
|
+
"""Load a PlanDoc from a file on disk.
|
|
197
|
+
|
|
198
|
+
Parameters
|
|
199
|
+
----------
|
|
200
|
+
path:
|
|
201
|
+
Absolute path to the plan markdown file.
|
|
202
|
+
|
|
203
|
+
Returns
|
|
204
|
+
-------
|
|
205
|
+
PlanDoc
|
|
206
|
+
Populated from frontmatter (strict validation).
|
|
207
|
+
|
|
208
|
+
Raises
|
|
209
|
+
------
|
|
210
|
+
PlanDocError
|
|
211
|
+
When no frontmatter block is present, or when the frontmatter block
|
|
212
|
+
is malformed, has missing required fields, has invalid field values,
|
|
213
|
+
or has unknown fields.
|
|
214
|
+
FileNotFoundError
|
|
215
|
+
When the file does not exist.
|
|
216
|
+
"""
|
|
217
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
218
|
+
try:
|
|
219
|
+
return cls.from_text(text)
|
|
220
|
+
except PlanDocError as exc:
|
|
221
|
+
# Re-raise with file path context if not already present.
|
|
222
|
+
# Use type(exc)(...) to preserve the exact subclass (e.g.
|
|
223
|
+
# NonDraftPrFrontmatterError) so callers that discriminate on
|
|
224
|
+
# the subclass work identically whether they call from_path or
|
|
225
|
+
# from_text.
|
|
226
|
+
msg = str(exc)
|
|
227
|
+
if str(path) not in msg and Path(path).name not in msg:
|
|
228
|
+
raise type(exc)(f"{path}: {msg}") from exc
|
|
229
|
+
raise
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def from_text(cls, text: str) -> PlanDoc:
|
|
233
|
+
"""Load a PlanDoc from raw plan text.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
text:
|
|
238
|
+
Raw content of the plan markdown file.
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
PlanDoc
|
|
243
|
+
Populated from frontmatter (strict validation).
|
|
244
|
+
|
|
245
|
+
Raises
|
|
246
|
+
------
|
|
247
|
+
PlanDocError
|
|
248
|
+
When no frontmatter block is present at the top of the file, or
|
|
249
|
+
when the frontmatter block is present but malformed or invalid.
|
|
250
|
+
Unmigrated plans (no leading ``---`` block) raise with an actionable
|
|
251
|
+
message: add ``plan_schema: 1``; see references/frontmatter-schema.md.
|
|
252
|
+
"""
|
|
253
|
+
fm_data, body = _extract_frontmatter(text)
|
|
254
|
+
if fm_data is not None:
|
|
255
|
+
return cls._from_frontmatter_dict(fm_data, body)
|
|
256
|
+
else:
|
|
257
|
+
raise PlanDocError(
|
|
258
|
+
"No frontmatter block found at the top of this plan file. "
|
|
259
|
+
"Add a 'plan_schema: 1' frontmatter block to the top of the file. "
|
|
260
|
+
"See references/frontmatter-schema.md for the full schema."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# -----------------------------------------------------------------------
|
|
264
|
+
# Internal construction
|
|
265
|
+
# -----------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def _from_frontmatter_dict(
|
|
269
|
+
cls, data: dict, body: str
|
|
270
|
+
) -> PlanDoc:
|
|
271
|
+
"""Construct a PlanDoc from a parsed frontmatter dict (strict mode).
|
|
272
|
+
|
|
273
|
+
Raises NonDraftPrFrontmatterError when the frontmatter block looks like a
|
|
274
|
+
foreign (non-draft-pr) document: no ``plan_schema`` key AND none of the
|
|
275
|
+
known draft-pr indicator fields present. This lets callers fall back to
|
|
276
|
+
legacy regex extraction for infrastructure plans (infra-099-style,
|
|
277
|
+
loki-exec-light-style) without silently swallowing real validation errors.
|
|
278
|
+
|
|
279
|
+
Raises PlanDocError on:
|
|
280
|
+
- missing required field ``plan_schema`` (when draft-pr indicator fields
|
|
281
|
+
ARE present — this is a malformed draft-pr plan, not a foreign doc)
|
|
282
|
+
- unsupported ``plan_schema`` value
|
|
283
|
+
- invalid ``disposal`` value
|
|
284
|
+
- unknown fields (when plan_schema is present)
|
|
285
|
+
"""
|
|
286
|
+
# Foreign-document discriminator: no plan_schema AND no draft-pr indicator
|
|
287
|
+
# fields → this is not a draft-pr plan at all (e.g. infra-099, loki-exec).
|
|
288
|
+
# Raise the typed subclass so callers can catch ONLY this case.
|
|
289
|
+
has_plan_schema = "plan_schema" in data
|
|
290
|
+
has_indicator_fields = bool(set(data.keys()) & _DRAFT_PR_INDICATOR_FIELDS)
|
|
291
|
+
if not has_plan_schema and not has_indicator_fields:
|
|
292
|
+
raise NonDraftPrFrontmatterError(
|
|
293
|
+
"Frontmatter block present but contains no 'plan_schema' key and "
|
|
294
|
+
"no draft-pr indicator fields — this appears to be a non-draft-pr "
|
|
295
|
+
"document. Known draft-pr fields: "
|
|
296
|
+
f"{sorted(_DRAFT_PR_INDICATOR_FIELDS | {'plan_schema'})}"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Unknown field check (only for plans that have at least one draft-pr marker)
|
|
300
|
+
unknown = set(data.keys()) - _KNOWN_FIELDS
|
|
301
|
+
if unknown:
|
|
302
|
+
names = ", ".join(sorted(unknown))
|
|
303
|
+
raise PlanDocError(
|
|
304
|
+
f"Unknown frontmatter field(s): {names}. "
|
|
305
|
+
f"Known fields: {sorted(_KNOWN_FIELDS)}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# plan_schema is required when frontmatter block is present and we know
|
|
309
|
+
# this is a draft-pr plan (indicator fields are present)
|
|
310
|
+
if not has_plan_schema:
|
|
311
|
+
raise PlanDocError(
|
|
312
|
+
"Frontmatter block present but 'plan_schema' field is missing. "
|
|
313
|
+
"Add 'plan_schema: 1' to the frontmatter block."
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
schema_version = data["plan_schema"]
|
|
317
|
+
if schema_version not in _SUPPORTED_SCHEMA_VERSIONS:
|
|
318
|
+
raise PlanDocError(
|
|
319
|
+
f"Unsupported plan_schema value: {schema_version!r}. "
|
|
320
|
+
f"Supported versions: {sorted(_SUPPORTED_SCHEMA_VERSIONS)}"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Validate disposal value if provided
|
|
324
|
+
disposal = data.get("disposal")
|
|
325
|
+
if disposal is not None and disposal not in _ALLOWED_DISPOSAL_VALUES:
|
|
326
|
+
raise PlanDocError(
|
|
327
|
+
f"Invalid disposal value: {disposal!r}. "
|
|
328
|
+
f"Allowed values: {sorted(_ALLOWED_DISPOSAL_VALUES)}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Validate and extract dossier block if present
|
|
332
|
+
dossier = data.get("dossier")
|
|
333
|
+
if dossier is not None:
|
|
334
|
+
_validate_dossier(dossier)
|
|
335
|
+
|
|
336
|
+
return cls(
|
|
337
|
+
plan_schema=schema_version,
|
|
338
|
+
template=data.get("template"),
|
|
339
|
+
disposal=disposal,
|
|
340
|
+
phase=data.get("phase"),
|
|
341
|
+
cleanup_trigger=data.get("cleanup_trigger"),
|
|
342
|
+
ticket=data.get("ticket"),
|
|
343
|
+
branch=data.get("branch"),
|
|
344
|
+
tdd_mode=data.get("tdd_mode"),
|
|
345
|
+
dossier=dossier,
|
|
346
|
+
body=body,
|
|
347
|
+
_from_frontmatter=True,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# Frontmatter extraction (first-block-only parser)
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _extract_frontmatter(text: str) -> tuple[Optional[dict], str]:
|
|
357
|
+
"""Extract the first ``---``...``---`` YAML frontmatter block from text.
|
|
358
|
+
|
|
359
|
+
Rules:
|
|
360
|
+
- The file MUST begin with a ``---`` line (optionally with trailing
|
|
361
|
+
whitespace) for a frontmatter block to be recognized.
|
|
362
|
+
- The parser extracts ONLY the first such block. Everything after the
|
|
363
|
+
closing ``---`` line is returned verbatim as the body.
|
|
364
|
+
- Does NOT split on body-level ``---`` horizontal rules — those are part
|
|
365
|
+
of the body.
|
|
366
|
+
- Returns (None, text) when no frontmatter block is present at the top.
|
|
367
|
+
|
|
368
|
+
Parameters
|
|
369
|
+
----------
|
|
370
|
+
text:
|
|
371
|
+
Raw file content.
|
|
372
|
+
|
|
373
|
+
Returns
|
|
374
|
+
-------
|
|
375
|
+
(dict, str) when a frontmatter block is found and parsed.
|
|
376
|
+
(None, text) when no frontmatter block is present.
|
|
377
|
+
|
|
378
|
+
Raises
|
|
379
|
+
------
|
|
380
|
+
PlanDocError
|
|
381
|
+
When a frontmatter block opening ``---`` is found at the top but the
|
|
382
|
+
YAML inside is malformed or the closing ``---`` is never found.
|
|
383
|
+
"""
|
|
384
|
+
lines = text.splitlines(keepends=True)
|
|
385
|
+
if not lines:
|
|
386
|
+
return None, text
|
|
387
|
+
|
|
388
|
+
# The first line must be exactly "---" (with optional trailing whitespace)
|
|
389
|
+
first_line = lines[0].rstrip()
|
|
390
|
+
if first_line != "---":
|
|
391
|
+
return None, text
|
|
392
|
+
|
|
393
|
+
# Find the closing --- (starting from line 1, skipping the opening ---)
|
|
394
|
+
closing_idx = None
|
|
395
|
+
for i in range(1, len(lines)):
|
|
396
|
+
if lines[i].rstrip() == "---":
|
|
397
|
+
closing_idx = i
|
|
398
|
+
break
|
|
399
|
+
|
|
400
|
+
if closing_idx is None:
|
|
401
|
+
raise PlanDocError(
|
|
402
|
+
"Frontmatter block opened with '---' but no closing '---' found."
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Extract the YAML content between the two --- markers
|
|
406
|
+
fm_lines = lines[1:closing_idx]
|
|
407
|
+
fm_text = "".join(fm_lines)
|
|
408
|
+
|
|
409
|
+
# Parse the YAML
|
|
410
|
+
data = _parse_yaml(fm_text)
|
|
411
|
+
|
|
412
|
+
# Everything after the closing --- is the body
|
|
413
|
+
body_lines = lines[closing_idx + 1:]
|
|
414
|
+
body = "".join(body_lines)
|
|
415
|
+
|
|
416
|
+
return data, body
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _parse_yaml(text: str) -> dict:
|
|
420
|
+
"""Parse YAML text and return a dict.
|
|
421
|
+
|
|
422
|
+
Uses PyYAML when available; falls back to a minimal parser for simple
|
|
423
|
+
key: value pairs.
|
|
424
|
+
|
|
425
|
+
Raises
|
|
426
|
+
------
|
|
427
|
+
PlanDocError
|
|
428
|
+
When the YAML is malformed.
|
|
429
|
+
"""
|
|
430
|
+
if _YAML_AVAILABLE:
|
|
431
|
+
try:
|
|
432
|
+
result = _yaml.safe_load(text)
|
|
433
|
+
except _yaml.YAMLError as exc:
|
|
434
|
+
raise PlanDocError(f"Malformed YAML in frontmatter: {exc}") from exc
|
|
435
|
+
if result is None:
|
|
436
|
+
return {}
|
|
437
|
+
if not isinstance(result, dict):
|
|
438
|
+
raise PlanDocError(
|
|
439
|
+
f"Frontmatter YAML must be a mapping (dict), got {type(result).__name__}"
|
|
440
|
+
)
|
|
441
|
+
return result
|
|
442
|
+
else:
|
|
443
|
+
# Minimal fallback parser for simple key: value pairs
|
|
444
|
+
return _parse_yaml_minimal(text)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _parse_yaml_minimal(text: str) -> dict:
|
|
448
|
+
"""Minimal YAML parser for simple ``key: value`` pairs (no PyYAML fallback).
|
|
449
|
+
|
|
450
|
+
Handles only scalar values. Raises PlanDocError on anything complex.
|
|
451
|
+
"""
|
|
452
|
+
result = {}
|
|
453
|
+
for line_num, line in enumerate(text.splitlines(), start=1):
|
|
454
|
+
line = line.strip()
|
|
455
|
+
if not line or line.startswith("#"):
|
|
456
|
+
continue
|
|
457
|
+
if ":" not in line:
|
|
458
|
+
raise PlanDocError(
|
|
459
|
+
f"Malformed YAML in frontmatter at line {line_num}: "
|
|
460
|
+
f"expected 'key: value', got {line!r}"
|
|
461
|
+
)
|
|
462
|
+
key, _, value = line.partition(":")
|
|
463
|
+
key = key.strip()
|
|
464
|
+
value = value.strip()
|
|
465
|
+
# Try to parse simple int values
|
|
466
|
+
if value.isdigit():
|
|
467
|
+
result[key] = int(value)
|
|
468
|
+
else:
|
|
469
|
+
# Strip surrounding quotes if present
|
|
470
|
+
if (value.startswith('"') and value.endswith('"')) or \
|
|
471
|
+
(value.startswith("'") and value.endswith("'")):
|
|
472
|
+
value = value[1:-1]
|
|
473
|
+
result[key] = value
|
|
474
|
+
return result
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _validate_dossier(dossier: object) -> None:
|
|
478
|
+
"""Light structural validation of the dossier block.
|
|
479
|
+
|
|
480
|
+
Rules (lenient by design — the pattern allows extension without version bumps):
|
|
481
|
+
- ``dossier`` must be a mapping (dict), not a scalar or list.
|
|
482
|
+
- Each of the recognized list-typed sub-keys (``files``, ``symbols``,
|
|
483
|
+
``patterns``, ``exclusions``, ``not_in_scope``, ``open_questions``,
|
|
484
|
+
``escape_log``) must be a list when present.
|
|
485
|
+
- Extra sub-keys beyond the recognized set are allowed; over-constraining
|
|
486
|
+
would break plans that use valid pattern extensions.
|
|
487
|
+
|
|
488
|
+
Raises
|
|
489
|
+
------
|
|
490
|
+
PlanDocError
|
|
491
|
+
When ``dossier`` is not a mapping, or a recognized list-typed sub-key
|
|
492
|
+
has the wrong type. The message names the offending sub-key.
|
|
493
|
+
"""
|
|
494
|
+
if not isinstance(dossier, dict):
|
|
495
|
+
raise PlanDocError(
|
|
496
|
+
f"'dossier' frontmatter block must be a mapping (dict), "
|
|
497
|
+
f"got {type(dossier).__name__!r}"
|
|
498
|
+
)
|
|
499
|
+
for key in _DOSSIER_LIST_KEYS:
|
|
500
|
+
value = dossier.get(key)
|
|
501
|
+
if value is not None and not isinstance(value, list):
|
|
502
|
+
raise PlanDocError(
|
|
503
|
+
f"'dossier.{key}' must be a list, "
|
|
504
|
+
f"got {type(value).__name__!r}: {value!r}"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|