feat: add deterministic preview rendering
This commit is contained in:
parent
8c75f4f44d
commit
411f417670
23 changed files with 1413 additions and 142 deletions
61
src/docforge/config_validation.py
Normal file
61
src/docforge/config_validation.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Reusable strict validation primitives for project-owned configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .errors import DocForgeError
|
||||
|
||||
ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{1,127}")
|
||||
_SECRET_PARTS = frozenset({".git", ".ssh", ".gnupg", "secrets", "credentials"})
|
||||
|
||||
|
||||
def require_string(document: dict[str, Any], key: str, source: Path) -> str:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise DocForgeError("invalid_source", f"{source.name}: {key} must be a non-empty string")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def string_list(value: object, *, key: str, source: Path) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value):
|
||||
raise DocForgeError("invalid_source", f"{source.name}: {key} must be a string list")
|
||||
if len(value) != len(set(value)):
|
||||
raise DocForgeError("invalid_source", f"{source.name}: {key} contains duplicates")
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def confined_path(
|
||||
root: Path,
|
||||
raw: object,
|
||||
*,
|
||||
field: str,
|
||||
must_exist: bool,
|
||||
expected: str | None = None,
|
||||
) -> Path:
|
||||
if not isinstance(raw, str) or not raw:
|
||||
raise DocForgeError("invalid_config", f"{field} must be a non-empty relative path")
|
||||
relative = Path(raw)
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise DocForgeError("path_escape", f"{field} must stay inside the project root", path=raw)
|
||||
if any(part.lower() in _SECRET_PARTS for part in relative.parts):
|
||||
raise DocForgeError("secret_path", f"{field} may not reference a protected path", path=raw)
|
||||
resolved = (root / relative).resolve(strict=False)
|
||||
if not resolved.is_relative_to(root):
|
||||
raise DocForgeError("path_escape", f"{field} resolves outside the project root", path=raw)
|
||||
if must_exist and not resolved.exists():
|
||||
raise DocForgeError("missing_path", f"{field} does not exist", path=raw)
|
||||
if expected == "file" and must_exist and not resolved.is_file():
|
||||
raise DocForgeError("invalid_path", f"{field} must identify a file", path=raw)
|
||||
if expected == "directory" and must_exist and not resolved.is_dir():
|
||||
raise DocForgeError("invalid_path", f"{field} must identify a directory", path=raw)
|
||||
return resolved
|
||||
|
||||
|
||||
def positive_int(value: object, field: str, *, allow_zero: bool = False) -> int:
|
||||
minimum = 0 if allow_zero else 1
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
|
||||
raise DocForgeError("invalid_config", f"{field} must be an integer >= {minimum}")
|
||||
return value
|
||||
Loading…
Add table
Add a link
Reference in a new issue