1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

feat: add deterministic preview rendering

This commit is contained in:
Andraxion 2026-07-22 03:32:05 -04:00
parent 8c75f4f44d
commit 411f417670
23 changed files with 1413 additions and 142 deletions

View file

@ -4,7 +4,6 @@ from __future__ import annotations
import hashlib
import json
import re
import subprocess
import tomllib
from collections import Counter
@ -12,6 +11,7 @@ from dataclasses import replace
from pathlib import Path
from typing import Any
from .config_validation import ID_PATTERN, confined_path, positive_int, require_string, string_list
from .errors import DocForgeError
from .models import (
ContextProfile,
@ -22,10 +22,9 @@ from .models import (
ProjectSnapshot,
ProposalWriter,
)
from .render_config import load_render_config
_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{1,127}")
_AUTHORITIES = frozenset({"authoritative", "approved_plan", "derived", "proposal", "historical"})
_SECRET_PARTS = frozenset({".git", ".ssh", ".gnupg", "secrets", "credentials"})
_CORE_METADATA = frozenset(
{
"schema_version",
@ -49,6 +48,7 @@ _DESCRIPTOR_KEYS = frozenset(
"sources",
"derived",
"changesets",
"render",
"graph",
"limits",
"profiles",
@ -69,48 +69,6 @@ def project_root_fingerprint(root: Path) -> str:
return hashlib.sha256(str(root).encode()).hexdigest()[:16]
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 _load_descriptor(root: Path) -> ProjectDescriptor:
descriptor_path = root / ".docforge" / "project.toml"
if not descriptor_path.is_file():
@ -131,13 +89,13 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
if document.get("schema_version") != 1:
raise DocForgeError("invalid_config", "Project descriptor schema_version must be 1")
project_id = _require_string(document, "project_id", descriptor_path)
if _ID_PATTERN.fullmatch(project_id) is None:
project_id = require_string(document, "project_id", descriptor_path)
if ID_PATTERN.fullmatch(project_id) is None:
raise DocForgeError(
"invalid_config", "project_id is not a stable ID", project_id=project_id
)
title = _require_string(document, "title", descriptor_path)
adapter = _require_string(document, "adapter", descriptor_path)
title = require_string(document, "title", descriptor_path)
adapter = require_string(document, "adapter", descriptor_path)
if adapter != "generic":
raise DocForgeError("unsupported_adapter", "DFG-1 supports only the generic adapter")
@ -164,38 +122,38 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
if unknown:
raise DocForgeError("invalid_config", f"{name} has unknown fields", fields=unknown)
content_roots = tuple(
_confined_path(
confined_path(
root,
item,
field="sources.content_roots",
must_exist=True,
expected="directory",
)
for item in _string_list(
for item in string_list(
sources.get("content_roots"), key="sources.content_roots", source=descriptor_path
)
)
if len(content_roots) != len(set(content_roots)):
raise DocForgeError("invalid_config", "sources.content_roots resolve to duplicates")
authority_files = tuple(
_confined_path(
confined_path(
root,
item,
field="sources.authority_files",
must_exist=True,
expected="file",
)
for item in _string_list(
for item in string_list(
sources.get("authority_files", []),
key="sources.authority_files",
source=descriptor_path,
)
)
cache_root = _confined_path(
cache_root = confined_path(
root, derived.get("cache_root"), field="derived.cache_root", must_exist=False
)
index_path = _confined_path(root, derived.get("index"), field="derived.index", must_exist=False)
changeset_root = _confined_path(
index_path = confined_path(root, derived.get("index"), field="derived.index", must_exist=False)
changeset_root = confined_path(
root, changesets.get("root"), field="changesets.root", must_exist=False
)
if not index_path.is_relative_to(cache_root):
@ -235,16 +193,16 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
raise DocForgeError(
"invalid_config", "Changeset writer has unknown fields", fields=unknown_writer
)
writer_id = _require_string(writer, "id", descriptor_path)
if _ID_PATTERN.fullmatch(writer_id) is None or writer_id in writer_ids:
writer_id = require_string(writer, "id", descriptor_path)
if ID_PATTERN.fullmatch(writer_id) is None or writer_id in writer_ids:
raise DocForgeError(
"invalid_config", "Changeset writer ID is invalid or duplicated", id=writer_id
)
writer_ids.add(writer_id)
families = _string_list(
families = string_list(
writer.get("families"), key="changesets.writer.families", source=descriptor_path
)
operations = _string_list(
operations = string_list(
writer.get("operations"), key="changesets.writer.operations", source=descriptor_path
)
if not families:
@ -264,13 +222,13 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
)
)
allowed_relations = _string_list(
allowed_relations = string_list(
graph.get("allowed_relations"), key="graph.allowed_relations", source=descriptor_path
)
if not allowed_relations:
raise DocForgeError("invalid_config", "At least one relationship type is required")
for relation in allowed_relations:
if _ID_PATTERN.fullmatch(relation) is None:
if ID_PATTERN.fullmatch(relation) is None:
raise DocForgeError("invalid_config", "Relationship type is invalid", relation=relation)
limit_values = document.get("limits", {})
@ -282,11 +240,23 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
raise DocForgeError("invalid_config", "limits has unknown fields", fields=unknown_limits)
limits = Limits(
**{
field: _positive_int(limit_values.get(field, getattr(defaults, field)), field)
field: positive_int(limit_values.get(field, getattr(defaults, field)), field)
for field in defaults.__dataclass_fields__
}
)
render = load_render_config(
root,
document.get("render"),
descriptor_path=descriptor_path,
content_roots=content_roots,
authority_files=authority_files,
cache_root=cache_root,
index_path=index_path,
changeset_root=changeset_root,
limits=limits,
)
profile_documents = document.get("profiles", [])
if not isinstance(profile_documents, list):
raise DocForgeError("invalid_config", "profiles must be an array of tables")
@ -300,14 +270,14 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
raise DocForgeError(
"invalid_config", "Profile has unknown fields", fields=unknown_profile
)
profile_id = _require_string(profile, "id", descriptor_path)
if _ID_PATTERN.fullmatch(profile_id) is None or profile_id in profile_ids:
profile_id = require_string(profile, "id", descriptor_path)
if ID_PATTERN.fullmatch(profile_id) is None or profile_id in profile_ids:
raise DocForgeError(
"invalid_config", "Profile ID is invalid or duplicated", id=profile_id
)
profile_ids.add(profile_id)
token_budget = _positive_int(profile.get("token_budget", 8_000), "profile.token_budget")
dependency_depth = _positive_int(
token_budget = positive_int(profile.get("token_budget", 8_000), "profile.token_budget")
dependency_depth = positive_int(
profile.get("dependency_depth", 1), "profile.dependency_depth", allow_zero=True
)
if token_budget > limits.max_context_tokens:
@ -317,13 +287,13 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
profiles.append(
ContextProfile(
profile_id=profile_id,
families=_string_list(
families=string_list(
profile.get("families", []), key="profile.families", source=descriptor_path
),
statuses=_string_list(
statuses=string_list(
profile.get("statuses", []), key="profile.statuses", source=descriptor_path
),
required_nodes=_string_list(
required_nodes=string_list(
profile.get("required_nodes", []),
key="profile.required_nodes",
source=descriptor_path,
@ -347,19 +317,13 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
index_path=index_path,
changeset_root=changeset_root,
proposal_writers=tuple(sorted(proposal_writers, key=lambda writer: writer.writer_id)),
render=render,
allowed_relations=allowed_relations,
profiles=tuple(profiles),
limits=limits,
)
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
def _markdown_record(path: Path, text: str) -> tuple[dict[str, Any], str]:
lines = text.splitlines()
if not lines or lines[0] != "+++":
@ -395,27 +359,27 @@ def validated_node_from_record(
raise DocForgeError(
"invalid_source", f"{source.name}: unknown metadata", keys=sorted(unknown)
)
node_id = _require_string(record, "id", source)
if _ID_PATTERN.fullmatch(node_id) is None:
node_id = require_string(record, "id", source)
if ID_PATTERN.fullmatch(node_id) is None:
raise DocForgeError("invalid_source", f"{source.name}: node ID is invalid", id=node_id)
authority = _require_string(record, "authority", source)
authority = require_string(record, "authority", source)
if authority not in _AUTHORITIES:
raise DocForgeError(
"invalid_source", f"{source.name}: authority is invalid", authority=authority
)
tags = _string_list(record.get("tags", []), key="tags", source=source)
tags = string_list(record.get("tags", []), key="tags", source=source)
anchor = record.get("source_anchor")
if anchor is not None and (not isinstance(anchor, str) or not anchor):
raise DocForgeError("invalid_source", f"{source.name}: source_anchor must be a string")
summary = _require_string(record, "summary", source)
summary = require_string(record, "summary", source)
if not content:
raise DocForgeError("invalid_source", f"{source.name}: node content is empty", id=node_id)
node = Node(
node_id=node_id,
title=_require_string(record, "title", source),
family=_require_string(record, "family", source),
title=require_string(record, "title", source),
family=require_string(record, "family", source),
authority=authority,
status=_require_string(record, "status", source),
status=require_string(record, "status", source),
tags=tags,
summary=summary,
content=content,
@ -426,7 +390,7 @@ def validated_node_from_record(
edges = tuple(
Edge(node_id, relation, target)
for relation in relations
for target in _string_list(record.get(relation, []), key=relation, source=source)
for target in string_list(record.get(relation, []), key=relation, source=source)
)
return node, edges
@ -618,7 +582,7 @@ class Project:
digest.update(relative.encode())
digest.update(b"\0")
digest.update(hashlib.sha256(captured[path]).digest())
digest.update(b"docforge-core:0.2.0:index:1")
digest.update(b"docforge-core:0.3.0:index:1")
return ProjectSnapshot(
descriptor=self.descriptor,
nodes=ordered_nodes,