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

Complete DFG-8 adapter proposal support

This commit is contained in:
Andraxion 2026-07-22 11:50:49 -04:00
parent f1e31487c0
commit 591bf0ae7d
14 changed files with 525 additions and 75 deletions

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
@ -16,6 +17,8 @@ from .models import (
Node,
ProjectDescriptor,
ProjectSnapshot,
ProposalWriter,
RenderConfig,
)
from .project import validate_graph
@ -82,6 +85,31 @@ class AdapterLoader(Protocol):
def load_projection(self) -> AdapterProjection: ...
ProposalValidator = Callable[
[
ProjectSnapshot,
ProjectSnapshot,
tuple[Mapping[str, object], ...],
],
None,
]
@dataclass(frozen=True)
class AdapterProjectSettings:
"""Optional confined proposal and preview policy supplied by an explicit adapter."""
descriptor_path: Path | None = None
content_roots: tuple[Path, ...] = ()
authority_files: tuple[Path, ...] = ()
canonical_sources: tuple[Path, ...] = ()
changeset_root: Path | None = None
proposal_writers: tuple[ProposalWriter, ...] = ()
render: RenderConfig | None = None
limits: Limits | None = None
proposal_validator: ProposalValidator | None = None
def validate_projection(projection: AdapterProjection) -> None:
"""Validate generic invariants without interpreting adapter metadata."""
@ -165,8 +193,15 @@ def validate_projection(projection: AdapterProjection) -> None:
class AdapterProject:
"""Expose a validated adapter projection through the standard index boundary."""
def __init__(self, loader: AdapterLoader, *, cache_root: Path) -> None:
def __init__(
self,
loader: AdapterLoader,
*,
cache_root: Path,
settings: AdapterProjectSettings | None = None,
) -> None:
self.loader = loader
self.settings = settings or AdapterProjectSettings()
initial = loader.load_projection()
validate_projection(initial)
root = initial.root
@ -185,30 +220,79 @@ class AdapterProject:
initial.adapter_version,
initial.root,
)
content_roots = self._resolved_directories(
root, self.settings.content_roots, label="content root"
)
authority_files = self._resolved_files(
root, self.settings.authority_files, label="authority file"
)
canonical_sources = self._resolved_files(
root, self.settings.canonical_sources, label="canonical source"
)
if canonical_sources and any(
not any(source.is_relative_to(content_root) for content_root in content_roots)
for source in canonical_sources
):
raise DocForgeError(
"invalid_adapter",
"Adapter canonical sources must be inside a declared content root",
)
changeset_root = self._resolved_output(
root,
self.settings.changeset_root or resolved_cache / "changesets-disabled",
label="changeset root",
)
if any(
changeset_root == content_root
or changeset_root.is_relative_to(content_root)
or content_root.is_relative_to(changeset_root)
for content_root in content_roots
):
raise DocForgeError(
"invalid_adapter", "Adapter changesets must not overlap canonical content"
)
descriptor_path = self.settings.descriptor_path or (
root / ".docforge" / "shadow-adapter.toml"
)
if self.settings.proposal_writers and (
not descriptor_path.is_file()
or descriptor_path.is_symlink()
or not descriptor_path.resolve().is_relative_to(root)
):
raise DocForgeError(
"invalid_adapter",
"Proposal-enabled adapters require a confined descriptor file",
)
limits = self.settings.limits or Limits(
max_nodes=max(10_000, len(initial.nodes)),
max_context_tokens=64_000,
)
self._validate_proposal_writers(initial, self.settings.proposal_writers)
self._validate_render(root, self.settings.render, content_roots, changeset_root)
self.descriptor = ProjectDescriptor(
schema_version=1,
project_id=initial.project_id,
title=initial.title,
adapter=f"{initial.adapter_id}@{initial.adapter_version}",
root=root,
descriptor_path=root / ".docforge" / "shadow-adapter.toml",
descriptor_path=descriptor_path,
descriptor_hash=initial.identity(),
content_roots=(),
authority_files=(),
content_roots=content_roots,
authority_files=authority_files,
cache_root=resolved_cache,
index_path=resolved_cache / "index.sqlite3",
changeset_root=resolved_cache / "changesets-disabled",
proposal_writers=(),
render=None,
changeset_root=changeset_root,
proposal_writers=self.settings.proposal_writers,
render=self.settings.render,
allowed_relations=tuple(sorted({item.edge.relation for item in initial.edges})),
profiles=(),
limits=Limits(
max_nodes=max(10_000, len(initial.nodes)),
max_context_tokens=64_000,
),
limits=limits,
)
self._canonical_sources = canonical_sources
def load(self) -> ProjectSnapshot:
canonical_sources = self.canonical_source_paths()
captured = {path: path.read_bytes() for path in canonical_sources}
projection = self.loader.load_projection()
validate_projection(projection)
identity = (
@ -219,18 +303,153 @@ class AdapterProject:
)
if identity != self._identity:
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
if self.canonical_source_paths() != canonical_sources or any(
not path.is_file() or path.read_bytes() != raw for path, raw in captured.items()
):
raise DocForgeError(
"source_changed", "Adapter canonical source changed during the operation"
)
source_hash = projection.source_hash
if captured:
digest = hashlib.sha256(projection.source_hash.encode("ascii"))
for path in canonical_sources:
relative = path.relative_to(projection.root).as_posix().encode()
digest.update(len(relative).to_bytes(8, "big"))
digest.update(relative)
digest.update(hashlib.sha256(captured[path]).digest())
source_hash = digest.hexdigest()
return ProjectSnapshot(
descriptor=self.descriptor,
nodes=projection.core_nodes(),
edges=projection.core_edges(),
source_hash=projection.source_hash,
source_hash=source_hash,
revision=projection.revision,
)
def canonical_source_paths(self) -> tuple[Path, ...]:
"""Adapters validate their own source sets before producing a projection."""
return ()
return self._resolved_files(
self.descriptor.root,
self._canonical_sources,
label="canonical source",
)
def validate_proposal(
self,
base: ProjectSnapshot,
projected: ProjectSnapshot,
operations: tuple[Mapping[str, object], ...],
) -> None:
validator = self.settings.proposal_validator
if validator is None:
if operations:
raise DocForgeError(
"proposal_policy_missing",
"Adapter does not define proposal validation policy",
)
return
validator(base, projected, operations)
@staticmethod
def _resolved_directories(
root: Path, paths: tuple[Path, ...], *, label: str
) -> tuple[Path, ...]:
resolved = []
for path in paths:
if path.is_symlink():
raise DocForgeError("path_escape", f"Adapter {label} is unsafe")
current = path.resolve(strict=True)
if not current.is_dir() or current.is_symlink() or not current.is_relative_to(root):
raise DocForgeError("path_escape", f"Adapter {label} is unsafe")
resolved.append(current)
if len(resolved) != len(set(resolved)):
raise DocForgeError("invalid_adapter", f"Adapter {label}s repeat")
return tuple(sorted(resolved))
@staticmethod
def _resolved_files(root: Path, paths: tuple[Path, ...], *, label: str) -> tuple[Path, ...]:
resolved = []
for path in paths:
if path.is_symlink():
raise DocForgeError("path_escape", f"Adapter {label} is unsafe")
current = path.resolve(strict=True)
if not current.is_file() or current.is_symlink() or not current.is_relative_to(root):
raise DocForgeError("path_escape", f"Adapter {label} is unsafe")
resolved.append(current)
if len(resolved) != len(set(resolved)):
raise DocForgeError("invalid_adapter", f"Adapter {label}s repeat")
return tuple(sorted(resolved))
@staticmethod
def _resolved_output(root: Path, path: Path, *, label: str) -> Path:
if path.is_symlink():
raise DocForgeError("path_escape", f"Adapter {label} is unsafe")
current = path.resolve(strict=False)
if current == root or current.is_symlink() or not current.is_relative_to(root):
raise DocForgeError("path_escape", f"Adapter {label} is unsafe")
return current
@staticmethod
def _validate_proposal_writers(
projection: AdapterProjection, writers: tuple[ProposalWriter, ...]
) -> None:
writer_ids = [writer.writer_id for writer in writers]
families = {item.node.family for item in projection.nodes}
if len(writer_ids) != len(set(writer_ids)):
raise DocForgeError("invalid_adapter", "Adapter proposal writer IDs repeat")
for writer in writers:
if (
ID_PATTERN.fullmatch(writer.writer_id) is None
or not writer.families
or not set(writer.families).issubset(families)
or not writer.operations
or not set(writer.operations).issubset({"create", "update", "move", "delete"})
):
raise DocForgeError("invalid_adapter", "Adapter proposal writer policy is invalid")
@classmethod
def _validate_render(
cls,
root: Path,
render: RenderConfig | None,
content_roots: tuple[Path, ...],
changeset_root: Path,
) -> None:
if render is None:
return
template_roots = cls._resolved_directories(
root, (render.template_root,), label="template root"
)
template_root = template_roots[0]
preview_root = cls._resolved_output(root, render.preview_root, label="preview root")
protected = (*content_roots, changeset_root, template_root)
if any(
preview_root == path
or preview_root.is_relative_to(path)
or path.is_relative_to(preview_root)
for path in protected
):
raise DocForgeError("invalid_adapter", "Adapter preview root overlaps a protected path")
view_ids: set[str] = set()
outputs: set[Path] = set()
for view in render.views:
template = cls._resolved_files(root, (view.template_path,), label="render template")[0]
output = cls._resolved_output(root, view.output_path, label="render output")
if (
ID_PATTERN.fullmatch(view.view_id) is None
or view.view_id in view_ids
or view.renderer != "generic_html"
or not template.is_relative_to(template_root)
or output.suffix != ".html"
or output in outputs
or any(output == path or output.is_relative_to(path) for path in protected)
or output == preview_root
or output.is_relative_to(preview_root)
):
raise DocForgeError("invalid_adapter", "Adapter render view is invalid")
view_ids.add(view.view_id)
outputs.add(output)
@dataclass(frozen=True)