feat: add isolated proposal changesets
This commit is contained in:
parent
9702ed1265
commit
8c75f4f44d
22 changed files with 2314 additions and 64 deletions
|
|
@ -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("*"):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue