feat: add deterministic preview rendering
This commit is contained in:
parent
8c75f4f44d
commit
411f417670
23 changed files with 1413 additions and 142 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue