Private
Public Access
0
0

feat(aggregate): add Inherits resolution + Non-directive context to preset parser

This commit is contained in:
2026-07-05 14:14:22 -04:00
parent 26dd92581c
commit 82468611ba
2 changed files with 359 additions and 42 deletions
+151 -42
View File
@@ -1,10 +1,19 @@
#!/usr/bin/env python3
"""Aggregate directive bodies from a preset markdown file."""
"""Aggregate directive bodies from a preset markdown file.
Supports three preset sections:
## Inherits — name of parent preset (resolved recursively, 3-level max)
## Directives to warm — list of <name>: <path to v1.md>
## Non-directive context — list of <description>: <path to full markdown file>
Child directives + non-directive files override parent on name/path conflict.
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
@@ -19,36 +28,113 @@ if hasattr(sys.stderr, "reconfigure"):
pass
REPO_ROOT = Path(__file__).resolve().parent.parent
ENTRY_RE = re.compile(r"^\s*-\s+(?P<name>[A-Za-z0-9_]+)\s*:\s*(?P<path>\S+)\s*$")
ENTRY_RE = re.compile(r"^\s*-\s+(?P<name>[^:]+):\s+(?P<path>\S+)\s*$")
EXPECTED_PATTERN_TEMPLATE = r"^conductor/directives/{name}/v\d+\.md$"
MAX_INHERIT_DEPTH = 3
def parse_preset(preset_path: Path, root: Path | None = None) -> list[tuple[str, Path]]:
@dataclass(frozen=True, slots=True)
class PresetSections:
inherits: str
directives: list[tuple[str, Path]]
non_directive_context: list[tuple[str, Path]]
def parse_preset_sections(preset_path: Path) -> PresetSections:
"""Parse a preset markdown file into Inherits, Directives, Non-directive context."""
if not preset_path.exists():
raise FileNotFoundError("preset file not found: " + str(preset_path))
text = preset_path.read_text(encoding="utf-8")
inherits: str = ""
directives: list[tuple[str, Path]] = []
non_directive: list[tuple[str, Path]] = []
current_section: str = ""
for line in text.splitlines():
if line.startswith("## Inherits"):
current_section = "inherits"
continue
if line.startswith("## Directives to warm"):
current_section = "directives"
continue
if line.startswith("## Non-directive context"):
current_section = "non_directive"
continue
if line.startswith("## ") or line.startswith("# "):
current_section = ""
continue
if current_section == "inherits" and line.strip():
inherits = line.strip()
elif current_section in ("directives", "non_directive"):
m = ENTRY_RE.match(line)
if m:
name = m.group("name")
rel_path = Path(m.group("path"))
if current_section == "directives":
directives.append((name, rel_path))
else:
non_directive.append((name, rel_path))
return PresetSections(inherits=inherits, directives=directives, non_directive_context=non_directive)
def resolve_inheritance(
preset_path: Path,
root: Path | None = None,
_visited: frozenset[str] | None = None,
_depth: int = 0,
) -> PresetSections:
"""Resolve a preset, recursively flattening parent directives + non-directive files."""
if root is None:
root = REPO_ROOT
entries: list[tuple[str, Path]] = []
for line in preset_path.read_text(encoding="utf-8").splitlines():
m = ENTRY_RE.match(line)
if not m:
continue
name = m.group("name")
rel_path = m.group("path")
abs_path = (root / rel_path).resolve()
entries.append((name, abs_path))
return entries
p = preset_path if preset_path.is_absolute() else (root / preset_path).resolve()
if _visited is None:
_visited = frozenset()
key = str(p)
if key in _visited:
raise ValueError("inheritance loop detected at " + key)
if _depth > MAX_INHERIT_DEPTH:
raise ValueError("inheritance depth exceeds " + str(MAX_INHERIT_DEPTH) + " at " + key)
sections = parse_preset_sections(p)
if not sections.inherits:
return sections
parent_name = sections.inherits
parent_path = (root / "conductor" / "directives" / "presets" / (parent_name + ".md")).resolve()
if not parent_path.exists():
raise FileNotFoundError("parent preset not found: " + str(parent_path))
parent = resolve_inheritance(parent_path, root=root, _visited=_visited | {key}, _depth=_depth + 1)
merged_directives: list[tuple[str, Path]] = []
child_names: set[str] = {n for n, _ in sections.directives}
for name, path in parent.directives:
if name not in child_names:
merged_directives.append((name, path))
for name, path in sections.directives:
merged_directives.append((name, path))
merged_non_directive: list[tuple[str, Path]] = []
child_paths: set[Path] = {p for _, p in sections.non_directive_context}
for desc, path in parent.non_directive_context:
if path not in child_paths:
merged_non_directive.append((desc, path))
for desc, path in sections.non_directive_context:
merged_non_directive.append((desc, path))
return PresetSections(inherits="", directives=merged_directives, non_directive_context=merged_non_directive)
def aggregate_directives(preset_path: str | Path, *, max_chars: int = 0, project_root: str | Path | None = None) -> str:
def aggregate_directives(
preset_path: str | Path,
*,
max_chars: int = 0,
project_root: str | Path | None = None,
) -> str:
"""
Read preset at preset_path, resolve each directive's v1.md, concatenate bodies.
Read preset at preset_path, resolve inheritance, read each directive's v1.md
and each non-directive-context file, concatenate into a single text blob.
Reads ONLY v1.md (never meta.md). Returns the concatenated string.
Raises FileNotFoundError if the preset or any referenced v1.md is missing,
ValueError if the preset is empty or any v1.md fails UTF-8 decoding.
Preset format:
## Inherits <parent_name>
## Directives to warm (list of <name>: <relative path to v1.md>)
## Non-directive context (list of <description>: <relative path>)
preset_path : path to the presets markdown file (absolute or relative to project_root)
max_chars : if > 0, truncate the rendered body to this many chars and append a suffix note
project_root : override for the project root (defaults to the module's REPO_ROOT)
Directives appear first (same ===== banner format as before), then a
NON-DIRECTIVE CONTEXT section appends each non-directive file's full body.
"""
root = Path(project_root).resolve() if project_root is not None else REPO_ROOT
p = Path(preset_path)
@@ -56,43 +142,66 @@ def aggregate_directives(preset_path: str | Path, *, max_chars: int = 0, project
p = (root / p).resolve()
if not p.exists():
raise FileNotFoundError("preset file not found: " + str(p))
entries = parse_preset(p, root=root)
if not entries:
raise ValueError("no directive entries found in " + str(p))
sections = resolve_inheritance(p, root=root)
if not sections.directives and not sections.non_directive_context:
raise ValueError("no directive or non-directive entries found in " + str(p))
missing: list[tuple[str, Path]] = []
bodies: list[tuple[str, str]] = []
for name, path in entries:
if not path.exists():
missing.append((name, path))
directive_bodies: list[tuple[str, str]] = []
for name, rel in sections.directives:
abs_path = (root / rel).resolve()
if not abs_path.exists():
missing.append((name, abs_path))
continue
try:
rel = path.relative_to(root).as_posix()
rel_str = abs_path.relative_to(root).as_posix()
except ValueError:
rel = str(path)
rel_str = str(abs_path)
pattern = EXPECTED_PATTERN_TEMPLATE.format(name=re.escape(name))
if not re.match(pattern, rel):
print("WARNING: " + name + ": path " + str(path) + " does not match pattern conductor/directives/" + name + "/vN.md; including anyway", file=sys.stderr)
if not re.match(pattern, rel_str):
print("WARNING: " + name + ": path " + str(abs_path) + " does not match pattern conductor/directives/" + name + "/vN.md; including anyway", file=sys.stderr)
try:
body = path.read_text(encoding="utf-8")
body = abs_path.read_text(encoding="utf-8")
except UnicodeDecodeError as e:
raise ValueError(name + ": UTF-8 decode error in " + str(path) + ": " + str(e)) from e
bodies.append((name, body))
raise ValueError(name + ": UTF-8 decode error in " + str(abs_path) + ": " + str(e)) from e
directive_bodies.append((name, body))
if missing:
names = ", ".join(n for n, _ in missing)
raise FileNotFoundError("missing v1.md files for directives: " + names)
non_directive_bodies: list[tuple[str, str]] = []
for desc, rel in sections.non_directive_context:
abs_path = (root / rel).resolve()
if not abs_path.exists():
print("WARNING: non-directive context file not found: " + str(abs_path), file=sys.stderr)
continue
try:
body = abs_path.read_text(encoding="utf-8")
except UnicodeDecodeError as e:
raise ValueError(desc + ": UTF-8 decode error in " + str(abs_path) + ": " + str(e)) from e
non_directive_bodies.append((desc, body))
out_lines: list[str] = []
for name, body in bodies:
for name, body in directive_bodies:
out_lines.append("=" * 40)
out_lines.append(name)
out_lines.append("=" * 40)
out_lines.append("")
out_lines.append(body.rstrip(chr(10)))
out_lines.append(body.rstrip("\n"))
out_lines.append("")
out_lines.append("")
rendered = chr(10).join(out_lines)
if non_directive_bodies:
out_lines.append("=" * 40)
out_lines.append("NON-DIRECTIVE CONTEXT")
out_lines.append("=" * 40)
out_lines.append("")
for desc, body in non_directive_bodies:
out_lines.append("--- " + desc + " ---")
out_lines.append("")
out_lines.append(body.rstrip("\n"))
out_lines.append("")
out_lines.append("")
rendered = "\n".join(out_lines)
if max_chars > 0 and len(rendered) > max_chars:
truncated = rendered[:max_chars]
suffix = chr(10) + chr(10) + "[truncated: showing " + str(max_chars) + " of " + str(len(rendered)) + " chars; omit max_chars for full text]"
suffix = "\n\n[truncated: showing " + str(max_chars) + " of " + str(len(rendered)) + " chars; omit max_chars for full text]"
rendered = truncated + suffix
return rendered
@@ -105,11 +214,11 @@ def aggregate(preset_path: Path, output_path: Path | None) -> int:
return 1
if output_path:
output_path.write_text(rendered, encoding="utf-8")
print("Wrote " + str(rendered.count("=" * 40) // 2) + " directives to " + str(output_path), file=sys.stderr)
print("Wrote " + str(rendered.count("=" * 40) // 2) + " sections to " + str(output_path), file=sys.stderr)
else:
sys.stdout.write(rendered)
if not rendered.endswith(chr(10)):
sys.stdout.write(chr(10))
if not rendered.endswith("\n"):
sys.stdout.write("\n")
return 0