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

feat: add project adapter shadow contract

This commit is contained in:
Andraxion 2026-07-22 04:17:05 -04:00
parent 411f417670
commit 561d98f1f8
10 changed files with 532 additions and 21 deletions

View file

@ -0,0 +1,271 @@
"""Reusable contracts for explicit project adapters and shadow verification."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from .config_validation import AUTHORITIES, ID_PATTERN
from .errors import DocForgeError
from .models import (
Edge,
Limits,
Node,
ProjectDescriptor,
ProjectSnapshot,
)
from .project import validate_graph
@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()
class AdapterLoader(Protocol):
"""Load one current, deterministic, project-confined adapter projection."""
def load_projection(self) -> AdapterProjection: ...
def validate_projection(projection: AdapterProjection) -> None:
"""Validate generic invariants without interpreting adapter metadata."""
root = projection.root.resolve(strict=True)
if not root.is_dir() or projection.root != root:
raise DocForgeError("invalid_adapter", "Adapter root must be a resolved directory")
for label, value in (
("project_id", projection.project_id),
("title", projection.title),
("adapter_id", projection.adapter_id),
("adapter_version", projection.adapter_version),
("revision", projection.revision),
("source_hash", projection.source_hash),
):
if not value.strip():
raise DocForgeError("invalid_adapter", f"Adapter {label} must not be empty")
if ID_PATTERN.fullmatch(projection.project_id) is None:
raise DocForgeError("invalid_adapter", "Adapter project ID is invalid")
if len(projection.source_hash) != 64 or any(
character not in "0123456789abcdef" for character in projection.source_hash
):
raise DocForgeError("invalid_adapter", "Adapter source hash must be lowercase SHA-256")
ordered_nodes = tuple(sorted(projection.nodes, key=lambda item: item.node.node_id))
ordered_edges = tuple(
sorted(
projection.edges,
key=lambda item: (
item.edge.source_id,
item.edge.relation,
item.edge.target_id,
),
)
)
if projection.nodes != ordered_nodes or projection.edges != ordered_edges:
raise DocForgeError(
"invalid_adapter", "Adapter projection must be deterministically ordered"
)
for item in (*projection.nodes, *projection.edges):
keys = [key for key, _ in item.metadata]
if keys != sorted(keys) or len(keys) != len(set(keys)):
raise DocForgeError(
"invalid_adapter", "Adapter metadata keys must be unique and ordered"
)
for item in projection.nodes:
node = item.node
source = Path(node.source_path)
if ID_PATTERN.fullmatch(node.node_id) is None:
raise DocForgeError("invalid_adapter", "Adapter node ID is invalid", id=node.node_id)
if node.authority not in AUTHORITIES:
raise DocForgeError(
"invalid_adapter", "Adapter node authority is invalid", id=node.node_id
)
if (
not node.title.strip()
or not node.family.strip()
or not node.status.strip()
or not node.summary.strip()
or not node.content.strip()
):
raise DocForgeError(
"invalid_adapter", "Adapter node has empty required content", id=node.node_id
)
if source.is_absolute() or ".." in source.parts or not node.source_path:
raise DocForgeError(
"invalid_adapter", "Adapter node source path is unsafe", id=node.node_id
)
if len(node.tags) != len(set(node.tags)) or any(not tag for tag in node.tags):
raise DocForgeError("invalid_adapter", "Adapter node tags are invalid", id=node.node_id)
if len(node.content_hash) != 64 or any(
character not in "0123456789abcdef" for character in node.content_hash
):
raise DocForgeError(
"invalid_adapter", "Adapter node content hash is invalid", id=node.node_id
)
for item in projection.edges:
if ID_PATTERN.fullmatch(item.edge.relation) is None:
raise DocForgeError("invalid_adapter", "Adapter relationship type is invalid")
validate_graph(projection.core_nodes(), projection.core_edges())
class AdapterProject:
"""Expose a validated adapter projection through the standard index boundary."""
def __init__(self, loader: AdapterLoader, *, cache_root: Path) -> None:
self.loader = loader
initial = loader.load_projection()
validate_projection(initial)
root = initial.root
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 = (
initial.project_id,
initial.adapter_id,
initial.adapter_version,
initial.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_hash=initial.identity(),
content_roots=(),
authority_files=(),
cache_root=resolved_cache,
index_path=resolved_cache / "index.sqlite3",
changeset_root=resolved_cache / "changesets-disabled",
proposal_writers=(),
render=None,
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,
),
)
def load(self) -> ProjectSnapshot:
projection = self.loader.load_projection()
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")
return ProjectSnapshot(
descriptor=self.descriptor,
nodes=projection.core_nodes(),
edges=projection.core_edges(),
source_hash=projection.source_hash,
revision=projection.revision,
)
@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
},
}

View file

@ -9,6 +9,7 @@ from typing import Any
from .errors import DocForgeError
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"})

View file

@ -8,13 +8,13 @@ import os
import sqlite3
import tempfile
from collections import deque
from collections.abc import Iterator
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from .errors import DocForgeError
from .models import Edge, Node, ProjectSnapshot
from .project import Project, project_root_fingerprint
from .models import Edge, Node, ProjectService, ProjectSnapshot
from .project import project_root_fingerprint
INDEX_SCHEMA_VERSION = 1
APPLICATION_ID = 1_146_683_778
@ -43,7 +43,7 @@ def _connect_read_only(path: Path) -> sqlite3.Connection:
@contextmanager
def _read_connection(path: Path) -> Iterator[sqlite3.Connection]:
def _read_connection(path: Path) -> Generator[sqlite3.Connection, None, None]:
connection: sqlite3.Connection | None = None
try:
connection = _connect_read_only(path)
@ -76,7 +76,7 @@ def _status(snapshot: ProjectSnapshot) -> dict[str, object]:
class ProjectIndex:
"""A disposable index that always checks current canonical source before queries."""
def __init__(self, project: Project) -> None:
def __init__(self, project: ProjectService) -> None:
self.project = project
@property
@ -265,7 +265,7 @@ class ProjectIndex:
""",
(expression, bounded),
).fetchall()
results = []
results: list[dict[str, object]] = []
for row in rows:
payload = _row_to_node(row).as_dict(include_content=False)
payload.update({"rank": row["rank"], "snippet": row["snippet"]})
@ -330,7 +330,7 @@ class ProjectIndex:
checked = self.check()
self._require_node(node_id)
maximum = self.project.descriptor.limits.max_traversal_depth
if not isinstance(depth, int) or isinstance(depth, bool) or depth < 0 or depth > maximum:
if type(depth) is not int or depth < 0 or depth > maximum:
raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
with _read_connection(self.path) as connection:
edges = tuple(
@ -340,7 +340,7 @@ class ProjectIndex:
"ORDER BY source_id, relation, target_id"
)
)
queue = deque([(node_id, 0, (node_id,))])
queue: deque[tuple[str, int, tuple[str, ...]]] = deque([(node_id, 0, (node_id,))])
seen = {node_id}
results: list[dict[str, object]] = []
while queue:
@ -417,7 +417,7 @@ def _row_to_node(row: sqlite3.Row) -> Node:
def _bounded_limit(value: int | None, maximum: int, *, default: int) -> int:
if value is None:
return min(default, maximum)
if not isinstance(value, int) or isinstance(value, bool) or value < 1 or value > maximum:
if type(value) is not int or value < 1 or value > maximum:
raise DocForgeError("invalid_limit", "Result limit is outside the configured range")
return value

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Protocol
@dataclass(frozen=True)
@ -118,6 +119,14 @@ class ProjectSnapshot:
revision: str
class ProjectService(Protocol):
"""Minimum immutable project boundary required by derived read services."""
descriptor: ProjectDescriptor
def load(self) -> ProjectSnapshot: ...
@dataclass(frozen=True)
class ContextEntry:
node_id: str

View file

@ -11,7 +11,14 @@ 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 .config_validation import (
AUTHORITIES,
ID_PATTERN,
confined_path,
positive_int,
require_string,
string_list,
)
from .errors import DocForgeError
from .models import (
ContextProfile,
@ -24,7 +31,6 @@ from .models import (
)
from .render_config import load_render_config
_AUTHORITIES = frozenset({"authoritative", "approved_plan", "derived", "proposal", "historical"})
_CORE_METADATA = frozenset(
{
"schema_version",
@ -363,7 +369,7 @@ def validated_node_from_record(
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)
if authority not in _AUTHORITIES:
if authority not in AUTHORITIES:
raise DocForgeError(
"invalid_source", f"{source.name}: authority is invalid", authority=authority
)