oc_orchestrator
Loading...
Searching...
No Matches
plan_metadata.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/plan_metadata.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by tests/test_257_dispatcher_seams.py (the provider seam) and oc_chat's own copy of the plan-257 witnesses"
6"""
7One reader for a plan file's header conventions.
8
9Purpose
10-------
11Plan 257 P2 (A4). A plan file states its branch, worktree, title and
12type in a mix of YAML frontmatter and ``**Name:**`` markdown headers,
13and the dispatch layer used to parse that in three separate places: the
14``**Branch:**`` / ``**Worktree:**`` regexes and the frontmatter path in
15``scripts/plan_manage.py``, and the ``# `` / ``**Plan Type:**`` line
16scan in ``scripts/plan_status.py``. Three copies of a parser drift.
17This module is the one place, and the two call sites now go through it.
18
19Responsibilities
20----------------
21- :func:`split_frontmatter` -- the ``---`` fence split, moved here from
22 ``scripts/plan_status.py`` so the format has one splitter in the
23 dispatch layer rather than one per consumer.
24- :class:`PlanMeta` -- the four header fields plus the parsed
25 frontmatter and, when it failed to parse, the error that caused it.
26- :class:`PlanMetadataProvider` -- the contract, so a caller can supply
27 its own reader.
28- :class:`DefaultPlanMetadataProvider` -- today's three parsers,
29 consolidated, field for field.
30
31Diagnostics
32-----------
33Domain: OC_CHAT.MAIN
34Levels:
35 L2 - lifecycle events (a file was read)
36 L3 - semantic details (which lane a field came from)
37 L4 - deep tracing (module load, per-field parse)
38
39Contracts
40---------
41- The default provider is BYTE-IDENTICAL to the parsers it replaces,
42 per field and including their disagreements. ``branch`` and
43 ``worktree`` prefer a truthy frontmatter scalar, fall back to an
44 unanchored markdown regex, and are returned stripped of whitespace
45 and backticks, possibly empty. ``title`` and ``plan_type`` come from
46 a whole-file line scan that strips neither backticks nor trailing
47 prose, and whose repeat-match precedence depends on ``plan_id``.
48 Preserving those quirks is deliberate: plan 257 P6 forbids a
49 behaviour change here, and the quirks are reported instead.
50- A malformed ``---`` fence yields empty frontmatter, as it always did.
51 Invalid YAML is NOT swallowed: it is returned in
52 :attr:`PlanMeta.frontmatter_error` so a caller that used to let it
53 escape can still let it escape, and a caller that never parsed YAML
54 at all can still ignore it.
55- Callers own their own fallbacks. This module never synthesises a
56 branch, a worktree or a title.
57
58Dependencies
59------------
60- oc_diagnostics (structured debug logging via _dbg)
61- yaml (frontmatter parse)
62"""
63
64from __future__ import annotations
65
66import re
67from collections.abc import Mapping
68from dataclasses import dataclass, field
69from pathlib import Path
70from typing import Protocol
71
72import yaml
73
74from oc_diagnostics import _dbg
75
76_dbg("OC_CHAT.MAIN", "module", "plan_metadata loaded", 4)
77
78
79# Markdown-form header parsers (the ``**Branch:**`` / ``**Worktree:**``
80# style used by legacy plans without YAML frontmatter). Moved verbatim
81# from scripts/plan_manage.py; their known defects are recorded in the
82# plan 257 report rather than fixed here.
83_BRANCH_RE = re.compile(r"\*\*Branch:\*\*\s*`?(?P<value>[^\s`]+)", re.MULTILINE)
84_WORKTREE_RE = re.compile(r"\*\*Worktree:\*\*\s*(?P<value>.+)", re.MULTILINE)
85
86_TITLE_PREFIX = "# "
87_PLAN_TYPE_PREFIX = "**Plan Type:**"
88
89
90@dataclass(frozen=True)
92 """A plan file's header fields, as its authors actually wrote them."""
93
94 #: Stripped of whitespace and backticks; ``""`` when absent.
95 branch: str = ""
96 #: Stripped of whitespace and backticks; ``""`` when absent. RAW --
97 #: never resolved, because a caller deletes what this names.
98 worktree: str = ""
99 #: The ``# `` heading text, or ``None`` when there is none.
100 title: str | None = None
101 #: The ``**Plan Type:**`` value, backticks and trailing prose intact.
102 plan_type: str | None = None
103 #: The parsed YAML frontmatter; empty when there is none.
104 frontmatter: Mapping[str, object] = field(default_factory=dict)
105 #: The YAML error, when the frontmatter did not parse.
106 frontmatter_error: Exception | None = None
107
108
109class PlanMetadataProvider(Protocol):
110 """Reads a plan file's header fields."""
111
112 def metadata(self, plan_path: Path, *, plan_id: str | None = None) -> PlanMeta:
113 """Read *plan_path* and return its header fields."""
114
115 def metadata_from_text(self, text: str, *, plan_id: str | None = None) -> PlanMeta:
116 """Return the header fields of an already-read plan file."""
117
118
119def split_frontmatter(text: str) -> tuple[str, str]:
120 """Split text into ``(yaml_frontmatter, markdown_body)``.
121
122 Moved from ``scripts/plan_status.py`` (plan 257 P2), which still
123 exposes it under its old private name for the two callers that
124 reach it that way.
125 """
126 func = "split_frontmatter"
127 lines = text.splitlines(keepends=True)
128 if not lines or lines[0].rstrip("\r\n") != "---":
129 raise ValueError("missing opening '---' delimiter")
130 end = None
131 for i in range(1, len(lines)):
132 if lines[i].rstrip("\r\n") == "---":
133 end = i
134 break
135 if end is None:
136 raise ValueError("missing closing '---' delimiter")
137 frontmatter = "".join(lines[1:end])
138 body = "".join(lines[end + 1 :]).lstrip("\n")
139 _dbg("OC_CHAT.MAIN", func, f"frontmatter {len(frontmatter)}B body {len(body)}B", 4)
140 return frontmatter, body
141
142
143def _markdown_value(text: str, pattern: re.Pattern[str]) -> str | None:
144 """First match of *pattern*, stripped of whitespace and backticks."""
145 match = pattern.search(text)
146 if not match:
147 return None
148 return match.group("value").strip().strip("`").strip()
149
150
151def _scan_headings(text: str, plan_id: str | None) -> tuple[str | None, str | None]:
152 """The ``# `` title and ``**Plan Type:**`` value, in one pass.
153
154 One pass, with one shared stop condition, because that is what the
155 pre-257 loop did and the stop condition is observable: it compares
156 the title against *plan_id*, so repeat-match precedence differs
157 depending on whether the heading happens to equal the plan id.
158 """
159 func = "_scan_headings"
160 title: str | None = None
161 plan_type: str | None = None
162 for line in text.splitlines():
163 stripped = line.strip()
164 if stripped.startswith(_TITLE_PREFIX):
165 title = stripped[len(_TITLE_PREFIX) :].strip() or None
166 if stripped.startswith(_PLAN_TYPE_PREFIX):
167 plan_type = stripped.split(_PLAN_TYPE_PREFIX, 1)[1].strip() or None
168 if title is not None and title != plan_id and plan_type is not None:
169 break
170 _dbg("OC_CHAT.MAIN", func, f"title={title!r} plan_type={plan_type!r}", 4)
171 return title, plan_type
172
173
175 """The three pre-257 parsers, consolidated without changing a verdict."""
176
177 def metadata(self, plan_path: Path, *, plan_id: str | None = None) -> PlanMeta:
178 """Read *plan_path* as UTF-8 and parse its header."""
179 func = "DefaultPlanMetadataProvider.metadata"
180 text = Path(plan_path).read_text(encoding="utf-8")
181 _dbg("OC_CHAT.MAIN", func, f"read {plan_path} ({len(text)}B)", 2)
182 return self.metadata_from_text(text, plan_id=plan_id)
183
184 def metadata_from_text(self, text: str, *, plan_id: str | None = None) -> PlanMeta:
185 """Parse *text*; reads nothing from disk."""
186 frontmatter, error = _parse_frontmatter(text)
187 branch = str(frontmatter.get("branch") or _markdown_value(text, _BRANCH_RE) or "").strip()
188 worktree = str(
189 frontmatter.get("worktree") or _markdown_value(text, _WORKTREE_RE) or ""
190 ).strip()
191 title, plan_type = _scan_headings(text, plan_id)
192 return PlanMeta(
193 branch=branch,
194 worktree=worktree,
195 title=title,
196 plan_type=plan_type,
197 frontmatter=frontmatter,
198 frontmatter_error=error,
199 )
200
201
202def _parse_frontmatter(text: str) -> tuple[dict[str, object], Exception | None]:
203 """The frontmatter mapping, and the YAML error that stopped it.
204
205 The ``ValueError`` arm is the pre-257 "no frontmatter -- markdown-form
206 plan" path. The ``YAMLError`` arm is new only in that the error is
207 returned rather than raised; ``plan_manage`` re-raises it, so the
208 caller that used to see it still does, and ``plan_status``, which
209 never parsed YAML here at all, is unaffected by a plan whose prose
210 frontmatter is not strict YAML.
211 """
212 func = "_parse_frontmatter"
213 try:
214 raw, _body = split_frontmatter(text)
215 loaded = yaml.safe_load(raw)
216 except ValueError:
217 _dbg("OC_CHAT.MAIN", func, "no frontmatter fence; markdown-form plan", 3)
218 return {}, None
219 except yaml.YAMLError as exc:
220 # VALIDATION_ERROR: a hand-authored plan header that is not strict
221 # YAML. Returned, not raised, so each caller keeps its own policy.
222 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: frontmatter: {exc}", 3)
223 return {}, exc
224 if isinstance(loaded, dict):
225 return loaded, None
226 return {}, None
227
228
229#: The provider every call site uses unless it is handed another one.
230DEFAULT_PROVIDER: PlanMetadataProvider = DefaultPlanMetadataProvider()
231
232
233def resolve_provider(provider: PlanMetadataProvider | None) -> PlanMetadataProvider:
234 """*provider*, or the module default when it is ``None``."""
235 return provider if provider is not None else DEFAULT_PROVIDER
236
237
238__all__ = [
239 "DEFAULT_PROVIDER",
240 "DefaultPlanMetadataProvider",
241 "PlanMeta",
242 "PlanMetadataProvider",
243 "resolve_provider",
244 "split_frontmatter",
245]
PlanMeta metadata_from_text(self, str text, *, str|None plan_id=None)
PlanMeta metadata(self, Path plan_path, *, str|None plan_id=None)
PlanMeta metadata_from_text(self, str text, *, str|None plan_id=None)
PlanMeta metadata(self, Path plan_path, *, str|None plan_id=None)
tuple[str|None, str|None] _scan_headings(str text, str|None plan_id)
PlanMetadataProvider resolve_provider(PlanMetadataProvider|None provider)
tuple[dict[str, object], Exception|None] _parse_frontmatter(str text)
str|None _markdown_value(str text, re.Pattern[str] pattern)
tuple[str, str] split_frontmatter(str text)