2026-07-22 01:29:32 -04:00
|
|
|
"""Project discovery, root confinement, canonical loading, and graph validation."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
2026-07-29 04:00:23 -04:00
|
|
|
import os
|
|
|
|
|
import stat
|
2026-07-22 01:29:32 -04:00
|
|
|
import subprocess
|
2026-07-29 04:00:23 -04:00
|
|
|
import tempfile
|
2026-07-22 01:29:32 -04:00
|
|
|
import tomllib
|
|
|
|
|
from collections import Counter
|
2026-07-22 11:50:49 -04:00
|
|
|
from collections.abc import Mapping
|
2026-07-29 04:00:23 -04:00
|
|
|
from dataclasses import dataclass, replace
|
|
|
|
|
from pathlib import Path, PurePosixPath
|
2026-07-24 22:26:01 -04:00
|
|
|
from typing import Any, cast
|
2026-07-22 01:29:32 -04:00
|
|
|
|
2026-07-22 04:17:05 -04:00
|
|
|
from .config_validation import (
|
|
|
|
|
AUTHORITIES,
|
|
|
|
|
ID_PATTERN,
|
|
|
|
|
confined_path,
|
|
|
|
|
positive_int,
|
|
|
|
|
require_string,
|
|
|
|
|
string_list,
|
|
|
|
|
)
|
2026-07-22 01:29:32 -04:00
|
|
|
from .errors import DocForgeError
|
|
|
|
|
from .models import (
|
|
|
|
|
ContextProfile,
|
|
|
|
|
Edge,
|
|
|
|
|
Limits,
|
|
|
|
|
Node,
|
|
|
|
|
ProjectDescriptor,
|
|
|
|
|
ProjectSnapshot,
|
2026-07-29 04:00:23 -04:00
|
|
|
ProjectState,
|
2026-07-22 02:58:51 -04:00
|
|
|
ProposalWriter,
|
2026-07-22 01:29:32 -04:00
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
from .render_config import load_render_config
|
2026-07-29 05:07:16 -04:00
|
|
|
from .telemetry import increment, stage
|
2026-07-22 01:29:32 -04:00
|
|
|
|
2026-07-29 04:00:23 -04:00
|
|
|
SOURCE_GENERATION_SCHEMA_VERSION = 1
|
|
|
|
|
GENERIC_SOURCE_CONTRACT = "docforge-core:0.7.1:index:1"
|
|
|
|
|
|
2026-07-22 01:29:32 -04:00
|
|
|
_CORE_METADATA = frozenset(
|
|
|
|
|
{
|
|
|
|
|
"schema_version",
|
|
|
|
|
"id",
|
|
|
|
|
"title",
|
|
|
|
|
"family",
|
|
|
|
|
"authority",
|
|
|
|
|
"status",
|
|
|
|
|
"tags",
|
|
|
|
|
"summary",
|
|
|
|
|
"source_anchor",
|
|
|
|
|
"content",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
_DESCRIPTOR_KEYS = frozenset(
|
|
|
|
|
{
|
|
|
|
|
"schema_version",
|
|
|
|
|
"project_id",
|
|
|
|
|
"title",
|
|
|
|
|
"adapter",
|
|
|
|
|
"sources",
|
|
|
|
|
"derived",
|
2026-07-22 02:58:51 -04:00
|
|
|
"changesets",
|
2026-07-22 03:32:05 -04:00
|
|
|
"render",
|
2026-07-22 01:29:32 -04:00
|
|
|
"graph",
|
|
|
|
|
"limits",
|
|
|
|
|
"profiles",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
_SOURCE_KEYS = frozenset({"content_roots", "authority_files"})
|
|
|
|
|
_DERIVED_KEYS = frozenset({"cache_root", "index"})
|
2026-07-22 02:58:51 -04:00
|
|
|
_CHANGESET_KEYS = frozenset({"root", "writers"})
|
|
|
|
|
_WRITER_KEYS = frozenset({"id", "families", "operations"})
|
2026-07-22 01:29:32 -04:00
|
|
|
_GRAPH_KEYS = frozenset({"allowed_relations"})
|
|
|
|
|
_PROFILE_KEYS = frozenset(
|
|
|
|
|
{"id", "families", "statuses", "required_nodes", "token_budget", "dependency_depth"}
|
|
|
|
|
)
|
2026-07-22 02:58:51 -04:00
|
|
|
_OPERATIONS = frozenset({"create", "update", "move", "delete"})
|
2026-07-22 01:29:32 -04:00
|
|
|
|
|
|
|
|
|
2026-07-29 04:00:23 -04:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class _CapturedGeneration:
|
|
|
|
|
source_hash: str
|
|
|
|
|
revision: str
|
|
|
|
|
files: tuple[tuple[str, int, int, int, int, int, int], ...]
|
|
|
|
|
directories: tuple[tuple[str, int, int, int, int, int], ...]
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 01:29:32 -04:00
|
|
|
def project_root_fingerprint(root: Path) -> str:
|
|
|
|
|
return hashlib.sha256(str(root).encode()).hexdigest()[:16]
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 04:00:23 -04:00
|
|
|
def _file_generation(
|
|
|
|
|
root: Path,
|
|
|
|
|
paths: tuple[Path, ...],
|
|
|
|
|
) -> tuple[tuple[str, int, int, int, int, int, int], ...]:
|
|
|
|
|
"""Capture cheap identities that change on ordinary source or metadata mutation."""
|
|
|
|
|
|
|
|
|
|
identities: list[tuple[str, int, int, int, int, int, int]] = []
|
|
|
|
|
for path in paths:
|
|
|
|
|
try:
|
|
|
|
|
status = path.lstat()
|
|
|
|
|
except OSError as error:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_changed",
|
|
|
|
|
"Canonical source disappeared during generation capture",
|
|
|
|
|
source=path.relative_to(root).as_posix(),
|
|
|
|
|
) from error
|
|
|
|
|
if not stat.S_ISREG(status.st_mode):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_changed",
|
|
|
|
|
"Canonical generation inputs must remain regular files",
|
|
|
|
|
source=path.relative_to(root).as_posix(),
|
|
|
|
|
)
|
|
|
|
|
identities.append(
|
|
|
|
|
(
|
|
|
|
|
path.relative_to(root).as_posix(),
|
|
|
|
|
status.st_dev,
|
|
|
|
|
status.st_ino,
|
|
|
|
|
status.st_mode,
|
|
|
|
|
status.st_size,
|
|
|
|
|
status.st_mtime_ns,
|
|
|
|
|
status.st_ctime_ns,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return tuple(identities)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _directory_generation(
|
|
|
|
|
root: Path,
|
|
|
|
|
paths: tuple[Path, ...],
|
|
|
|
|
) -> tuple[tuple[str, int, int, int, int, int], ...]:
|
|
|
|
|
"""Capture directory identities so source membership changes invalidate a receipt."""
|
|
|
|
|
|
|
|
|
|
identities: list[tuple[str, int, int, int, int, int]] = []
|
|
|
|
|
for path in paths:
|
|
|
|
|
try:
|
|
|
|
|
status = path.lstat()
|
|
|
|
|
except OSError as error:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_changed",
|
|
|
|
|
"Canonical source directory disappeared during generation capture",
|
|
|
|
|
source=path.relative_to(root).as_posix(),
|
|
|
|
|
) from error
|
|
|
|
|
if not stat.S_ISDIR(status.st_mode):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_changed",
|
|
|
|
|
"Canonical source directories must remain directories",
|
|
|
|
|
source=path.relative_to(root).as_posix(),
|
|
|
|
|
)
|
|
|
|
|
identities.append(
|
|
|
|
|
(
|
|
|
|
|
path.relative_to(root).as_posix(),
|
|
|
|
|
status.st_dev,
|
|
|
|
|
status.st_ino,
|
|
|
|
|
status.st_mode,
|
|
|
|
|
status.st_mtime_ns,
|
|
|
|
|
status.st_ctime_ns,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return tuple(identities)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _receipt_paths(root: Path, value: object, *, width: int) -> tuple[Path, ...] | None:
|
|
|
|
|
if not isinstance(value, list):
|
|
|
|
|
return None
|
|
|
|
|
paths: list[Path] = []
|
|
|
|
|
for raw_item in cast(list[object], value):
|
|
|
|
|
if not isinstance(raw_item, list):
|
|
|
|
|
return None
|
|
|
|
|
item = cast(list[object], raw_item)
|
|
|
|
|
if len(item) != width or not isinstance(item[0], str):
|
|
|
|
|
return None
|
|
|
|
|
relative = PurePosixPath(item[0])
|
|
|
|
|
if relative.is_absolute() or not relative.parts or ".." in relative.parts:
|
|
|
|
|
return None
|
|
|
|
|
path = root.joinpath(*relative.parts)
|
|
|
|
|
if not path.is_relative_to(root):
|
|
|
|
|
return None
|
|
|
|
|
paths.append(path)
|
|
|
|
|
return tuple(paths)
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 01:29:32 -04:00
|
|
|
def _load_descriptor(root: Path) -> ProjectDescriptor:
|
|
|
|
|
descriptor_path = root / ".docforge" / "project.toml"
|
|
|
|
|
if not descriptor_path.is_file():
|
|
|
|
|
raise DocForgeError("missing_config", "Missing .docforge/project.toml")
|
|
|
|
|
try:
|
|
|
|
|
descriptor_bytes = descriptor_path.read_bytes()
|
2026-07-24 22:26:01 -04:00
|
|
|
document = cast(dict[str, object], tomllib.loads(descriptor_bytes.decode("utf-8")))
|
2026-07-22 01:29:32 -04:00
|
|
|
except UnicodeDecodeError as error:
|
|
|
|
|
raise DocForgeError("invalid_config", "Project descriptor is not UTF-8") from error
|
|
|
|
|
except tomllib.TOMLDecodeError as error:
|
|
|
|
|
raise DocForgeError("invalid_config", f"Invalid project descriptor: {error}") from error
|
|
|
|
|
|
|
|
|
|
unknown_descriptor = sorted(set(document) - _DESCRIPTOR_KEYS)
|
|
|
|
|
if unknown_descriptor:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_config", "Project descriptor has unknown fields", fields=unknown_descriptor
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if document.get("schema_version") != 1:
|
|
|
|
|
raise DocForgeError("invalid_config", "Project descriptor schema_version must be 1")
|
2026-07-22 03:32:05 -04:00
|
|
|
project_id = require_string(document, "project_id", descriptor_path)
|
|
|
|
|
if ID_PATTERN.fullmatch(project_id) is None:
|
2026-07-22 01:29:32 -04:00
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_config", "project_id is not a stable ID", project_id=project_id
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
title = require_string(document, "title", descriptor_path)
|
|
|
|
|
adapter = require_string(document, "adapter", descriptor_path)
|
2026-07-22 01:29:32 -04:00
|
|
|
if adapter != "generic":
|
|
|
|
|
raise DocForgeError("unsupported_adapter", "DFG-1 supports only the generic adapter")
|
|
|
|
|
|
|
|
|
|
sources = document.get("sources")
|
|
|
|
|
derived = document.get("derived")
|
2026-07-22 02:58:51 -04:00
|
|
|
changesets = document.get("changesets")
|
2026-07-22 01:29:32 -04:00
|
|
|
graph = document.get("graph")
|
|
|
|
|
if (
|
|
|
|
|
not isinstance(sources, dict)
|
|
|
|
|
or not isinstance(derived, dict)
|
2026-07-22 02:58:51 -04:00
|
|
|
or not isinstance(changesets, dict)
|
2026-07-22 01:29:32 -04:00
|
|
|
or not isinstance(graph, dict)
|
|
|
|
|
):
|
2026-07-22 02:58:51 -04:00
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_config", "sources, derived, changesets, and graph tables are required"
|
|
|
|
|
)
|
2026-07-24 22:26:01 -04:00
|
|
|
sources = cast(dict[str, object], sources)
|
|
|
|
|
derived = cast(dict[str, object], derived)
|
|
|
|
|
changesets = cast(dict[str, object], changesets)
|
|
|
|
|
graph = cast(dict[str, object], graph)
|
2026-07-22 01:29:32 -04:00
|
|
|
for table, allowed, name in (
|
|
|
|
|
(sources, _SOURCE_KEYS, "sources"),
|
|
|
|
|
(derived, _DERIVED_KEYS, "derived"),
|
2026-07-22 02:58:51 -04:00
|
|
|
(changesets, _CHANGESET_KEYS, "changesets"),
|
2026-07-22 01:29:32 -04:00
|
|
|
(graph, _GRAPH_KEYS, "graph"),
|
|
|
|
|
):
|
|
|
|
|
unknown = sorted(set(table) - allowed)
|
|
|
|
|
if unknown:
|
|
|
|
|
raise DocForgeError("invalid_config", f"{name} has unknown fields", fields=unknown)
|
|
|
|
|
content_roots = tuple(
|
2026-07-22 03:32:05 -04:00
|
|
|
confined_path(
|
2026-07-22 01:29:32 -04:00
|
|
|
root,
|
|
|
|
|
item,
|
|
|
|
|
field="sources.content_roots",
|
|
|
|
|
must_exist=True,
|
|
|
|
|
expected="directory",
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
for item in string_list(
|
2026-07-22 01:29:32 -04:00
|
|
|
sources.get("content_roots"), key="sources.content_roots", source=descriptor_path
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if len(content_roots) != len(set(content_roots)):
|
|
|
|
|
raise DocForgeError("invalid_config", "sources.content_roots resolve to duplicates")
|
|
|
|
|
authority_files = tuple(
|
2026-07-22 03:32:05 -04:00
|
|
|
confined_path(
|
2026-07-22 01:29:32 -04:00
|
|
|
root,
|
|
|
|
|
item,
|
|
|
|
|
field="sources.authority_files",
|
|
|
|
|
must_exist=True,
|
|
|
|
|
expected="file",
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
for item in string_list(
|
2026-07-22 01:29:32 -04:00
|
|
|
sources.get("authority_files", []),
|
|
|
|
|
key="sources.authority_files",
|
|
|
|
|
source=descriptor_path,
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
cache_root = confined_path(
|
2026-07-22 01:29:32 -04:00
|
|
|
root, derived.get("cache_root"), field="derived.cache_root", must_exist=False
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
index_path = confined_path(root, derived.get("index"), field="derived.index", must_exist=False)
|
|
|
|
|
changeset_root = confined_path(
|
2026-07-22 02:58:51 -04:00
|
|
|
root, changesets.get("root"), field="changesets.root", must_exist=False
|
|
|
|
|
)
|
2026-07-22 01:29:32 -04:00
|
|
|
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:
|
|
|
|
|
if (
|
|
|
|
|
content_root == cache_root
|
|
|
|
|
or content_root.is_relative_to(cache_root)
|
|
|
|
|
or cache_root.is_relative_to(content_root)
|
|
|
|
|
):
|
|
|
|
|
raise DocForgeError("invalid_config", "Canonical content and cache must not overlap")
|
2026-07-22 02:58:51 -04:00
|
|
|
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()
|
2026-07-24 22:26:01 -04:00
|
|
|
for writer_value in cast(list[object], writer_documents):
|
|
|
|
|
if not isinstance(writer_value, dict):
|
2026-07-22 02:58:51 -04:00
|
|
|
raise DocForgeError("invalid_config", "Each changeset writer must be a table")
|
2026-07-24 22:26:01 -04:00
|
|
|
writer = cast(dict[str, object], writer_value)
|
2026-07-22 02:58:51 -04:00
|
|
|
unknown_writer = sorted(set(writer) - _WRITER_KEYS)
|
|
|
|
|
if unknown_writer:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_config", "Changeset writer has unknown fields", fields=unknown_writer
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
writer_id = require_string(writer, "id", descriptor_path)
|
|
|
|
|
if ID_PATTERN.fullmatch(writer_id) is None or writer_id in writer_ids:
|
2026-07-22 02:58:51 -04:00
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_config", "Changeset writer ID is invalid or duplicated", id=writer_id
|
|
|
|
|
)
|
|
|
|
|
writer_ids.add(writer_id)
|
2026-07-22 03:32:05 -04:00
|
|
|
families = string_list(
|
2026-07-22 02:58:51 -04:00
|
|
|
writer.get("families"), key="changesets.writer.families", source=descriptor_path
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
operations = string_list(
|
2026-07-22 02:58:51 -04:00
|
|
|
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)),
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-07-22 01:29:32 -04:00
|
|
|
|
2026-07-22 03:32:05 -04:00
|
|
|
allowed_relations = string_list(
|
2026-07-22 01:29:32 -04:00
|
|
|
graph.get("allowed_relations"), key="graph.allowed_relations", source=descriptor_path
|
|
|
|
|
)
|
|
|
|
|
if not allowed_relations:
|
|
|
|
|
raise DocForgeError("invalid_config", "At least one relationship type is required")
|
|
|
|
|
for relation in allowed_relations:
|
2026-07-22 03:32:05 -04:00
|
|
|
if ID_PATTERN.fullmatch(relation) is None:
|
2026-07-22 01:29:32 -04:00
|
|
|
raise DocForgeError("invalid_config", "Relationship type is invalid", relation=relation)
|
|
|
|
|
|
|
|
|
|
limit_values = document.get("limits", {})
|
|
|
|
|
if not isinstance(limit_values, dict):
|
|
|
|
|
raise DocForgeError("invalid_config", "limits must be a table")
|
2026-07-24 22:26:01 -04:00
|
|
|
limit_values = cast(dict[str, object], limit_values)
|
2026-07-22 01:29:32 -04:00
|
|
|
defaults = Limits()
|
|
|
|
|
unknown_limits = sorted(set(limit_values) - set(defaults.__dataclass_fields__))
|
|
|
|
|
if unknown_limits:
|
|
|
|
|
raise DocForgeError("invalid_config", "limits has unknown fields", fields=unknown_limits)
|
|
|
|
|
limits = Limits(
|
|
|
|
|
**{
|
2026-07-22 03:32:05 -04:00
|
|
|
field: positive_int(limit_values.get(field, getattr(defaults, field)), field)
|
2026-07-22 01:29:32 -04:00
|
|
|
for field in defaults.__dataclass_fields__
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-22 03:32:05 -04:00
|
|
|
render = load_render_config(
|
|
|
|
|
root,
|
|
|
|
|
document.get("render"),
|
|
|
|
|
descriptor_path=descriptor_path,
|
|
|
|
|
content_roots=content_roots,
|
|
|
|
|
authority_files=authority_files,
|
|
|
|
|
cache_root=cache_root,
|
|
|
|
|
index_path=index_path,
|
|
|
|
|
changeset_root=changeset_root,
|
|
|
|
|
limits=limits,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-22 01:29:32 -04:00
|
|
|
profile_documents = document.get("profiles", [])
|
|
|
|
|
if not isinstance(profile_documents, list):
|
|
|
|
|
raise DocForgeError("invalid_config", "profiles must be an array of tables")
|
|
|
|
|
profiles: list[ContextProfile] = []
|
|
|
|
|
profile_ids: set[str] = set()
|
2026-07-24 22:26:01 -04:00
|
|
|
for profile_value in cast(list[object], profile_documents):
|
|
|
|
|
if not isinstance(profile_value, dict):
|
2026-07-22 01:29:32 -04:00
|
|
|
raise DocForgeError("invalid_config", "Each profile must be a table")
|
2026-07-24 22:26:01 -04:00
|
|
|
profile = cast(dict[str, object], profile_value)
|
2026-07-22 01:29:32 -04:00
|
|
|
unknown_profile = sorted(set(profile) - _PROFILE_KEYS)
|
|
|
|
|
if unknown_profile:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_config", "Profile has unknown fields", fields=unknown_profile
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
profile_id = require_string(profile, "id", descriptor_path)
|
|
|
|
|
if ID_PATTERN.fullmatch(profile_id) is None or profile_id in profile_ids:
|
2026-07-22 01:29:32 -04:00
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_config", "Profile ID is invalid or duplicated", id=profile_id
|
|
|
|
|
)
|
|
|
|
|
profile_ids.add(profile_id)
|
2026-07-22 03:32:05 -04:00
|
|
|
token_budget = positive_int(profile.get("token_budget", 8_000), "profile.token_budget")
|
|
|
|
|
dependency_depth = positive_int(
|
2026-07-22 01:29:32 -04:00
|
|
|
profile.get("dependency_depth", 1), "profile.dependency_depth", allow_zero=True
|
|
|
|
|
)
|
|
|
|
|
if token_budget > limits.max_context_tokens:
|
|
|
|
|
raise DocForgeError("invalid_config", "Profile token budget exceeds project limit")
|
|
|
|
|
if dependency_depth > limits.max_traversal_depth:
|
|
|
|
|
raise DocForgeError("invalid_config", "Profile dependency depth exceeds project limit")
|
|
|
|
|
profiles.append(
|
|
|
|
|
ContextProfile(
|
|
|
|
|
profile_id=profile_id,
|
2026-07-22 03:32:05 -04:00
|
|
|
families=string_list(
|
2026-07-22 01:29:32 -04:00
|
|
|
profile.get("families", []), key="profile.families", source=descriptor_path
|
|
|
|
|
),
|
2026-07-22 03:32:05 -04:00
|
|
|
statuses=string_list(
|
2026-07-22 01:29:32 -04:00
|
|
|
profile.get("statuses", []), key="profile.statuses", source=descriptor_path
|
|
|
|
|
),
|
2026-07-22 03:32:05 -04:00
|
|
|
required_nodes=string_list(
|
2026-07-22 01:29:32 -04:00
|
|
|
profile.get("required_nodes", []),
|
|
|
|
|
key="profile.required_nodes",
|
|
|
|
|
source=descriptor_path,
|
|
|
|
|
),
|
|
|
|
|
token_budget=token_budget,
|
|
|
|
|
dependency_depth=dependency_depth,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return ProjectDescriptor(
|
|
|
|
|
schema_version=1,
|
|
|
|
|
project_id=project_id,
|
|
|
|
|
title=title,
|
|
|
|
|
adapter=adapter,
|
|
|
|
|
root=root,
|
|
|
|
|
descriptor_path=descriptor_path,
|
|
|
|
|
descriptor_hash=hashlib.sha256(descriptor_bytes).hexdigest(),
|
|
|
|
|
content_roots=content_roots,
|
|
|
|
|
authority_files=authority_files,
|
|
|
|
|
cache_root=cache_root,
|
|
|
|
|
index_path=index_path,
|
2026-07-22 02:58:51 -04:00
|
|
|
changeset_root=changeset_root,
|
|
|
|
|
proposal_writers=tuple(sorted(proposal_writers, key=lambda writer: writer.writer_id)),
|
2026-07-22 03:32:05 -04:00
|
|
|
render=render,
|
2026-07-22 01:29:32 -04:00
|
|
|
allowed_relations=allowed_relations,
|
|
|
|
|
profiles=tuple(profiles),
|
|
|
|
|
limits=limits,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _markdown_record(path: Path, text: str) -> tuple[dict[str, Any], str]:
|
|
|
|
|
lines = text.splitlines()
|
|
|
|
|
if not lines or lines[0] != "+++":
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_source", f"{path.name}: Markdown must start with TOML metadata"
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
close = lines.index("+++", 1)
|
|
|
|
|
except ValueError as error:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_source", f"{path.name}: metadata block is not closed"
|
|
|
|
|
) from error
|
|
|
|
|
try:
|
|
|
|
|
metadata = tomllib.loads("\n".join(lines[1:close]))
|
|
|
|
|
except tomllib.TOMLDecodeError as error:
|
|
|
|
|
raise DocForgeError("invalid_source", f"{path.name}: invalid metadata: {error}") from error
|
|
|
|
|
return metadata, "\n".join(lines[close + 1 :]).strip()
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 02:58:51 -04:00
|
|
|
def validated_node_from_record(
|
2026-07-22 01:29:32 -04:00
|
|
|
record: dict[str, Any],
|
|
|
|
|
*,
|
|
|
|
|
content: str,
|
|
|
|
|
source: Path,
|
|
|
|
|
relative_source: str,
|
|
|
|
|
relations: tuple[str, ...],
|
|
|
|
|
hash_bytes: bytes,
|
|
|
|
|
) -> tuple[Node, tuple[Edge, ...]]:
|
|
|
|
|
if record.get("schema_version") != 1:
|
|
|
|
|
raise DocForgeError("invalid_source", f"{source.name}: node schema_version must be 1")
|
|
|
|
|
unknown = set(record) - _CORE_METADATA - set(relations)
|
|
|
|
|
if unknown:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_source", f"{source.name}: unknown metadata", keys=sorted(unknown)
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
node_id = require_string(record, "id", source)
|
|
|
|
|
if ID_PATTERN.fullmatch(node_id) is None:
|
2026-07-22 01:29:32 -04:00
|
|
|
raise DocForgeError("invalid_source", f"{source.name}: node ID is invalid", id=node_id)
|
2026-07-22 03:32:05 -04:00
|
|
|
authority = require_string(record, "authority", source)
|
2026-07-22 04:17:05 -04:00
|
|
|
if authority not in AUTHORITIES:
|
2026-07-22 01:29:32 -04:00
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_source", f"{source.name}: authority is invalid", authority=authority
|
|
|
|
|
)
|
2026-07-22 03:32:05 -04:00
|
|
|
tags = string_list(record.get("tags", []), key="tags", source=source)
|
2026-07-22 01:29:32 -04:00
|
|
|
anchor = record.get("source_anchor")
|
|
|
|
|
if anchor is not None and (not isinstance(anchor, str) or not anchor):
|
|
|
|
|
raise DocForgeError("invalid_source", f"{source.name}: source_anchor must be a string")
|
2026-07-22 03:32:05 -04:00
|
|
|
summary = require_string(record, "summary", source)
|
2026-07-22 01:29:32 -04:00
|
|
|
if not content:
|
|
|
|
|
raise DocForgeError("invalid_source", f"{source.name}: node content is empty", id=node_id)
|
|
|
|
|
node = Node(
|
|
|
|
|
node_id=node_id,
|
2026-07-22 03:32:05 -04:00
|
|
|
title=require_string(record, "title", source),
|
|
|
|
|
family=require_string(record, "family", source),
|
2026-07-22 01:29:32 -04:00
|
|
|
authority=authority,
|
2026-07-22 03:32:05 -04:00
|
|
|
status=require_string(record, "status", source),
|
2026-07-22 01:29:32 -04:00
|
|
|
tags=tags,
|
|
|
|
|
summary=summary,
|
|
|
|
|
content=content,
|
|
|
|
|
source_path=relative_source,
|
|
|
|
|
source_anchor=anchor,
|
|
|
|
|
content_hash=hashlib.sha256(hash_bytes).hexdigest(),
|
|
|
|
|
)
|
|
|
|
|
edges = tuple(
|
|
|
|
|
Edge(node_id, relation, target)
|
|
|
|
|
for relation in relations
|
2026-07-22 03:32:05 -04:00
|
|
|
for target in string_list(record.get(relation, []), key=relation, source=source)
|
2026-07-22 01:29:32 -04:00
|
|
|
)
|
|
|
|
|
return node, edges
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_source_file(
|
|
|
|
|
descriptor: ProjectDescriptor, path: Path, raw: bytes
|
|
|
|
|
) -> tuple[tuple[Node, ...], tuple[Edge, ...]]:
|
|
|
|
|
if len(raw) > descriptor.limits.max_source_bytes:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_too_large", "Canonical source exceeds configured limit", source=path.name
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
text = raw.decode("utf-8")
|
|
|
|
|
except UnicodeDecodeError as error:
|
|
|
|
|
raise DocForgeError("invalid_source", f"{path.name}: source is not UTF-8") from error
|
|
|
|
|
relative = path.relative_to(descriptor.root).as_posix()
|
|
|
|
|
if path.suffix == ".md":
|
|
|
|
|
record, content = _markdown_record(path, text)
|
2026-07-24 22:26:01 -04:00
|
|
|
node, node_edges = validated_node_from_record(
|
2026-07-22 01:29:32 -04:00
|
|
|
record,
|
|
|
|
|
content=content,
|
|
|
|
|
source=path,
|
|
|
|
|
relative_source=relative,
|
|
|
|
|
relations=descriptor.allowed_relations,
|
|
|
|
|
hash_bytes=raw,
|
|
|
|
|
)
|
2026-07-24 22:26:01 -04:00
|
|
|
return (node,), node_edges
|
2026-07-22 01:29:32 -04:00
|
|
|
if path.suffix == ".toml":
|
|
|
|
|
try:
|
2026-07-24 22:26:01 -04:00
|
|
|
document = cast(dict[str, object], tomllib.loads(text))
|
2026-07-22 01:29:32 -04:00
|
|
|
except tomllib.TOMLDecodeError as error:
|
|
|
|
|
raise DocForgeError("invalid_source", f"{path.name}: invalid TOML: {error}") from error
|
|
|
|
|
records = document.get("nodes")
|
|
|
|
|
if set(document) != {"nodes"} or not isinstance(records, list) or not records:
|
|
|
|
|
raise DocForgeError("invalid_source", f"{path.name}: TOML sources require [[nodes]]")
|
|
|
|
|
nodes: list[Node] = []
|
|
|
|
|
edges: list[Edge] = []
|
2026-07-24 22:26:01 -04:00
|
|
|
for index, record_value in enumerate(cast(list[object], records)):
|
|
|
|
|
if not isinstance(record_value, dict):
|
2026-07-22 01:29:32 -04:00
|
|
|
raise DocForgeError("invalid_source", f"{path.name}: nodes must be tables")
|
2026-07-24 22:26:01 -04:00
|
|
|
record = cast(dict[str, Any], record_value)
|
2026-07-22 01:29:32 -04:00
|
|
|
content = record.get("content")
|
|
|
|
|
if not isinstance(content, str):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_source", f"{path.name}: TOML node content must be text"
|
|
|
|
|
)
|
|
|
|
|
canonical = json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
|
2026-07-22 02:58:51 -04:00
|
|
|
node, node_edges = validated_node_from_record(
|
2026-07-22 01:29:32 -04:00
|
|
|
record,
|
|
|
|
|
content=content.strip(),
|
|
|
|
|
source=path,
|
|
|
|
|
relative_source=relative,
|
|
|
|
|
relations=descriptor.allowed_relations,
|
|
|
|
|
hash_bytes=canonical,
|
|
|
|
|
)
|
|
|
|
|
nodes.append(replace(node, source_anchor=node.source_anchor or f"node-{index + 1}"))
|
|
|
|
|
edges.extend(node_edges)
|
|
|
|
|
return tuple(nodes), tuple(edges)
|
|
|
|
|
raise DocForgeError("invalid_source", "Unsupported canonical source type", source=relative)
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 02:58:51 -04:00
|
|
|
def validate_graph(nodes: tuple[Node, ...], edges: tuple[Edge, ...]) -> None:
|
2026-07-22 01:29:32 -04:00
|
|
|
node_ids = {node.node_id for node in nodes}
|
|
|
|
|
if len(node_ids) != len(nodes):
|
|
|
|
|
counts = Counter(node.node_id for node in nodes)
|
|
|
|
|
duplicates = sorted(node_id for node_id, count in counts.items() if count > 1)
|
|
|
|
|
raise DocForgeError("duplicate_node", "Stable node IDs must be unique", ids=duplicates)
|
|
|
|
|
|
2026-07-29 03:45:09 -04:00
|
|
|
dependencies: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
|
2026-07-29 04:00:23 -04:00
|
|
|
edge_keys: set[tuple[str, str, str]] = set()
|
|
|
|
|
missing_sources: set[str] = set()
|
|
|
|
|
missing_targets: set[str] = set()
|
2026-07-29 03:45:09 -04:00
|
|
|
for edge in edges:
|
2026-07-29 04:00:23 -04:00
|
|
|
key = (edge.source_id, edge.relation, edge.target_id)
|
|
|
|
|
if key in edge_keys:
|
|
|
|
|
raise DocForgeError("duplicate_edge", "Relationships must be unique")
|
|
|
|
|
edge_keys.add(key)
|
|
|
|
|
if edge.source_id not in node_ids:
|
|
|
|
|
missing_sources.add(edge.source_id)
|
|
|
|
|
if edge.target_id not in node_ids:
|
|
|
|
|
missing_targets.add(edge.target_id)
|
|
|
|
|
if edge.relation == "depends_on" and edge.source_id in dependencies:
|
2026-07-29 03:45:09 -04:00
|
|
|
dependencies[edge.source_id].append(edge.target_id)
|
2026-07-29 04:00:23 -04:00
|
|
|
if missing_sources or missing_targets:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"broken_edge",
|
|
|
|
|
"Relationships reference missing nodes",
|
|
|
|
|
sources=sorted(missing_sources),
|
|
|
|
|
targets=sorted(missing_targets),
|
|
|
|
|
)
|
2026-07-29 03:45:09 -04:00
|
|
|
for targets in dependencies.values():
|
|
|
|
|
targets.sort()
|
2026-07-22 01:29:32 -04:00
|
|
|
|
2026-07-29 04:00:23 -04:00
|
|
|
states: dict[str, int] = {}
|
|
|
|
|
for root in sorted(node_ids):
|
|
|
|
|
if states.get(root) == 2:
|
|
|
|
|
continue
|
|
|
|
|
path: list[str] = []
|
|
|
|
|
stack: list[tuple[str, int]] = [(root, 0)]
|
|
|
|
|
while stack:
|
|
|
|
|
node_id, child_index = stack[-1]
|
|
|
|
|
if states.get(node_id, 0) == 0:
|
|
|
|
|
states[node_id] = 1
|
|
|
|
|
path.append(node_id)
|
|
|
|
|
targets = dependencies[node_id]
|
|
|
|
|
if child_index < len(targets):
|
|
|
|
|
target = targets[child_index]
|
|
|
|
|
stack[-1] = (node_id, child_index + 1)
|
|
|
|
|
state = states.get(target, 0)
|
|
|
|
|
if state == 1:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"dependency_cycle",
|
|
|
|
|
"depends_on relationships contain a cycle",
|
|
|
|
|
path=(*path, target),
|
|
|
|
|
)
|
|
|
|
|
if state == 0:
|
|
|
|
|
stack.append((target, 0))
|
|
|
|
|
continue
|
|
|
|
|
stack.pop()
|
|
|
|
|
path.pop()
|
|
|
|
|
states[node_id] = 2
|
2026-07-22 01:29:32 -04:00
|
|
|
|
|
|
|
|
|
2026-07-22 11:50:49 -04:00
|
|
|
def validate_source_layout(nodes: tuple[Node, ...]) -> None:
|
|
|
|
|
"""Validate the generic Markdown and TOML source-layout contract."""
|
|
|
|
|
|
|
|
|
|
markdown_sources: dict[str, list[str]] = {}
|
|
|
|
|
anchors: dict[tuple[str, str], list[str]] = {}
|
|
|
|
|
for node in nodes:
|
|
|
|
|
if Path(node.source_path).suffix == ".md":
|
|
|
|
|
markdown_sources.setdefault(node.source_path, []).append(node.node_id)
|
|
|
|
|
continue
|
|
|
|
|
if not node.source_anchor:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_anchor_required",
|
|
|
|
|
"TOML nodes require a stable source anchor",
|
|
|
|
|
node_id=node.node_id,
|
|
|
|
|
)
|
|
|
|
|
anchors.setdefault((node.source_path, node.source_anchor), []).append(node.node_id)
|
|
|
|
|
markdown_conflicts = {
|
|
|
|
|
source: sorted(node_ids)
|
|
|
|
|
for source, node_ids in markdown_sources.items()
|
|
|
|
|
if len(node_ids) > 1
|
|
|
|
|
}
|
|
|
|
|
if markdown_conflicts:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_conflict",
|
|
|
|
|
"Markdown sources may contain only one node",
|
|
|
|
|
sources=markdown_conflicts,
|
|
|
|
|
)
|
|
|
|
|
anchor_conflicts = [
|
|
|
|
|
{"source": source, "source_anchor": anchor, "nodes": sorted(node_ids)}
|
|
|
|
|
for (source, anchor), node_ids in sorted(anchors.items())
|
|
|
|
|
if len(node_ids) > 1
|
|
|
|
|
]
|
|
|
|
|
if anchor_conflicts:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_anchor_conflict",
|
|
|
|
|
"TOML source anchors must be unique within their source",
|
|
|
|
|
conflicts=anchor_conflicts,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 01:29:32 -04:00
|
|
|
def _revision(root: Path) -> str:
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
["git", "rev-parse", "HEAD"],
|
|
|
|
|
cwd=root,
|
|
|
|
|
check=False,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=2,
|
|
|
|
|
)
|
|
|
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
|
|
|
return "unversioned"
|
|
|
|
|
return (
|
|
|
|
|
result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else "unversioned"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Project:
|
|
|
|
|
"""One immutable project binding for loading and querying canonical documentation."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, descriptor: ProjectDescriptor) -> None:
|
|
|
|
|
self.descriptor = descriptor
|
2026-07-29 04:00:23 -04:00
|
|
|
self._captured_generation: _CapturedGeneration | None = None
|
2026-07-22 01:29:32 -04:00
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def open(cls, project_root: str | Path) -> Project:
|
|
|
|
|
try:
|
|
|
|
|
root = Path(project_root).expanduser().resolve(strict=True)
|
|
|
|
|
except OSError as error:
|
|
|
|
|
raise DocForgeError("invalid_root", "Project root does not exist") from error
|
|
|
|
|
if not root.is_dir():
|
|
|
|
|
raise DocForgeError("invalid_root", "Project root must be a directory")
|
|
|
|
|
return cls(_load_descriptor(root))
|
|
|
|
|
|
|
|
|
|
def load(self) -> ProjectSnapshot:
|
2026-07-29 05:07:16 -04:00
|
|
|
increment("project_loads")
|
2026-07-22 01:29:32 -04:00
|
|
|
descriptor_bytes = self.descriptor.descriptor_path.read_bytes()
|
|
|
|
|
if hashlib.sha256(descriptor_bytes).hexdigest() != self.descriptor.descriptor_hash:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_changed", "Project descriptor changed after the project was opened"
|
|
|
|
|
)
|
2026-07-29 04:00:23 -04:00
|
|
|
ordered_sources, ordered_directories = self._canonical_inventory()
|
|
|
|
|
generation_paths = (
|
|
|
|
|
self.descriptor.descriptor_path,
|
|
|
|
|
*self.descriptor.authority_files,
|
|
|
|
|
*ordered_sources,
|
|
|
|
|
)
|
|
|
|
|
before_generation = _file_generation(self.descriptor.root, generation_paths)
|
|
|
|
|
before_directories = _directory_generation(
|
|
|
|
|
self.descriptor.root,
|
|
|
|
|
ordered_directories,
|
|
|
|
|
)
|
|
|
|
|
captured = {path: path.read_bytes() for path in generation_paths}
|
2026-07-22 01:29:32 -04:00
|
|
|
|
|
|
|
|
nodes: list[Node] = []
|
|
|
|
|
edges: list[Edge] = []
|
|
|
|
|
for path in ordered_sources:
|
2026-07-29 05:07:16 -04:00
|
|
|
raw = captured[path]
|
|
|
|
|
increment("source_files_parsed")
|
|
|
|
|
increment("source_bytes_parsed", len(raw))
|
|
|
|
|
with stage("source.parse"):
|
|
|
|
|
source_nodes, source_edges = _load_source_file(
|
|
|
|
|
self.descriptor,
|
|
|
|
|
path,
|
|
|
|
|
raw,
|
|
|
|
|
)
|
2026-07-22 01:29:32 -04:00
|
|
|
nodes.extend(source_nodes)
|
|
|
|
|
edges.extend(source_edges)
|
|
|
|
|
if len(nodes) > self.descriptor.limits.max_nodes:
|
|
|
|
|
raise DocForgeError("node_limit", "Project exceeds configured node limit")
|
|
|
|
|
ordered_nodes = tuple(sorted(nodes, key=lambda node: node.node_id))
|
|
|
|
|
ordered_edges = tuple(
|
|
|
|
|
sorted(edges, key=lambda edge: (edge.source_id, edge.relation, edge.target_id))
|
|
|
|
|
)
|
2026-07-22 02:58:51 -04:00
|
|
|
validate_graph(ordered_nodes, ordered_edges)
|
2026-07-22 01:29:32 -04:00
|
|
|
node_ids = {node.node_id for node in ordered_nodes}
|
|
|
|
|
for profile in self.descriptor.profiles:
|
|
|
|
|
missing = sorted(set(profile.required_nodes) - node_ids)
|
|
|
|
|
if missing:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_config", "Context profile requires missing nodes", nodes=missing
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-29 04:00:23 -04:00
|
|
|
current_sources, current_directories = self._canonical_inventory()
|
|
|
|
|
if current_sources != ordered_sources or current_directories != ordered_directories:
|
2026-07-22 01:29:32 -04:00
|
|
|
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:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_changed",
|
|
|
|
|
"Canonical source changed during loading",
|
|
|
|
|
source=path.relative_to(self.descriptor.root).as_posix(),
|
|
|
|
|
)
|
2026-07-29 04:00:23 -04:00
|
|
|
after_generation = _file_generation(self.descriptor.root, generation_paths)
|
|
|
|
|
after_directories = _directory_generation(
|
|
|
|
|
self.descriptor.root,
|
|
|
|
|
ordered_directories,
|
|
|
|
|
)
|
|
|
|
|
if after_generation != before_generation or after_directories != before_directories:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_changed",
|
|
|
|
|
"Canonical source metadata changed during loading",
|
|
|
|
|
)
|
2026-07-22 01:29:32 -04:00
|
|
|
|
|
|
|
|
digest = hashlib.sha256()
|
|
|
|
|
for path in sorted(
|
|
|
|
|
captured, key=lambda item: item.relative_to(self.descriptor.root).as_posix()
|
|
|
|
|
):
|
|
|
|
|
relative = path.relative_to(self.descriptor.root).as_posix()
|
|
|
|
|
digest.update(relative.encode())
|
|
|
|
|
digest.update(b"\0")
|
|
|
|
|
digest.update(hashlib.sha256(captured[path]).digest())
|
2026-07-29 04:00:23 -04:00
|
|
|
digest.update(GENERIC_SOURCE_CONTRACT.encode("ascii"))
|
|
|
|
|
source_hash = digest.hexdigest()
|
|
|
|
|
revision = _revision(self.descriptor.root)
|
|
|
|
|
snapshot = ProjectSnapshot(
|
2026-07-22 01:29:32 -04:00
|
|
|
descriptor=self.descriptor,
|
|
|
|
|
nodes=ordered_nodes,
|
|
|
|
|
edges=ordered_edges,
|
2026-07-29 04:00:23 -04:00
|
|
|
source_hash=source_hash,
|
|
|
|
|
revision=revision,
|
|
|
|
|
)
|
|
|
|
|
self._captured_generation = _CapturedGeneration(
|
|
|
|
|
source_hash=source_hash,
|
|
|
|
|
revision=revision,
|
|
|
|
|
files=after_generation,
|
|
|
|
|
directories=after_directories,
|
|
|
|
|
)
|
|
|
|
|
return snapshot
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def generation_path(self) -> Path:
|
|
|
|
|
"""Return the confined disposable receipt for one verified source generation."""
|
|
|
|
|
|
|
|
|
|
return self.descriptor.cache_root / "source-generation.json"
|
|
|
|
|
|
|
|
|
|
def incremental_state(self) -> ProjectState | None:
|
|
|
|
|
"""Return current source identity without reading or parsing canonical source bytes."""
|
|
|
|
|
|
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-29 04:00:23 -04:00
|
|
|
path = self.generation_path
|
|
|
|
|
if not path.is_file() or path.is_symlink():
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
parsed: object = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
|
|
|
return None
|
|
|
|
|
if not isinstance(parsed, dict):
|
|
|
|
|
return None
|
|
|
|
|
payload = cast(dict[str, object], parsed)
|
|
|
|
|
source_hash = payload.get("source_hash")
|
|
|
|
|
revision = payload.get("revision")
|
|
|
|
|
if (
|
|
|
|
|
payload.get("schema_version") != SOURCE_GENERATION_SCHEMA_VERSION
|
|
|
|
|
or payload.get("source_contract") != GENERIC_SOURCE_CONTRACT
|
|
|
|
|
or payload.get("project_id") != self.descriptor.project_id
|
|
|
|
|
or payload.get("project_root_fingerprint")
|
|
|
|
|
!= project_root_fingerprint(self.descriptor.root)
|
|
|
|
|
or payload.get("adapter") != self.descriptor.adapter
|
|
|
|
|
or not isinstance(source_hash, str)
|
|
|
|
|
or len(source_hash) != 64
|
|
|
|
|
or not isinstance(revision, str)
|
|
|
|
|
):
|
|
|
|
|
return None
|
|
|
|
|
directory_paths = _receipt_paths(
|
|
|
|
|
self.descriptor.root,
|
|
|
|
|
payload.get("directories"),
|
|
|
|
|
width=6,
|
2026-07-22 01:29:32 -04:00
|
|
|
)
|
2026-07-29 04:00:23 -04:00
|
|
|
file_paths = _receipt_paths(
|
|
|
|
|
self.descriptor.root,
|
|
|
|
|
payload.get("files"),
|
|
|
|
|
width=7,
|
|
|
|
|
)
|
|
|
|
|
if directory_paths is None or file_paths is None:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
current_directories = _directory_generation(self.descriptor.root, directory_paths)
|
|
|
|
|
except DocForgeError:
|
|
|
|
|
return None
|
|
|
|
|
if payload.get("directories") != [list(identity) for identity in current_directories]:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
current_files = _file_generation(self.descriptor.root, file_paths)
|
|
|
|
|
except DocForgeError:
|
|
|
|
|
return None
|
|
|
|
|
if payload.get("files") != [list(identity) for identity in current_files]:
|
|
|
|
|
return None
|
|
|
|
|
if _revision(self.descriptor.root) != revision:
|
|
|
|
|
return None
|
|
|
|
|
return ProjectState(source_hash=source_hash, revision=revision)
|
|
|
|
|
|
|
|
|
|
def record_generation(self, snapshot: ProjectSnapshot) -> None:
|
|
|
|
|
"""Persist a generation only after its complete derived index was verified."""
|
|
|
|
|
|
|
|
|
|
captured = self._captured_generation
|
|
|
|
|
if (
|
|
|
|
|
captured is None
|
|
|
|
|
or captured.source_hash != snapshot.source_hash
|
|
|
|
|
or captured.revision != snapshot.revision
|
|
|
|
|
):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_changed",
|
|
|
|
|
"Cannot record a source generation without a matching complete load",
|
|
|
|
|
)
|
|
|
|
|
root = self.descriptor.cache_root
|
|
|
|
|
path = self.generation_path
|
|
|
|
|
if path.parent != root or path.is_symlink() or root.resolve(strict=False) != root:
|
|
|
|
|
raise DocForgeError("path_escape", "Source generation receipt path is not safe")
|
|
|
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
if not root.is_dir() or root.resolve(strict=False) != root:
|
|
|
|
|
raise DocForgeError("path_escape", "Source generation receipt directory is not safe")
|
|
|
|
|
payload = {
|
|
|
|
|
"schema_version": SOURCE_GENERATION_SCHEMA_VERSION,
|
|
|
|
|
"source_contract": GENERIC_SOURCE_CONTRACT,
|
|
|
|
|
"project_id": self.descriptor.project_id,
|
|
|
|
|
"project_root_fingerprint": project_root_fingerprint(self.descriptor.root),
|
|
|
|
|
"adapter": self.descriptor.adapter,
|
|
|
|
|
"source_hash": captured.source_hash,
|
|
|
|
|
"revision": captured.revision,
|
|
|
|
|
"files": [list(identity) for identity in captured.files],
|
|
|
|
|
"directories": [list(identity) for identity in captured.directories],
|
|
|
|
|
}
|
|
|
|
|
raw = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
|
|
|
|
|
descriptor, temporary_name = tempfile.mkstemp(prefix=".source-generation-", dir=root)
|
|
|
|
|
temporary = Path(temporary_name)
|
|
|
|
|
try:
|
|
|
|
|
with os.fdopen(descriptor, "wb") as handle:
|
|
|
|
|
handle.write(raw)
|
|
|
|
|
handle.flush()
|
|
|
|
|
os.fsync(handle.fileno())
|
|
|
|
|
os.replace(temporary, path)
|
|
|
|
|
directory_descriptor = os.open(root, os.O_RDONLY)
|
|
|
|
|
try:
|
|
|
|
|
os.fsync(directory_descriptor)
|
|
|
|
|
finally:
|
|
|
|
|
os.close(directory_descriptor)
|
|
|
|
|
except Exception:
|
|
|
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
raise
|
2026-07-22 01:29:32 -04:00
|
|
|
|
2026-07-22 02:58:51 -04:00
|
|
|
def canonical_source_paths(self) -> tuple[Path, ...]:
|
|
|
|
|
"""Return the deterministic confined canonical source set."""
|
|
|
|
|
|
2026-07-29 04:00:23 -04:00
|
|
|
sources, _ = self._canonical_inventory()
|
|
|
|
|
return sources
|
|
|
|
|
|
|
|
|
|
def _canonical_inventory(self) -> tuple[tuple[Path, ...], tuple[Path, ...]]:
|
|
|
|
|
"""Return deterministic canonical files and membership-bearing directories."""
|
|
|
|
|
|
2026-07-22 01:29:32 -04:00
|
|
|
source_paths: set[Path] = set()
|
2026-07-29 04:00:23 -04:00
|
|
|
directories: set[Path] = set()
|
2026-07-22 01:29:32 -04:00
|
|
|
for content_root in self.descriptor.content_roots:
|
2026-07-29 04:00:23 -04:00
|
|
|
directories.add(content_root)
|
2026-07-22 01:29:32 -04:00
|
|
|
for path in content_root.rglob("*"):
|
2026-07-29 04:00:23 -04:00
|
|
|
if path.is_dir():
|
|
|
|
|
resolved_directory = path.resolve()
|
|
|
|
|
if not resolved_directory.is_relative_to(self.descriptor.root):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"path_escape",
|
|
|
|
|
"Canonical source directory resolves outside project root",
|
|
|
|
|
)
|
|
|
|
|
directories.add(resolved_directory)
|
|
|
|
|
continue
|
2026-07-22 01:29:32 -04:00
|
|
|
if path.suffix not in {".md", ".toml"} or not path.is_file():
|
|
|
|
|
continue
|
|
|
|
|
resolved = path.resolve()
|
|
|
|
|
if not resolved.is_relative_to(self.descriptor.root):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"path_escape", "Canonical source resolves outside project root"
|
|
|
|
|
)
|
|
|
|
|
source_paths.add(resolved)
|
|
|
|
|
ordered_sources = sorted(
|
|
|
|
|
source_paths, key=lambda path: path.relative_to(self.descriptor.root).as_posix()
|
|
|
|
|
)
|
|
|
|
|
if not ordered_sources:
|
|
|
|
|
raise DocForgeError("empty_project", "No canonical Markdown or TOML sources were found")
|
2026-07-29 04:00:23 -04:00
|
|
|
ordered_directories = sorted(
|
|
|
|
|
directories,
|
|
|
|
|
key=lambda path: path.relative_to(self.descriptor.root).as_posix(),
|
|
|
|
|
)
|
|
|
|
|
return tuple(ordered_sources), tuple(ordered_directories)
|
2026-07-22 11:50:49 -04:00
|
|
|
|
|
|
|
|
def validate_proposal(
|
|
|
|
|
self,
|
|
|
|
|
base: ProjectSnapshot,
|
|
|
|
|
projected: ProjectSnapshot,
|
|
|
|
|
operations: tuple[Mapping[str, object], ...],
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Generic sources require no policy beyond the core proposal validation."""
|
|
|
|
|
|
|
|
|
|
del base, operations
|
|
|
|
|
validate_source_layout(projected.nodes)
|