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

feat: add isolated proposal changesets

This commit is contained in:
Andraxion 2026-07-22 02:58:51 -04:00
parent 9702ed1265
commit 8c75f4f44d
22 changed files with 2314 additions and 64 deletions

View file

@ -20,6 +20,7 @@ from .models import (
Node,
ProjectDescriptor,
ProjectSnapshot,
ProposalWriter,
)
_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{1,127}")
@ -47,6 +48,7 @@ _DESCRIPTOR_KEYS = frozenset(
"adapter",
"sources",
"derived",
"changesets",
"graph",
"limits",
"profiles",
@ -54,10 +56,13 @@ _DESCRIPTOR_KEYS = frozenset(
)
_SOURCE_KEYS = frozenset({"content_roots", "authority_files"})
_DERIVED_KEYS = frozenset({"cache_root", "index"})
_CHANGESET_KEYS = frozenset({"root", "writers"})
_WRITER_KEYS = frozenset({"id", "families", "operations"})
_GRAPH_KEYS = frozenset({"allowed_relations"})
_PROFILE_KEYS = frozenset(
{"id", "families", "statuses", "required_nodes", "token_budget", "dependency_depth"}
)
_OPERATIONS = frozenset({"create", "update", "move", "delete"})
def project_root_fingerprint(root: Path) -> str:
@ -138,16 +143,21 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
sources = document.get("sources")
derived = document.get("derived")
changesets = document.get("changesets")
graph = document.get("graph")
if (
not isinstance(sources, dict)
or not isinstance(derived, dict)
or not isinstance(changesets, dict)
or not isinstance(graph, dict)
):
raise DocForgeError("invalid_config", "sources, derived, and graph tables are required")
raise DocForgeError(
"invalid_config", "sources, derived, changesets, and graph tables are required"
)
for table, allowed, name in (
(sources, _SOURCE_KEYS, "sources"),
(derived, _DERIVED_KEYS, "derived"),
(changesets, _CHANGESET_KEYS, "changesets"),
(graph, _GRAPH_KEYS, "graph"),
):
unknown = sorted(set(table) - allowed)
@ -185,6 +195,9 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
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(
root, changesets.get("root"), field="changesets.root", must_exist=False
)
if not index_path.is_relative_to(cache_root):
raise DocForgeError("invalid_config", "derived.index must be inside derived.cache_root")
for content_root in content_roots:
@ -194,6 +207,62 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
or cache_root.is_relative_to(content_root)
):
raise DocForgeError("invalid_config", "Canonical content and cache must not overlap")
if (
content_root == changeset_root
or content_root.is_relative_to(changeset_root)
or changeset_root.is_relative_to(content_root)
):
raise DocForgeError(
"invalid_config", "Canonical content and changesets must not overlap"
)
if (
cache_root == changeset_root
or cache_root.is_relative_to(changeset_root)
or changeset_root.is_relative_to(cache_root)
):
raise DocForgeError("invalid_config", "Cache and changesets must not overlap")
writer_documents = changesets.get("writers")
if not isinstance(writer_documents, list):
raise DocForgeError("invalid_config", "changesets.writers must be an array of tables")
proposal_writers: list[ProposalWriter] = []
writer_ids: set[str] = set()
for writer in writer_documents:
if not isinstance(writer, dict):
raise DocForgeError("invalid_config", "Each changeset writer must be a table")
unknown_writer = sorted(set(writer) - _WRITER_KEYS)
if unknown_writer:
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:
raise DocForgeError(
"invalid_config", "Changeset writer ID is invalid or duplicated", id=writer_id
)
writer_ids.add(writer_id)
families = _string_list(
writer.get("families"), key="changesets.writer.families", source=descriptor_path
)
operations = _string_list(
writer.get("operations"), key="changesets.writer.operations", source=descriptor_path
)
if not families:
raise DocForgeError("invalid_config", "Changeset writer needs at least one family")
invalid_operations = sorted(set(operations) - _OPERATIONS)
if not operations or invalid_operations:
raise DocForgeError(
"invalid_config",
"Changeset writer operations are empty or invalid",
operations=invalid_operations,
)
proposal_writers.append(
ProposalWriter(
writer_id=writer_id,
families=tuple(sorted(families)),
operations=tuple(sorted(operations)),
)
)
allowed_relations = _string_list(
graph.get("allowed_relations"), key="graph.allowed_relations", source=descriptor_path
@ -276,6 +345,8 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
authority_files=authority_files,
cache_root=cache_root,
index_path=index_path,
changeset_root=changeset_root,
proposal_writers=tuple(sorted(proposal_writers, key=lambda writer: writer.writer_id)),
allowed_relations=allowed_relations,
profiles=tuple(profiles),
limits=limits,
@ -308,7 +379,7 @@ def _markdown_record(path: Path, text: str) -> tuple[dict[str, Any], str]:
return metadata, "\n".join(lines[close + 1 :]).strip()
def _node_from_record(
def validated_node_from_record(
record: dict[str, Any],
*,
content: str,
@ -374,7 +445,7 @@ def _load_source_file(
relative = path.relative_to(descriptor.root).as_posix()
if path.suffix == ".md":
record, content = _markdown_record(path, text)
node, edges = _node_from_record(
node, edges = validated_node_from_record(
record,
content=content,
source=path,
@ -402,7 +473,7 @@ def _load_source_file(
"invalid_source", f"{path.name}: TOML node content must be text"
)
canonical = json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
node, node_edges = _node_from_record(
node, node_edges = validated_node_from_record(
record,
content=content.strip(),
source=path,
@ -416,7 +487,7 @@ def _load_source_file(
raise DocForgeError("invalid_source", "Unsupported canonical source type", source=relative)
def _validate_graph(nodes: tuple[Node, ...], edges: tuple[Edge, ...]) -> None:
def validate_graph(nodes: tuple[Node, ...], edges: tuple[Edge, ...]) -> None:
node_ids = {node.node_id for node in nodes}
if len(node_ids) != len(nodes):
counts = Counter(node.node_id for node in nodes)
@ -498,7 +569,7 @@ class Project:
raise DocForgeError(
"source_changed", "Project descriptor changed after the project was opened"
)
ordered_sources = self._canonical_source_paths()
ordered_sources = self.canonical_source_paths()
captured = {
path: path.read_bytes()
for path in (
@ -520,7 +591,7 @@ class Project:
ordered_edges = tuple(
sorted(edges, key=lambda edge: (edge.source_id, edge.relation, edge.target_id))
)
_validate_graph(ordered_nodes, ordered_edges)
validate_graph(ordered_nodes, ordered_edges)
node_ids = {node.node_id for node in ordered_nodes}
for profile in self.descriptor.profiles:
missing = sorted(set(profile.required_nodes) - node_ids)
@ -529,7 +600,7 @@ class Project:
"invalid_config", "Context profile requires missing nodes", nodes=missing
)
if self._canonical_source_paths() != ordered_sources:
if self.canonical_source_paths() != ordered_sources:
raise DocForgeError("source_changed", "Canonical source set changed during loading")
for path, raw in captured.items():
if not path.is_file() or path.read_bytes() != raw:
@ -547,7 +618,7 @@ class Project:
digest.update(relative.encode())
digest.update(b"\0")
digest.update(hashlib.sha256(captured[path]).digest())
digest.update(b"docforge-core:0.1.0:index:1")
digest.update(b"docforge-core:0.2.0:index:1")
return ProjectSnapshot(
descriptor=self.descriptor,
nodes=ordered_nodes,
@ -556,7 +627,9 @@ class Project:
revision=_revision(self.descriptor.root),
)
def _canonical_source_paths(self) -> tuple[Path, ...]:
def canonical_source_paths(self) -> tuple[Path, ...]:
"""Return the deterministic confined canonical source set."""
source_paths: set[Path] = set()
for content_root in self.descriptor.content_roots:
for path in content_root.rglob("*"):