2026-07-22 04:17:05 -04:00
|
|
|
"""Reusable contracts for explicit project adapters and shadow verification."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
2026-07-28 19:44:25 -04:00
|
|
|
import inspect
|
2026-07-22 04:17:05 -04:00
|
|
|
import json
|
2026-07-22 11:50:49 -04:00
|
|
|
from collections.abc import Callable, Mapping
|
2026-07-28 19:44:25 -04:00
|
|
|
from dataclasses import dataclass, replace
|
2026-07-22 04:17:05 -04:00
|
|
|
from pathlib import Path
|
2026-07-25 19:08:39 -04:00
|
|
|
from typing import Protocol, runtime_checkable
|
|
|
|
|
|
|
|
|
|
from .adapter_validation import (
|
|
|
|
|
source_payload,
|
|
|
|
|
source_projection,
|
2026-07-27 16:01:40 -04:00
|
|
|
validate_logic_projection,
|
2026-07-25 19:08:39 -04:00
|
|
|
validate_manifest,
|
|
|
|
|
validate_projection,
|
|
|
|
|
validate_source_projection,
|
|
|
|
|
)
|
|
|
|
|
from .config_validation import ID_PATTERN
|
2026-07-22 04:17:05 -04:00
|
|
|
from .errors import DocForgeError
|
2026-07-25 19:08:39 -04:00
|
|
|
from .incremental import (
|
|
|
|
|
CachedSource,
|
|
|
|
|
ExtractionCache,
|
|
|
|
|
affected_sources,
|
|
|
|
|
load_extraction_cache,
|
|
|
|
|
write_extraction_cache,
|
|
|
|
|
)
|
2026-07-22 04:17:05 -04:00
|
|
|
from .models import (
|
|
|
|
|
Edge,
|
|
|
|
|
Limits,
|
2026-07-25 19:08:39 -04:00
|
|
|
LogicProjection,
|
2026-07-22 04:17:05 -04:00
|
|
|
Node,
|
|
|
|
|
ProjectDescriptor,
|
|
|
|
|
ProjectSnapshot,
|
2026-07-25 19:08:39 -04:00
|
|
|
ProjectState,
|
2026-07-22 11:50:49 -04:00
|
|
|
ProposalWriter,
|
|
|
|
|
RenderConfig,
|
2026-07-22 04:17:05 -04:00
|
|
|
)
|
2026-07-29 05:07:16 -04:00
|
|
|
from .telemetry import increment, stage
|
2026-07-22 04:17:05 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AdapterNode:
|
|
|
|
|
"""One core node plus deterministic adapter-owned metadata."""
|
|
|
|
|
|
|
|
|
|
node: Node
|
|
|
|
|
metadata: tuple[tuple[str, str], ...] = ()
|
|
|
|
|
|
|
|
|
|
def as_dict(self) -> dict[str, object]:
|
|
|
|
|
return {"node": self.node.as_dict(), "metadata": dict(self.metadata)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AdapterEdge:
|
|
|
|
|
"""One core edge plus deterministic adapter-owned metadata."""
|
|
|
|
|
|
|
|
|
|
edge: Edge
|
|
|
|
|
metadata: tuple[tuple[str, str], ...] = ()
|
|
|
|
|
|
|
|
|
|
def as_dict(self) -> dict[str, object]:
|
|
|
|
|
return {"edge": self.edge.as_dict(), "metadata": dict(self.metadata)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AdapterProjection:
|
|
|
|
|
"""A complete immutable graph projection supplied by one project adapter."""
|
|
|
|
|
|
|
|
|
|
project_id: str
|
|
|
|
|
title: str
|
|
|
|
|
adapter_id: str
|
|
|
|
|
adapter_version: str
|
|
|
|
|
root: Path
|
|
|
|
|
revision: str
|
|
|
|
|
source_hash: str
|
|
|
|
|
nodes: tuple[AdapterNode, ...]
|
|
|
|
|
edges: tuple[AdapterEdge, ...]
|
|
|
|
|
|
|
|
|
|
def core_nodes(self) -> tuple[Node, ...]:
|
|
|
|
|
return tuple(item.node for item in self.nodes)
|
|
|
|
|
|
|
|
|
|
def core_edges(self) -> tuple[Edge, ...]:
|
|
|
|
|
return tuple(item.edge for item in self.edges)
|
|
|
|
|
|
|
|
|
|
def identity(self) -> str:
|
|
|
|
|
payload = {
|
|
|
|
|
"project_id": self.project_id,
|
|
|
|
|
"adapter_id": self.adapter_id,
|
|
|
|
|
"adapter_version": self.adapter_version,
|
|
|
|
|
"revision": self.revision,
|
|
|
|
|
"source_hash": self.source_hash,
|
|
|
|
|
"nodes": [item.as_dict() for item in self.nodes],
|
|
|
|
|
"edges": [item.as_dict() for item in self.edges],
|
|
|
|
|
}
|
|
|
|
|
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
|
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AdapterSource:
|
|
|
|
|
"""One fingerprinted extraction unit declared by an incremental adapter."""
|
|
|
|
|
|
|
|
|
|
source_id: str
|
|
|
|
|
source_path: str
|
|
|
|
|
fingerprint: str
|
|
|
|
|
extractor_version: str
|
|
|
|
|
dependencies: tuple[str, ...] = ()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AdapterManifest:
|
|
|
|
|
"""Cheap project identity and source inventory for incremental compilation."""
|
|
|
|
|
|
|
|
|
|
project_id: str
|
|
|
|
|
title: str
|
|
|
|
|
adapter_id: str
|
|
|
|
|
adapter_version: str
|
|
|
|
|
root: Path
|
|
|
|
|
revision: str
|
|
|
|
|
source_hash: str
|
|
|
|
|
families: tuple[str, ...]
|
|
|
|
|
allowed_relations: tuple[str, ...]
|
|
|
|
|
sources: tuple[AdapterSource, ...]
|
|
|
|
|
estimated_nodes: int = 10_000
|
|
|
|
|
|
|
|
|
|
def identity(self) -> tuple[str, str, str, Path]:
|
|
|
|
|
return self.project_id, self.adapter_id, self.adapter_version, self.root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AdapterSourceProjection:
|
|
|
|
|
"""Nodes, relationships, and optional logic owned by one source unit."""
|
|
|
|
|
|
|
|
|
|
source_id: str
|
|
|
|
|
fingerprint: str
|
|
|
|
|
nodes: tuple[AdapterNode, ...]
|
|
|
|
|
edges: tuple[AdapterEdge, ...]
|
|
|
|
|
logic: tuple[LogicProjection, ...] = ()
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 16:01:40 -04:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AdapterAssembly:
|
|
|
|
|
"""One finalized graph and Logic set assembled from cached source contributions."""
|
|
|
|
|
|
|
|
|
|
projection: AdapterProjection
|
|
|
|
|
logic: tuple[LogicProjection, ...] = ()
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 14:23:29 -04:00
|
|
|
@runtime_checkable
|
|
|
|
|
class CompleteAdapterAssemblyLoader(Protocol):
|
|
|
|
|
"""Load one cache-independent complete graph and Logic equivalence oracle."""
|
|
|
|
|
|
|
|
|
|
def load_complete_assembly(self) -> AdapterAssembly: ...
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 04:17:05 -04:00
|
|
|
class AdapterLoader(Protocol):
|
|
|
|
|
"""Load one current, deterministic, project-confined adapter projection."""
|
|
|
|
|
|
|
|
|
|
def load_projection(self) -> AdapterProjection: ...
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
@runtime_checkable
|
|
|
|
|
class IncrementalAdapterLoader(AdapterLoader, Protocol):
|
|
|
|
|
"""Opt-in adapter contract for cacheable, source-scoped extraction."""
|
|
|
|
|
|
|
|
|
|
def load_manifest(self) -> AdapterManifest: ...
|
|
|
|
|
|
|
|
|
|
def extract_source(self, source: AdapterSource) -> AdapterSourceProjection: ...
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 16:01:40 -04:00
|
|
|
@runtime_checkable
|
|
|
|
|
class IncrementalAdapterAssembler(Protocol):
|
|
|
|
|
"""Optionally normalize overlapping source evidence into one final projection."""
|
|
|
|
|
|
|
|
|
|
def assemble_projection(
|
|
|
|
|
self,
|
|
|
|
|
manifest: AdapterManifest,
|
|
|
|
|
contributions: tuple[AdapterSourceProjection, ...],
|
|
|
|
|
) -> AdapterAssembly: ...
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 11:50:49 -04:00
|
|
|
ProposalValidator = Callable[
|
|
|
|
|
[
|
|
|
|
|
ProjectSnapshot,
|
|
|
|
|
ProjectSnapshot,
|
|
|
|
|
tuple[Mapping[str, object], ...],
|
|
|
|
|
],
|
|
|
|
|
None,
|
|
|
|
|
]
|
2026-07-28 19:44:25 -04:00
|
|
|
MAX_IMPLEMENTATION_DIFF_PATHS = 50
|
|
|
|
|
MAX_IMPLEMENTATION_FILES = 4_096
|
|
|
|
|
MAX_IMPLEMENTATION_BYTES = 64_000_000
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 05:07:16 -04:00
|
|
|
def _load_adapter_projection(loader: AdapterLoader) -> AdapterProjection:
|
|
|
|
|
increment("adapter_projection_loads")
|
|
|
|
|
with stage("adapter.projection"):
|
|
|
|
|
return loader.load_projection()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_adapter_source(
|
|
|
|
|
loader: IncrementalAdapterLoader,
|
|
|
|
|
source: AdapterSource,
|
|
|
|
|
) -> AdapterSourceProjection:
|
|
|
|
|
increment("adapter_source_extractions")
|
|
|
|
|
with stage("adapter.extract"):
|
|
|
|
|
return loader.extract_source(source)
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 14:23:29 -04:00
|
|
|
def _load_complete_adapter_assembly(
|
|
|
|
|
loader: CompleteAdapterAssemblyLoader,
|
|
|
|
|
) -> AdapterAssembly:
|
|
|
|
|
increment("adapter_projection_loads")
|
|
|
|
|
with stage("adapter.projection"):
|
|
|
|
|
return loader.load_complete_assembly()
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 19:44:25 -04:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AdapterImplementation:
|
|
|
|
|
"""One confined implementation boundary that must remain stable for a process."""
|
|
|
|
|
|
|
|
|
|
roots: tuple[Path, ...] = ()
|
|
|
|
|
files: tuple[Path, ...] = ()
|
|
|
|
|
suffixes: tuple[str, ...] = ()
|
2026-07-22 11:50:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
2026-07-28 19:44:25 -04:00
|
|
|
implementation: AdapterImplementation | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class _ImplementationSnapshot:
|
|
|
|
|
fingerprint: str
|
|
|
|
|
files: tuple[tuple[str, str], ...]
|
2026-07-22 11:50:49 -04:00
|
|
|
|
|
|
|
|
|
2026-07-29 14:23:29 -04:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AdapterConformanceReport:
|
|
|
|
|
"""Stable evidence that one adapter passed its complete and incremental contracts."""
|
|
|
|
|
|
|
|
|
|
schema_version: int
|
|
|
|
|
project_id: str
|
|
|
|
|
adapter_id: str
|
|
|
|
|
adapter_version: str
|
|
|
|
|
revision: str
|
|
|
|
|
source_hash: str
|
|
|
|
|
assembly_hash: str
|
|
|
|
|
node_count: int
|
|
|
|
|
edge_count: int
|
|
|
|
|
logic_projection_count: int
|
|
|
|
|
incremental: bool
|
|
|
|
|
|
|
|
|
|
def as_dict(self) -> dict[str, object]:
|
|
|
|
|
return {
|
|
|
|
|
"schema_version": self.schema_version,
|
|
|
|
|
"project_id": self.project_id,
|
|
|
|
|
"adapter_id": self.adapter_id,
|
|
|
|
|
"adapter_version": self.adapter_version,
|
|
|
|
|
"revision": self.revision,
|
|
|
|
|
"source_hash": self.source_hash,
|
|
|
|
|
"assembly_hash": self.assembly_hash,
|
|
|
|
|
"node_count": self.node_count,
|
|
|
|
|
"edge_count": self.edge_count,
|
|
|
|
|
"logic_projection_count": self.logic_projection_count,
|
|
|
|
|
"incremental": self.incremental,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 04:17:05 -04:00
|
|
|
class AdapterProject:
|
|
|
|
|
"""Expose a validated adapter projection through the standard index boundary."""
|
|
|
|
|
|
2026-07-22 11:50:49 -04:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
loader: AdapterLoader,
|
|
|
|
|
*,
|
|
|
|
|
cache_root: Path,
|
|
|
|
|
settings: AdapterProjectSettings | None = None,
|
|
|
|
|
) -> None:
|
2026-07-22 04:17:05 -04:00
|
|
|
self.loader = loader
|
2026-07-22 11:50:49 -04:00
|
|
|
self.settings = settings or AdapterProjectSettings()
|
2026-07-25 19:08:39 -04:00
|
|
|
self._incremental_loader = loader if isinstance(loader, IncrementalAdapterLoader) else None
|
|
|
|
|
initial: AdapterProjection | None = None
|
|
|
|
|
manifest: AdapterManifest | None = None
|
|
|
|
|
if self._incremental_loader is not None:
|
|
|
|
|
manifest = self._incremental_loader.load_manifest()
|
|
|
|
|
validate_manifest(manifest)
|
|
|
|
|
root = manifest.root
|
|
|
|
|
project_id = manifest.project_id
|
|
|
|
|
title = manifest.title
|
|
|
|
|
adapter_id = manifest.adapter_id
|
|
|
|
|
adapter_version = manifest.adapter_version
|
|
|
|
|
descriptor_hash = hashlib.sha256(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"project_id": manifest.project_id,
|
|
|
|
|
"adapter_id": manifest.adapter_id,
|
|
|
|
|
"adapter_version": manifest.adapter_version,
|
|
|
|
|
"families": manifest.families,
|
|
|
|
|
"allowed_relations": manifest.allowed_relations,
|
|
|
|
|
},
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
separators=(",", ":"),
|
|
|
|
|
).encode()
|
|
|
|
|
).hexdigest()
|
|
|
|
|
families = set(manifest.families)
|
|
|
|
|
allowed_relations = manifest.allowed_relations
|
|
|
|
|
estimated_nodes = manifest.estimated_nodes
|
|
|
|
|
else:
|
2026-07-29 05:07:16 -04:00
|
|
|
initial = _load_adapter_projection(loader)
|
2026-07-25 19:08:39 -04:00
|
|
|
validate_projection(initial)
|
|
|
|
|
root = initial.root
|
|
|
|
|
project_id = initial.project_id
|
|
|
|
|
title = initial.title
|
|
|
|
|
adapter_id = initial.adapter_id
|
|
|
|
|
adapter_version = initial.adapter_version
|
|
|
|
|
descriptor_hash = initial.identity()
|
|
|
|
|
families = {item.node.family for item in initial.nodes}
|
|
|
|
|
allowed_relations = tuple(sorted({item.edge.relation for item in initial.edges}))
|
|
|
|
|
estimated_nodes = len(initial.nodes)
|
2026-07-22 04:17:05 -04:00
|
|
|
resolved_cache = cache_root.resolve(strict=False)
|
|
|
|
|
if (
|
|
|
|
|
resolved_cache == root
|
|
|
|
|
or not resolved_cache.is_relative_to(root)
|
|
|
|
|
or resolved_cache.is_symlink()
|
|
|
|
|
):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"path_escape", "Adapter cache must be a confined project subdirectory"
|
|
|
|
|
)
|
|
|
|
|
self._identity = (
|
2026-07-25 19:08:39 -04:00
|
|
|
project_id,
|
|
|
|
|
adapter_id,
|
|
|
|
|
adapter_version,
|
|
|
|
|
root,
|
2026-07-22 04:17:05 -04:00
|
|
|
)
|
2026-07-28 19:44:25 -04:00
|
|
|
self._implementation = self._validate_implementation(
|
|
|
|
|
root,
|
|
|
|
|
loader,
|
|
|
|
|
self.settings.implementation,
|
|
|
|
|
descriptor_path=self.settings.descriptor_path,
|
|
|
|
|
)
|
2026-07-25 19:08:39 -04:00
|
|
|
self._cache_path = resolved_cache / "extractions.json"
|
|
|
|
|
self._last_build_report: dict[str, object] = {
|
|
|
|
|
"mode": "full",
|
|
|
|
|
"cache_hits": 0,
|
|
|
|
|
"reparsed_sources": 0,
|
|
|
|
|
"invalidated_sources": 0,
|
|
|
|
|
"deleted_sources": 0,
|
|
|
|
|
"total_sources": 0,
|
|
|
|
|
}
|
|
|
|
|
self._last_logic: tuple[LogicProjection, ...] = ()
|
2026-07-22 11:50:49 -04:00
|
|
|
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(
|
2026-07-25 19:08:39 -04:00
|
|
|
max_nodes=max(10_000, estimated_nodes),
|
2026-07-22 11:50:49 -04:00
|
|
|
max_context_tokens=64_000,
|
|
|
|
|
)
|
2026-07-25 19:08:39 -04:00
|
|
|
self._validate_proposal_writers(families, self.settings.proposal_writers)
|
2026-07-22 11:50:49 -04:00
|
|
|
self._validate_render(root, self.settings.render, content_roots, changeset_root)
|
2026-07-22 04:17:05 -04:00
|
|
|
self.descriptor = ProjectDescriptor(
|
|
|
|
|
schema_version=1,
|
2026-07-25 19:08:39 -04:00
|
|
|
project_id=project_id,
|
|
|
|
|
title=title,
|
|
|
|
|
adapter=f"{adapter_id}@{adapter_version}",
|
2026-07-22 04:17:05 -04:00
|
|
|
root=root,
|
2026-07-22 11:50:49 -04:00
|
|
|
descriptor_path=descriptor_path,
|
2026-07-25 19:08:39 -04:00
|
|
|
descriptor_hash=descriptor_hash,
|
2026-07-22 11:50:49 -04:00
|
|
|
content_roots=content_roots,
|
|
|
|
|
authority_files=authority_files,
|
2026-07-22 04:17:05 -04:00
|
|
|
cache_root=resolved_cache,
|
|
|
|
|
index_path=resolved_cache / "index.sqlite3",
|
2026-07-22 11:50:49 -04:00
|
|
|
changeset_root=changeset_root,
|
|
|
|
|
proposal_writers=self.settings.proposal_writers,
|
|
|
|
|
render=self.settings.render,
|
2026-07-25 19:08:39 -04:00
|
|
|
allowed_relations=allowed_relations,
|
2026-07-22 04:17:05 -04:00
|
|
|
profiles=(),
|
2026-07-22 11:50:49 -04:00
|
|
|
limits=limits,
|
2026-07-22 04:17:05 -04:00
|
|
|
)
|
2026-07-22 11:50:49 -04:00
|
|
|
self._canonical_sources = canonical_sources
|
2026-07-28 19:44:25 -04:00
|
|
|
self._implementation_snapshot = self._capture_implementation(initial=True)
|
2026-07-22 04:17:05 -04:00
|
|
|
|
|
|
|
|
def load(self) -> ProjectSnapshot:
|
2026-07-29 05:07:16 -04:00
|
|
|
increment("project_loads")
|
2026-07-28 19:44:25 -04:00
|
|
|
self.validate_runtime()
|
2026-07-22 11:50:49 -04:00
|
|
|
canonical_sources = self.canonical_source_paths()
|
|
|
|
|
captured = {path: path.read_bytes() for path in canonical_sources}
|
2026-07-25 19:08:39 -04:00
|
|
|
projection = (
|
|
|
|
|
self._load_incremental()
|
|
|
|
|
if self._incremental_loader is not None
|
2026-07-29 05:07:16 -04:00
|
|
|
else _load_adapter_projection(self.loader)
|
2026-07-25 19:08:39 -04:00
|
|
|
)
|
2026-07-22 04:17:05 -04:00
|
|
|
validate_projection(projection)
|
|
|
|
|
identity = (
|
|
|
|
|
projection.project_id,
|
|
|
|
|
projection.adapter_id,
|
|
|
|
|
projection.adapter_version,
|
|
|
|
|
projection.root,
|
|
|
|
|
)
|
|
|
|
|
if identity != self._identity:
|
|
|
|
|
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
|
2026-07-28 19:44:25 -04:00
|
|
|
self.validate_runtime()
|
2026-07-22 11:50:49 -04:00
|
|
|
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"
|
|
|
|
|
)
|
2026-07-25 19:08:39 -04:00
|
|
|
source_hash = self._combined_source_hash(
|
|
|
|
|
projection.source_hash,
|
|
|
|
|
projection.root,
|
|
|
|
|
canonical_sources,
|
|
|
|
|
captured,
|
|
|
|
|
)
|
2026-07-22 04:17:05 -04:00
|
|
|
return ProjectSnapshot(
|
|
|
|
|
descriptor=self.descriptor,
|
|
|
|
|
nodes=projection.core_nodes(),
|
|
|
|
|
edges=projection.core_edges(),
|
2026-07-22 11:50:49 -04:00
|
|
|
source_hash=source_hash,
|
2026-07-22 04:17:05 -04:00
|
|
|
revision=projection.revision,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
def incremental_state(self) -> ProjectState | None:
|
|
|
|
|
"""Return current source identity without reconstructing the complete projection."""
|
|
|
|
|
|
2026-07-29 05:07:16 -04:00
|
|
|
increment("source_generation_checks")
|
|
|
|
|
with stage("source.generation"):
|
|
|
|
|
return self._incremental_state()
|
|
|
|
|
|
|
|
|
|
def _incremental_state(self) -> ProjectState | None:
|
2026-07-28 19:44:25 -04:00
|
|
|
self.validate_runtime()
|
2026-07-25 19:08:39 -04:00
|
|
|
loader = self._incremental_loader
|
|
|
|
|
if loader is None:
|
|
|
|
|
return None
|
|
|
|
|
canonical_sources = self.canonical_source_paths()
|
|
|
|
|
captured = {path: path.read_bytes() for path in canonical_sources}
|
|
|
|
|
manifest = loader.load_manifest()
|
|
|
|
|
validate_manifest(manifest)
|
|
|
|
|
if manifest.identity() != self._identity:
|
|
|
|
|
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
|
2026-07-28 19:44:25 -04:00
|
|
|
self.validate_runtime()
|
2026-07-25 19:08:39 -04:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
return ProjectState(
|
|
|
|
|
source_hash=self._combined_source_hash(
|
|
|
|
|
manifest.source_hash,
|
|
|
|
|
manifest.root,
|
|
|
|
|
canonical_sources,
|
|
|
|
|
captured,
|
|
|
|
|
),
|
|
|
|
|
revision=manifest.revision,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def build_report(self) -> dict[str, object]:
|
|
|
|
|
"""Return deterministic extraction metrics from the most recent load."""
|
|
|
|
|
|
|
|
|
|
return dict(self._last_build_report)
|
|
|
|
|
|
2026-07-28 19:44:25 -04:00
|
|
|
def validate_runtime(self) -> None:
|
|
|
|
|
"""Reject use after declared adapter implementation files change."""
|
|
|
|
|
|
|
|
|
|
expected = self._implementation_snapshot
|
|
|
|
|
if expected is None:
|
|
|
|
|
return
|
|
|
|
|
current = self._capture_implementation()
|
|
|
|
|
if current is None:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"adapter_restart_required",
|
|
|
|
|
"Adapter implementation policy changed after the project server started",
|
|
|
|
|
)
|
|
|
|
|
if current == expected:
|
|
|
|
|
return
|
|
|
|
|
expected_files = dict(expected.files)
|
|
|
|
|
current_files = dict(current.files)
|
|
|
|
|
added = sorted(set(current_files) - set(expected_files))
|
|
|
|
|
deleted = sorted(set(expected_files) - set(current_files))
|
|
|
|
|
changed = sorted(
|
|
|
|
|
path
|
|
|
|
|
for path in set(expected_files) & set(current_files)
|
|
|
|
|
if expected_files[path] != current_files[path]
|
|
|
|
|
)
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"adapter_restart_required",
|
|
|
|
|
"Adapter implementation changed after the project server started",
|
|
|
|
|
started_fingerprint=expected.fingerprint,
|
|
|
|
|
current_fingerprint=current.fingerprint,
|
|
|
|
|
added_count=len(added),
|
|
|
|
|
deleted_count=len(deleted),
|
|
|
|
|
changed_count=len(changed),
|
|
|
|
|
added=added[:MAX_IMPLEMENTATION_DIFF_PATHS],
|
|
|
|
|
deleted=deleted[:MAX_IMPLEMENTATION_DIFF_PATHS],
|
|
|
|
|
changed=changed[:MAX_IMPLEMENTATION_DIFF_PATHS],
|
|
|
|
|
paths_truncated=any(
|
|
|
|
|
len(paths) > MAX_IMPLEMENTATION_DIFF_PATHS for paths in (added, deleted, changed)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
def logic_projection(self, owner_node_id: str) -> LogicProjection | None:
|
|
|
|
|
"""Load one lazily stored function-scoped logic projection."""
|
|
|
|
|
|
|
|
|
|
self.load()
|
|
|
|
|
return next(
|
|
|
|
|
(
|
|
|
|
|
projection
|
|
|
|
|
for projection in self._last_logic
|
|
|
|
|
if projection.owner_node_id == owner_node_id
|
|
|
|
|
),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 21:08:43 -04:00
|
|
|
def logic_projections(self) -> tuple[LogicProjection, ...]:
|
|
|
|
|
"""Return logic captured by the most recent validated project load."""
|
|
|
|
|
|
|
|
|
|
return self._last_logic
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
def verify_incremental_equivalence(self) -> dict[str, object]:
|
2026-07-29 14:23:29 -04:00
|
|
|
"""Prove incremental graph and Logic output matches an independent complete oracle."""
|
2026-07-25 19:08:39 -04:00
|
|
|
|
2026-07-28 19:44:25 -04:00
|
|
|
self.validate_runtime()
|
2026-07-29 14:23:29 -04:00
|
|
|
loader = self._incremental_loader
|
|
|
|
|
if loader is None:
|
2026-07-25 19:08:39 -04:00
|
|
|
raise DocForgeError(
|
|
|
|
|
"incremental_disabled", "Adapter does not implement incremental extraction"
|
|
|
|
|
)
|
2026-07-29 14:23:29 -04:00
|
|
|
incremental = self._load_incremental_assembly()
|
2026-07-29 05:07:16 -04:00
|
|
|
full = _load_adapter_projection(self.loader)
|
2026-07-25 19:08:39 -04:00
|
|
|
validate_projection(full)
|
2026-07-29 14:23:29 -04:00
|
|
|
complete = AdapterAssembly(full)
|
|
|
|
|
if isinstance(loader, CompleteAdapterAssemblyLoader):
|
|
|
|
|
complete = _load_complete_adapter_assembly(loader)
|
|
|
|
|
_validate_adapter_assembly(complete)
|
|
|
|
|
oracle_fields = _projection_equivalence_fields(complete.projection, full)
|
|
|
|
|
oracle_mismatches = [field for field, matches in oracle_fields.items() if not matches]
|
|
|
|
|
if oracle_mismatches:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"complete_oracle_mismatch",
|
|
|
|
|
"Complete adapter assembly does not match load_projection()",
|
|
|
|
|
fields=oracle_mismatches,
|
|
|
|
|
)
|
|
|
|
|
elif incremental.logic:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"complete_logic_oracle_required",
|
|
|
|
|
"Incremental Logic publication requires load_complete_assembly()",
|
|
|
|
|
)
|
|
|
|
|
fields = _assembly_equivalence_fields(incremental, complete)
|
2026-07-25 19:08:39 -04:00
|
|
|
mismatches = [field for field, matches in fields.items() if not matches]
|
|
|
|
|
if mismatches:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"incremental_mismatch",
|
2026-07-29 14:23:29 -04:00
|
|
|
"Incremental extraction does not match a complete adapter assembly",
|
2026-07-25 19:08:39 -04:00
|
|
|
fields=mismatches,
|
|
|
|
|
)
|
2026-07-28 19:44:25 -04:00
|
|
|
self.validate_runtime()
|
2026-07-25 19:08:39 -04:00
|
|
|
return {
|
|
|
|
|
"status": "ok",
|
2026-07-29 14:23:29 -04:00
|
|
|
"project_id": incremental.projection.project_id,
|
|
|
|
|
"revision": incremental.projection.revision,
|
|
|
|
|
"source_hash": incremental.projection.source_hash,
|
|
|
|
|
"node_count": len(incremental.projection.nodes),
|
|
|
|
|
"edge_count": len(incremental.projection.edges),
|
|
|
|
|
"logic_projection_count": len(incremental.logic),
|
2026-07-25 19:08:39 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _load_incremental(self) -> AdapterProjection:
|
2026-07-29 14:23:29 -04:00
|
|
|
return self._load_incremental_assembly().projection
|
|
|
|
|
|
|
|
|
|
def _load_incremental_assembly(self) -> AdapterAssembly:
|
2026-07-25 19:08:39 -04:00
|
|
|
loader = self._incremental_loader
|
|
|
|
|
if loader is None:
|
|
|
|
|
raise DocForgeError("incremental_disabled", "Incremental adapter is not configured")
|
|
|
|
|
manifest = loader.load_manifest()
|
|
|
|
|
validate_manifest(manifest)
|
|
|
|
|
if manifest.identity() != self._identity:
|
|
|
|
|
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
|
|
|
|
|
cache = load_extraction_cache(
|
|
|
|
|
self._cache_path,
|
|
|
|
|
project_id=manifest.project_id,
|
|
|
|
|
adapter_id=manifest.adapter_id,
|
|
|
|
|
adapter_version=manifest.adapter_version,
|
|
|
|
|
)
|
|
|
|
|
cached = {source.source_id: source for source in cache.sources} if cache else {}
|
|
|
|
|
current = {source.source_id: source for source in manifest.sources}
|
|
|
|
|
changed = {
|
|
|
|
|
source_id
|
|
|
|
|
for source_id, source in current.items()
|
|
|
|
|
if source_id not in cached
|
|
|
|
|
or cached[source_id].source_path != source.source_path
|
|
|
|
|
or cached[source_id].fingerprint != source.fingerprint
|
|
|
|
|
or cached[source_id].extractor_version != source.extractor_version
|
|
|
|
|
or cached[source_id].dependencies != source.dependencies
|
|
|
|
|
}
|
|
|
|
|
deleted = set(cached) - set(current)
|
|
|
|
|
invalidated = affected_sources(
|
|
|
|
|
current_dependencies={
|
|
|
|
|
source.source_id: source.dependencies for source in manifest.sources
|
|
|
|
|
},
|
|
|
|
|
cached_dependencies={
|
|
|
|
|
source.source_id: source.dependencies for source in cached.values()
|
|
|
|
|
},
|
|
|
|
|
changed=changed | deleted,
|
|
|
|
|
)
|
|
|
|
|
contributions: list[AdapterSourceProjection] = []
|
|
|
|
|
cache_records: list[CachedSource] = []
|
|
|
|
|
reparsed: list[str] = []
|
|
|
|
|
hits: list[str] = []
|
|
|
|
|
for source in manifest.sources:
|
|
|
|
|
if source.source_id in invalidated:
|
2026-07-29 05:07:16 -04:00
|
|
|
contribution = _extract_adapter_source(loader, source)
|
2026-07-25 19:08:39 -04:00
|
|
|
reparsed.append(source.source_id)
|
2026-07-25 20:00:21 -04:00
|
|
|
cache_record = CachedSource(
|
2026-07-25 19:08:39 -04:00
|
|
|
source_id=source.source_id,
|
|
|
|
|
source_path=source.source_path,
|
|
|
|
|
fingerprint=source.fingerprint,
|
|
|
|
|
extractor_version=source.extractor_version,
|
|
|
|
|
dependencies=source.dependencies,
|
|
|
|
|
payload=source_payload(contribution),
|
|
|
|
|
)
|
2026-07-25 20:00:21 -04:00
|
|
|
else:
|
|
|
|
|
cache_record = cached[source.source_id]
|
|
|
|
|
contribution = source_projection(cache_record.payload)
|
|
|
|
|
hits.append(source.source_id)
|
|
|
|
|
validate_source_projection(source, contribution)
|
|
|
|
|
contributions.append(contribution)
|
|
|
|
|
cache_records.append(cache_record)
|
2026-07-27 16:01:40 -04:00
|
|
|
if isinstance(loader, IncrementalAdapterAssembler):
|
|
|
|
|
assembly = loader.assemble_projection(manifest, tuple(contributions))
|
|
|
|
|
projection = assembly.projection
|
|
|
|
|
logic_projections = tuple(
|
2026-07-25 19:08:39 -04:00
|
|
|
sorted(
|
2026-07-27 16:01:40 -04:00
|
|
|
assembly.logic,
|
|
|
|
|
key=lambda projection: projection.owner_node_id,
|
2026-07-25 19:08:39 -04:00
|
|
|
)
|
2026-07-27 16:01:40 -04:00
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
projection = AdapterProjection(
|
|
|
|
|
project_id=manifest.project_id,
|
|
|
|
|
title=manifest.title,
|
|
|
|
|
adapter_id=manifest.adapter_id,
|
|
|
|
|
adapter_version=manifest.adapter_version,
|
|
|
|
|
root=manifest.root,
|
|
|
|
|
revision=manifest.revision,
|
|
|
|
|
source_hash=manifest.source_hash,
|
|
|
|
|
nodes=tuple(
|
|
|
|
|
sorted(
|
|
|
|
|
(node for contribution in contributions for node in contribution.nodes),
|
|
|
|
|
key=lambda item: item.node.node_id,
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
edges=tuple(
|
|
|
|
|
sorted(
|
|
|
|
|
(edge for contribution in contributions for edge in contribution.edges),
|
|
|
|
|
key=lambda item: (
|
|
|
|
|
item.edge.source_id,
|
|
|
|
|
item.edge.relation,
|
|
|
|
|
item.edge.target_id,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
logic_projections = tuple(
|
2026-07-25 19:08:39 -04:00
|
|
|
sorted(
|
2026-07-27 16:01:40 -04:00
|
|
|
(logic for contribution in contributions for logic in contribution.logic),
|
|
|
|
|
key=lambda projection: projection.owner_node_id,
|
2026-07-25 19:08:39 -04:00
|
|
|
)
|
|
|
|
|
)
|
2026-07-29 14:23:29 -04:00
|
|
|
assembly = AdapterAssembly(projection=projection, logic=logic_projections)
|
2026-07-27 16:01:40 -04:00
|
|
|
identity = (
|
|
|
|
|
projection.project_id,
|
|
|
|
|
projection.adapter_id,
|
|
|
|
|
projection.adapter_version,
|
|
|
|
|
projection.root,
|
2026-07-25 19:08:39 -04:00
|
|
|
)
|
2026-07-27 16:01:40 -04:00
|
|
|
if (
|
|
|
|
|
identity != manifest.identity()
|
|
|
|
|
or projection.title != manifest.title
|
|
|
|
|
or projection.revision != manifest.revision
|
|
|
|
|
or projection.source_hash != manifest.source_hash
|
|
|
|
|
):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_adapter",
|
|
|
|
|
"Incremental assembly changed the manifest-bound project identity",
|
|
|
|
|
)
|
2026-07-29 14:23:29 -04:00
|
|
|
_validate_adapter_assembly(assembly)
|
2026-07-25 19:08:39 -04:00
|
|
|
stable = loader.load_manifest()
|
|
|
|
|
validate_manifest(stable)
|
|
|
|
|
if stable != manifest:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_changed", "Adapter sources changed during incremental extraction"
|
|
|
|
|
)
|
2026-07-25 20:00:21 -04:00
|
|
|
if cache is None or invalidated or deleted:
|
|
|
|
|
write_extraction_cache(
|
|
|
|
|
self._cache_path,
|
|
|
|
|
ExtractionCache(
|
|
|
|
|
project_id=manifest.project_id,
|
|
|
|
|
adapter_id=manifest.adapter_id,
|
|
|
|
|
adapter_version=manifest.adapter_version,
|
|
|
|
|
sources=tuple(cache_records),
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-07-25 19:08:39 -04:00
|
|
|
self._last_logic = logic_projections
|
|
|
|
|
self._last_build_report = {
|
|
|
|
|
"mode": "incremental",
|
|
|
|
|
"cache_hits": len(hits),
|
|
|
|
|
"reparsed_sources": len(reparsed),
|
|
|
|
|
"invalidated_sources": len(invalidated & set(current)),
|
|
|
|
|
"deleted_sources": len(deleted),
|
|
|
|
|
"total_sources": len(current),
|
|
|
|
|
"cache_hit_ids": hits,
|
|
|
|
|
"reparsed_source_ids": reparsed,
|
|
|
|
|
}
|
2026-07-29 14:23:29 -04:00
|
|
|
return assembly
|
2026-07-25 19:08:39 -04:00
|
|
|
|
2026-07-22 05:59:20 -04:00
|
|
|
def canonical_source_paths(self) -> tuple[Path, ...]:
|
|
|
|
|
"""Adapters validate their own source sets before producing a projection."""
|
|
|
|
|
|
2026-07-22 11:50:49 -04:00
|
|
|
return self._resolved_files(
|
|
|
|
|
self.descriptor.root,
|
|
|
|
|
self._canonical_sources,
|
|
|
|
|
label="canonical source",
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 19:08:39 -04:00
|
|
|
@staticmethod
|
|
|
|
|
def _combined_source_hash(
|
|
|
|
|
projection_hash: str,
|
|
|
|
|
root: Path,
|
|
|
|
|
canonical_sources: tuple[Path, ...],
|
|
|
|
|
captured: dict[Path, bytes],
|
|
|
|
|
) -> str:
|
|
|
|
|
if not captured:
|
|
|
|
|
return projection_hash
|
|
|
|
|
digest = hashlib.sha256(projection_hash.encode("ascii"))
|
|
|
|
|
for path in canonical_sources:
|
|
|
|
|
relative = path.relative_to(root).as_posix().encode()
|
|
|
|
|
digest.update(len(relative).to_bytes(8, "big"))
|
|
|
|
|
digest.update(relative)
|
|
|
|
|
digest.update(hashlib.sha256(captured[path]).digest())
|
|
|
|
|
return digest.hexdigest()
|
|
|
|
|
|
2026-07-22 11:50:49 -04:00
|
|
|
def validate_proposal(
|
|
|
|
|
self,
|
|
|
|
|
base: ProjectSnapshot,
|
|
|
|
|
projected: ProjectSnapshot,
|
|
|
|
|
operations: tuple[Mapping[str, object], ...],
|
|
|
|
|
) -> None:
|
2026-07-28 19:44:25 -04:00
|
|
|
self.validate_runtime()
|
2026-07-22 11:50:49 -04:00
|
|
|
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)
|
2026-07-28 19:44:25 -04:00
|
|
|
self.validate_runtime()
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def _validate_implementation(
|
|
|
|
|
cls,
|
|
|
|
|
root: Path,
|
|
|
|
|
loader: AdapterLoader,
|
|
|
|
|
implementation: AdapterImplementation | None,
|
|
|
|
|
*,
|
|
|
|
|
descriptor_path: Path | None,
|
|
|
|
|
) -> AdapterImplementation | None:
|
|
|
|
|
if implementation is None:
|
|
|
|
|
implementation = cls._infer_implementation(root, loader)
|
|
|
|
|
if descriptor_path is not None and (
|
|
|
|
|
implementation is None
|
|
|
|
|
or descriptor_path.resolve(strict=False)
|
|
|
|
|
not in {path.resolve(strict=False) for path in implementation.files}
|
|
|
|
|
):
|
|
|
|
|
implementation = (
|
|
|
|
|
AdapterImplementation(files=(descriptor_path,))
|
|
|
|
|
if implementation is None
|
|
|
|
|
else replace(
|
|
|
|
|
implementation,
|
|
|
|
|
files=(*implementation.files, descriptor_path),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if implementation is None:
|
|
|
|
|
return None
|
|
|
|
|
roots = cls._resolved_directories(
|
|
|
|
|
root,
|
|
|
|
|
implementation.roots,
|
|
|
|
|
label="implementation root",
|
|
|
|
|
)
|
|
|
|
|
files = cls._resolved_files(
|
|
|
|
|
root,
|
|
|
|
|
implementation.files,
|
|
|
|
|
label="implementation file",
|
|
|
|
|
)
|
|
|
|
|
suffixes = tuple(sorted(implementation.suffixes))
|
|
|
|
|
if (
|
|
|
|
|
not roots
|
|
|
|
|
and not files
|
|
|
|
|
or len(suffixes) != len(set(suffixes))
|
|
|
|
|
or any(
|
|
|
|
|
not suffix or not suffix.startswith(".") or "/" in suffix or "\\" in suffix
|
|
|
|
|
for suffix in suffixes
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_adapter",
|
|
|
|
|
"Adapter implementation policy is invalid",
|
|
|
|
|
)
|
|
|
|
|
return AdapterImplementation(roots=roots, files=files, suffixes=suffixes)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _infer_implementation(
|
|
|
|
|
root: Path,
|
|
|
|
|
loader: AdapterLoader,
|
|
|
|
|
) -> AdapterImplementation | None:
|
|
|
|
|
source = inspect.getsourcefile(type(loader))
|
|
|
|
|
if source is None:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
source_path = Path(source).resolve(strict=True)
|
|
|
|
|
except OSError:
|
|
|
|
|
return None
|
|
|
|
|
if not source_path.is_file() or not source_path.is_relative_to(root):
|
|
|
|
|
return None
|
|
|
|
|
module = inspect.getmodule(type(loader))
|
|
|
|
|
package = module.__package__.strip() if module and module.__package__ else ""
|
|
|
|
|
if package:
|
|
|
|
|
package_root = source_path.parent
|
|
|
|
|
for _ in package.split(".")[1:]:
|
|
|
|
|
package_root = package_root.parent
|
|
|
|
|
if package_root != root and (package_root / "__init__.py").is_file():
|
|
|
|
|
return AdapterImplementation(roots=(package_root,), suffixes=(".py",))
|
|
|
|
|
return AdapterImplementation(files=(source_path,))
|
|
|
|
|
|
|
|
|
|
def _capture_implementation(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
initial: bool = False,
|
|
|
|
|
) -> _ImplementationSnapshot | None:
|
|
|
|
|
implementation = self._implementation
|
|
|
|
|
if implementation is None:
|
|
|
|
|
return None
|
|
|
|
|
root = self.descriptor.root
|
|
|
|
|
candidates = set(implementation.files)
|
|
|
|
|
if len(candidates) > MAX_IMPLEMENTATION_FILES:
|
|
|
|
|
self._raise_implementation_boundary_error(
|
|
|
|
|
initial,
|
|
|
|
|
"Adapter implementation boundary exceeds its file limit",
|
|
|
|
|
max_files=MAX_IMPLEMENTATION_FILES,
|
|
|
|
|
)
|
|
|
|
|
for implementation_root in implementation.roots:
|
|
|
|
|
if (
|
|
|
|
|
implementation_root.is_symlink()
|
|
|
|
|
or not implementation_root.is_dir()
|
|
|
|
|
or not implementation_root.resolve(strict=False).is_relative_to(root)
|
|
|
|
|
):
|
|
|
|
|
candidates.add(implementation_root)
|
|
|
|
|
continue
|
|
|
|
|
for path in implementation_root.rglob("*"):
|
|
|
|
|
if path.is_symlink() or (
|
|
|
|
|
(not implementation.suffixes or path.suffix in implementation.suffixes)
|
|
|
|
|
and path.is_file()
|
|
|
|
|
):
|
|
|
|
|
candidates.add(path)
|
|
|
|
|
if len(candidates) > MAX_IMPLEMENTATION_FILES:
|
|
|
|
|
self._raise_implementation_boundary_error(
|
|
|
|
|
initial,
|
|
|
|
|
"Adapter implementation boundary exceeds its file limit",
|
|
|
|
|
max_files=MAX_IMPLEMENTATION_FILES,
|
|
|
|
|
)
|
|
|
|
|
captured: list[tuple[str, str]] = []
|
|
|
|
|
unsafe: list[str] = []
|
|
|
|
|
total_bytes = 0
|
|
|
|
|
for path in sorted(candidates):
|
|
|
|
|
try:
|
|
|
|
|
relative = path.relative_to(root).as_posix()
|
|
|
|
|
if (
|
|
|
|
|
path.is_symlink()
|
|
|
|
|
or not path.is_file()
|
|
|
|
|
or not path.resolve(strict=True).is_relative_to(root)
|
|
|
|
|
):
|
|
|
|
|
unsafe.append(relative)
|
|
|
|
|
continue
|
|
|
|
|
size = path.stat().st_size
|
|
|
|
|
if size > MAX_IMPLEMENTATION_BYTES - total_bytes:
|
|
|
|
|
self._raise_implementation_boundary_error(
|
|
|
|
|
initial,
|
|
|
|
|
"Adapter implementation boundary exceeds its byte limit",
|
|
|
|
|
max_bytes=MAX_IMPLEMENTATION_BYTES,
|
|
|
|
|
)
|
|
|
|
|
raw = path.read_bytes()
|
|
|
|
|
total_bytes += len(raw)
|
|
|
|
|
if total_bytes > MAX_IMPLEMENTATION_BYTES:
|
|
|
|
|
self._raise_implementation_boundary_error(
|
|
|
|
|
initial,
|
|
|
|
|
"Adapter implementation boundary exceeds its byte limit",
|
|
|
|
|
max_bytes=MAX_IMPLEMENTATION_BYTES,
|
|
|
|
|
)
|
|
|
|
|
captured.append((relative, hashlib.sha256(raw).hexdigest()))
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
try:
|
|
|
|
|
unsafe.append(path.relative_to(root).as_posix())
|
|
|
|
|
except ValueError:
|
|
|
|
|
unsafe.append(str(path))
|
|
|
|
|
if unsafe:
|
|
|
|
|
self._raise_implementation_boundary_error(
|
|
|
|
|
initial,
|
|
|
|
|
"Adapter implementation boundary became missing or unsafe",
|
|
|
|
|
unsafe=sorted(unsafe),
|
|
|
|
|
)
|
|
|
|
|
digest = hashlib.sha256()
|
|
|
|
|
for relative, content_hash in captured:
|
|
|
|
|
encoded = relative.encode()
|
|
|
|
|
digest.update(len(encoded).to_bytes(8, "big"))
|
|
|
|
|
digest.update(encoded)
|
|
|
|
|
digest.update(bytes.fromhex(content_hash))
|
|
|
|
|
return _ImplementationSnapshot(digest.hexdigest(), tuple(captured))
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _raise_implementation_boundary_error(
|
|
|
|
|
initial: bool,
|
|
|
|
|
message: str,
|
|
|
|
|
**details: object,
|
|
|
|
|
) -> None:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_adapter" if initial else "adapter_restart_required",
|
|
|
|
|
message,
|
|
|
|
|
**details,
|
|
|
|
|
)
|
2026-07-22 11:50:49 -04:00
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _resolved_directories(
|
|
|
|
|
root: Path, paths: tuple[Path, ...], *, label: str
|
|
|
|
|
) -> tuple[Path, ...]:
|
2026-07-24 22:26:01 -04:00
|
|
|
resolved: list[Path] = []
|
2026-07-22 11:50:49 -04:00
|
|
|
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, ...]:
|
2026-07-24 22:26:01 -04:00
|
|
|
resolved: list[Path] = []
|
2026-07-22 11:50:49 -04:00
|
|
|
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
|
2026-07-25 19:08:39 -04:00
|
|
|
def _validate_proposal_writers(families: set[str], writers: tuple[ProposalWriter, ...]) -> None:
|
2026-07-22 11:50:49 -04:00
|
|
|
writer_ids = [writer.writer_id for writer in writers]
|
|
|
|
|
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)
|
2026-07-22 05:59:20 -04:00
|
|
|
|
2026-07-22 04:17:05 -04:00
|
|
|
|
2026-07-29 14:23:29 -04:00
|
|
|
def _validate_adapter_assembly(assembly: AdapterAssembly) -> None:
|
|
|
|
|
validate_projection(assembly.projection)
|
|
|
|
|
ordered_logic = tuple(sorted(assembly.logic, key=lambda projection: projection.owner_node_id))
|
|
|
|
|
if assembly.logic != ordered_logic:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_adapter",
|
|
|
|
|
"Complete Logic projections must be deterministically ordered",
|
|
|
|
|
)
|
|
|
|
|
owners = [projection.owner_node_id for projection in assembly.logic]
|
|
|
|
|
if len(owners) != len(set(owners)):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_adapter", "A primary graph node may own only one logic projection"
|
|
|
|
|
)
|
|
|
|
|
node_ids = {item.node.node_id for item in assembly.projection.nodes}
|
|
|
|
|
for logic_projection in assembly.logic:
|
|
|
|
|
validate_logic_projection(logic_projection)
|
|
|
|
|
if logic_projection.owner_node_id not in node_ids:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_adapter",
|
|
|
|
|
"A Logic projection owner must exist in the assembled primary graph",
|
|
|
|
|
owner_node_id=logic_projection.owner_node_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _projection_equivalence_fields(
|
|
|
|
|
candidate: AdapterProjection,
|
|
|
|
|
reference: AdapterProjection,
|
|
|
|
|
) -> dict[str, bool]:
|
|
|
|
|
return {
|
|
|
|
|
"project_id": candidate.project_id == reference.project_id,
|
|
|
|
|
"title": candidate.title == reference.title,
|
|
|
|
|
"adapter_id": candidate.adapter_id == reference.adapter_id,
|
|
|
|
|
"adapter_version": candidate.adapter_version == reference.adapter_version,
|
|
|
|
|
"root": candidate.root == reference.root,
|
|
|
|
|
"revision": candidate.revision == reference.revision,
|
|
|
|
|
"source_hash": candidate.source_hash == reference.source_hash,
|
|
|
|
|
"nodes": candidate.nodes == reference.nodes,
|
|
|
|
|
"edges": candidate.edges == reference.edges,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assembly_equivalence_fields(
|
|
|
|
|
candidate: AdapterAssembly,
|
|
|
|
|
reference: AdapterAssembly,
|
|
|
|
|
) -> dict[str, bool]:
|
|
|
|
|
return {
|
|
|
|
|
**_projection_equivalence_fields(candidate.projection, reference.projection),
|
|
|
|
|
"logic": candidate.logic == reference.logic,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assembly_hash(assembly: AdapterAssembly) -> str:
|
|
|
|
|
projection = assembly.projection
|
|
|
|
|
payload = {
|
|
|
|
|
"projection": {
|
|
|
|
|
"project_id": projection.project_id,
|
|
|
|
|
"title": projection.title,
|
|
|
|
|
"adapter_id": projection.adapter_id,
|
|
|
|
|
"adapter_version": projection.adapter_version,
|
|
|
|
|
"root": str(projection.root),
|
|
|
|
|
"revision": projection.revision,
|
|
|
|
|
"source_hash": projection.source_hash,
|
|
|
|
|
"nodes": [item.as_dict() for item in projection.nodes],
|
|
|
|
|
"edges": [item.as_dict() for item in projection.edges],
|
|
|
|
|
},
|
|
|
|
|
"logic": [logic_projection.as_dict() for logic_projection in assembly.logic],
|
|
|
|
|
}
|
|
|
|
|
return hashlib.sha256(
|
|
|
|
|
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
|
|
|
).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _complete_reference_assembly(loader: AdapterLoader) -> AdapterAssembly:
|
|
|
|
|
if isinstance(loader, CompleteAdapterAssemblyLoader):
|
|
|
|
|
assembly = _load_complete_adapter_assembly(loader)
|
|
|
|
|
else:
|
|
|
|
|
assembly = AdapterAssembly(_load_adapter_projection(loader))
|
|
|
|
|
_validate_adapter_assembly(assembly)
|
|
|
|
|
return assembly
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_adapter_conformance(
|
|
|
|
|
loader: AdapterLoader,
|
|
|
|
|
*,
|
|
|
|
|
cache_root: Path,
|
|
|
|
|
settings: AdapterProjectSettings | None = None,
|
|
|
|
|
) -> AdapterConformanceReport:
|
|
|
|
|
"""Prove deterministic complete output and, when supported, incremental parity."""
|
|
|
|
|
|
|
|
|
|
project = AdapterProject(loader, cache_root=cache_root, settings=settings)
|
|
|
|
|
project.validate_runtime()
|
|
|
|
|
first = _complete_reference_assembly(loader)
|
|
|
|
|
second = _complete_reference_assembly(loader)
|
|
|
|
|
deterministic_fields = _assembly_equivalence_fields(first, second)
|
|
|
|
|
nondeterministic = [field for field, matches in deterministic_fields.items() if not matches]
|
|
|
|
|
if nondeterministic:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"nondeterministic_adapter",
|
|
|
|
|
"Repeated complete adapter assemblies do not match",
|
|
|
|
|
fields=nondeterministic,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if isinstance(loader, CompleteAdapterAssemblyLoader):
|
|
|
|
|
graph_oracle = _load_adapter_projection(loader)
|
|
|
|
|
validate_projection(graph_oracle)
|
|
|
|
|
oracle_fields = _projection_equivalence_fields(first.projection, graph_oracle)
|
|
|
|
|
oracle_mismatches = [field for field, matches in oracle_fields.items() if not matches]
|
|
|
|
|
if oracle_mismatches:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"complete_oracle_mismatch",
|
|
|
|
|
"Complete adapter assembly does not match load_projection()",
|
|
|
|
|
fields=oracle_mismatches,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
incremental = isinstance(loader, IncrementalAdapterLoader)
|
|
|
|
|
if incremental:
|
|
|
|
|
project.verify_incremental_equivalence()
|
|
|
|
|
|
|
|
|
|
project.validate_runtime()
|
|
|
|
|
projection = first.projection
|
|
|
|
|
return AdapterConformanceReport(
|
|
|
|
|
schema_version=1,
|
|
|
|
|
project_id=projection.project_id,
|
|
|
|
|
adapter_id=projection.adapter_id,
|
|
|
|
|
adapter_version=projection.adapter_version,
|
|
|
|
|
revision=projection.revision,
|
|
|
|
|
source_hash=projection.source_hash,
|
|
|
|
|
assembly_hash=_assembly_hash(first),
|
|
|
|
|
node_count=len(projection.nodes),
|
|
|
|
|
edge_count=len(projection.edges),
|
|
|
|
|
logic_projection_count=len(first.logic),
|
|
|
|
|
incremental=incremental,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 04:17:05 -04:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ShadowArtifact:
|
|
|
|
|
"""One named deterministic byte artifact used by a shadow comparison."""
|
|
|
|
|
|
|
|
|
|
artifact_id: str
|
|
|
|
|
content: bytes
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def sha256(self) -> str:
|
|
|
|
|
return hashlib.sha256(self.content).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compare_artifacts(
|
|
|
|
|
reference: tuple[ShadowArtifact, ...], candidate: tuple[ShadowArtifact, ...]
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
"""Compare complete artifact sets without writing either side."""
|
|
|
|
|
|
|
|
|
|
reference_by_id = {item.artifact_id: item for item in reference}
|
|
|
|
|
candidate_by_id = {item.artifact_id: item for item in candidate}
|
|
|
|
|
if len(reference_by_id) != len(reference) or len(candidate_by_id) != len(candidate):
|
|
|
|
|
raise DocForgeError("duplicate_artifact", "Shadow artifact IDs must be unique")
|
|
|
|
|
missing = sorted(set(reference_by_id) - set(candidate_by_id))
|
|
|
|
|
unexpected = sorted(set(candidate_by_id) - set(reference_by_id))
|
|
|
|
|
changed = sorted(
|
|
|
|
|
artifact_id
|
|
|
|
|
for artifact_id in set(reference_by_id) & set(candidate_by_id)
|
|
|
|
|
if reference_by_id[artifact_id].content != candidate_by_id[artifact_id].content
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"status": "ok" if not missing and not unexpected and not changed else "mismatch",
|
|
|
|
|
"count": len(reference),
|
|
|
|
|
"missing": missing,
|
|
|
|
|
"unexpected": unexpected,
|
|
|
|
|
"changed": changed,
|
|
|
|
|
"hashes": {
|
|
|
|
|
artifact_id: reference_by_id[artifact_id].sha256
|
|
|
|
|
for artifact_id in sorted(reference_by_id)
|
|
|
|
|
if artifact_id in candidate_by_id
|
|
|
|
|
and reference_by_id[artifact_id].content == candidate_by_id[artifact_id].content
|
|
|
|
|
},
|
|
|
|
|
}
|