Add deterministic client integration diagnostics
This commit is contained in:
parent
eb9355b003
commit
fb0df5e4a1
15 changed files with 6193 additions and 18 deletions
|
|
@ -9,7 +9,9 @@ import webbrowser
|
|||
from pathlib import Path
|
||||
|
||||
from .application import CanonicalApplicationService, GenericCanonicalApplier
|
||||
from .client_config import CLIENT_NAMES, generate_client_configuration
|
||||
from .context import compile_context
|
||||
from .doctor import run_doctor
|
||||
from .errors import DocForgeError
|
||||
from .index import ProjectIndex
|
||||
from .onboarding import assess_project, scaffold_project
|
||||
|
|
@ -21,13 +23,33 @@ from .viewer_manager import ViewerManagerClient
|
|||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="docforge")
|
||||
parser.add_argument("--project-root", type=Path, required=True)
|
||||
parser.add_argument("--project-root", type=Path)
|
||||
parser.add_argument(
|
||||
"--diagnostics",
|
||||
action="store_true",
|
||||
help="Attach bounded request-local stage timings and counters",
|
||||
)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
configure = commands.add_parser("configure")
|
||||
configure.add_argument("client", choices=CLIENT_NAMES)
|
||||
configure.add_argument("--project", type=Path, required=True)
|
||||
configure.add_argument("--name")
|
||||
configure.add_argument(
|
||||
"--capability-mode",
|
||||
choices=("read", "proposal", "application"),
|
||||
default="read",
|
||||
)
|
||||
configure.add_argument("--proposal-writer")
|
||||
configure.add_argument("--canonical-applier")
|
||||
configure.add_argument("--no-ast", action="store_true")
|
||||
configure.add_argument("--startup-timeout", type=int, default=30)
|
||||
configure.add_argument("--tool-timeout", type=int, default=300)
|
||||
configure.add_argument("--output", type=Path)
|
||||
doctor = commands.add_parser("doctor")
|
||||
doctor.add_argument("--client", choices=CLIENT_NAMES, required=True)
|
||||
doctor.add_argument("--project", type=Path)
|
||||
doctor.add_argument("--config", type=Path)
|
||||
doctor.add_argument("--server-name")
|
||||
onboard = commands.add_parser("onboard")
|
||||
onboard.add_argument("--language", action="append", default=[])
|
||||
onboard.add_argument("--scaffold", action="store_true")
|
||||
|
|
@ -92,6 +114,33 @@ def _parser() -> argparse.ArgumentParser:
|
|||
|
||||
|
||||
def _run(arguments: argparse.Namespace) -> dict[str, object]:
|
||||
if arguments.command == "configure":
|
||||
project = Project.open(arguments.project)
|
||||
return generate_client_configuration(
|
||||
project,
|
||||
arguments.client,
|
||||
server_name=arguments.name,
|
||||
capability_mode=arguments.capability_mode,
|
||||
proposal_writer=arguments.proposal_writer,
|
||||
canonical_applier=arguments.canonical_applier,
|
||||
no_ast=arguments.no_ast,
|
||||
startup_timeout=arguments.startup_timeout,
|
||||
tool_timeout=arguments.tool_timeout,
|
||||
output=arguments.output,
|
||||
)
|
||||
if arguments.command == "doctor":
|
||||
root = arguments.project or arguments.project_root or Path.cwd()
|
||||
return run_doctor(
|
||||
Project.open(root),
|
||||
arguments.client,
|
||||
config_path=arguments.config,
|
||||
server_name=arguments.server_name,
|
||||
)
|
||||
if arguments.project_root is None:
|
||||
raise DocForgeError(
|
||||
"missing_project_root",
|
||||
"This command requires --project-root",
|
||||
)
|
||||
if arguments.command == "onboard":
|
||||
languages = tuple(arguments.language)
|
||||
if arguments.scaffold:
|
||||
|
|
@ -250,13 +299,14 @@ def main(argv: list[str] | None = None) -> int:
|
|||
) as collector:
|
||||
try:
|
||||
result = _run(arguments)
|
||||
code = 0
|
||||
doctor_state = result.get("doctor_state")
|
||||
code = 2 if doctor_state == "unhealthy" else (1 if doctor_state == "degraded" else 0)
|
||||
except DocForgeError as error:
|
||||
result = {"status": "error", "error": error.as_dict()}
|
||||
code = 2
|
||||
if collector is not None:
|
||||
result["diagnostics"] = collector.as_dict(
|
||||
outcome="ok" if code == 0 else "error",
|
||||
outcome="ok" if result.get("status") == "ok" else "error",
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True, indent=2))
|
||||
return code
|
||||
|
|
|
|||
937
src/docforge/client_config.py
Normal file
937
src/docforge/client_config.py
Normal file
|
|
@ -0,0 +1,937 @@
|
|||
"""Deterministic, explicit client-configuration plans for DocForge MCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
from .changeset_contract import document_hash
|
||||
from .errors import DocForgeError
|
||||
from .models import ProjectService
|
||||
from .policy import CapabilityMode, compose_effective_policy
|
||||
from .project import project_root_fingerprint, validate_descriptor_binding
|
||||
|
||||
ClientName = Literal["codex", "claude", "openclaw"]
|
||||
CLIENT_NAMES: tuple[ClientName, ...] = ("codex", "claude", "openclaw")
|
||||
MAX_CLIENT_FRAGMENT_BYTES = 1_000_000
|
||||
GENERATED_CAPABILITY_MODES: tuple[CapabilityMode, ...] = (
|
||||
"read",
|
||||
"proposal",
|
||||
"application",
|
||||
)
|
||||
_SERVER_NAME = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FileIdentity:
|
||||
device: int
|
||||
inode: int
|
||||
mode: int
|
||||
size: int
|
||||
mtime_ns: int
|
||||
ctime_ns: int
|
||||
uid: int
|
||||
link_count: int
|
||||
|
||||
|
||||
def _file_identity(status: os.stat_result) -> _FileIdentity:
|
||||
return _FileIdentity(
|
||||
device=status.st_dev,
|
||||
inode=status.st_ino,
|
||||
mode=status.st_mode,
|
||||
size=status.st_size,
|
||||
mtime_ns=status.st_mtime_ns,
|
||||
ctime_ns=status.st_ctime_ns,
|
||||
uid=status.st_uid,
|
||||
link_count=status.st_nlink,
|
||||
)
|
||||
|
||||
|
||||
def _client_name(value: str) -> ClientName:
|
||||
if value not in CLIENT_NAMES:
|
||||
raise DocForgeError(
|
||||
"unsupported_client",
|
||||
"Client configuration target is unsupported",
|
||||
client=value,
|
||||
allowed=list(CLIENT_NAMES),
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _capability_mode(value: str) -> CapabilityMode:
|
||||
if value not in GENERATED_CAPABILITY_MODES:
|
||||
raise DocForgeError(
|
||||
"invalid_capability_mode",
|
||||
"Generated configuration supports read, proposal, or application mode",
|
||||
capability_mode=value,
|
||||
allowed=list(GENERATED_CAPABILITY_MODES),
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_seconds(value: int, *, field: str, maximum: int) -> int:
|
||||
if type(value) is not int or value < 1 or value > maximum:
|
||||
raise DocForgeError(
|
||||
"invalid_timeout",
|
||||
"Client timeout is outside the supported range",
|
||||
field=field,
|
||||
minimum=1,
|
||||
maximum=maximum,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _default_server_name(project_id: str, fingerprint: str) -> str:
|
||||
prefix = re.sub(r"[^a-z0-9_-]+", "-", project_id.lower()).strip("-_")
|
||||
prefix = prefix or "project"
|
||||
suffix = f"-{fingerprint}"
|
||||
available = 64 - len("docforge-") - len(suffix)
|
||||
return f"docforge-{prefix[:available]}{suffix}"
|
||||
|
||||
|
||||
def _validated_server_name(value: str | None, *, project_id: str, fingerprint: str) -> str:
|
||||
selected = value or _default_server_name(project_id, fingerprint)
|
||||
if _SERVER_NAME.fullmatch(selected) is None:
|
||||
raise DocForgeError(
|
||||
"invalid_server_name",
|
||||
"Generated server name must be a stable lowercase client identifier",
|
||||
pattern=_SERVER_NAME.pattern,
|
||||
maximum_length=64,
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def _toml_string(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _toml_array(values: list[str]) -> str:
|
||||
return "[" + ", ".join(_toml_string(value) for value in values) + "]"
|
||||
|
||||
|
||||
def _artifact(
|
||||
client: ClientName,
|
||||
*,
|
||||
server_name: str,
|
||||
command: str,
|
||||
arguments: list[str],
|
||||
startup_timeout: int,
|
||||
tool_timeout: int,
|
||||
) -> tuple[str, str, str | None]:
|
||||
if client == "codex":
|
||||
content = "\n".join(
|
||||
(
|
||||
f'[mcp_servers."{server_name}"]',
|
||||
f"command = {_toml_string(command)}",
|
||||
f"args = {_toml_array(arguments)}",
|
||||
"env = {}",
|
||||
f"startup_timeout_sec = {startup_timeout}",
|
||||
f"tool_timeout_sec = {tool_timeout}",
|
||||
"",
|
||||
)
|
||||
)
|
||||
return "codex-toml-fragment-v1", content, None
|
||||
if client == "openclaw":
|
||||
content = (
|
||||
json.dumps(
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
server_name: {
|
||||
"args": arguments,
|
||||
"command": command,
|
||||
"connectTimeout": startup_timeout,
|
||||
"env": {},
|
||||
"supportsParallelToolCalls": False,
|
||||
"timeout": tool_timeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
return "openclaw-json-fragment-v1", content, None
|
||||
content = (
|
||||
json.dumps(
|
||||
{
|
||||
"mcpServers": {
|
||||
server_name: {
|
||||
"args": arguments,
|
||||
"command": command,
|
||||
"env": {},
|
||||
}
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
return (
|
||||
"claude-json-fragment-v1",
|
||||
content,
|
||||
"Claude per-server timeout representation is not yet verified.",
|
||||
)
|
||||
|
||||
|
||||
def _signature(
|
||||
directory_fd: int,
|
||||
name: str,
|
||||
) -> _FileIdentity | None:
|
||||
try:
|
||||
status = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"unsafe_output",
|
||||
"Configuration output cannot be inspected safely",
|
||||
) from error
|
||||
if stat.S_ISLNK(status.st_mode) or not stat.S_ISREG(status.st_mode):
|
||||
raise DocForgeError(
|
||||
"unsafe_output",
|
||||
"Configuration output must be a regular file and not a symbolic link",
|
||||
)
|
||||
return _file_identity(status)
|
||||
|
||||
|
||||
def _parent_binding_current(path: Path, directory_fd: int) -> bool:
|
||||
try:
|
||||
before = path.lstat()
|
||||
resolved = path.resolve(strict=True)
|
||||
after = path.lstat()
|
||||
opened = os.fstat(directory_fd)
|
||||
return (
|
||||
not stat.S_ISLNK(before.st_mode)
|
||||
and stat.S_ISDIR(before.st_mode)
|
||||
and resolved == path
|
||||
and (before.st_dev, before.st_ino, before.st_mode)
|
||||
== (after.st_dev, after.st_ino, after.st_mode)
|
||||
== (opened.st_dev, opened.st_ino, opened.st_mode)
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _require_parent_binding(path: Path, directory_fd: int) -> None:
|
||||
if not _parent_binding_current(path, directory_fd):
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output parent changed during publication",
|
||||
)
|
||||
|
||||
|
||||
def _bound_parent(path: Path) -> tuple[Path, int]:
|
||||
absolute = Path(os.path.abspath(path.expanduser()))
|
||||
parent = absolute.parent
|
||||
try:
|
||||
parent_status = parent.lstat()
|
||||
resolved = parent.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"invalid_output",
|
||||
"Configuration output parent does not exist",
|
||||
) from error
|
||||
if (
|
||||
stat.S_ISLNK(parent_status.st_mode)
|
||||
or not stat.S_ISDIR(parent_status.st_mode)
|
||||
or resolved != parent
|
||||
or absolute.name in {"", ".", ".."}
|
||||
):
|
||||
raise DocForgeError(
|
||||
"unsafe_output",
|
||||
"Configuration output parent must be one real non-symlinked directory",
|
||||
)
|
||||
try:
|
||||
directory_fd = os.open(
|
||||
parent,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"unsafe_output",
|
||||
"Configuration output parent cannot be opened safely",
|
||||
) from error
|
||||
opened = os.fstat(directory_fd)
|
||||
if opened.st_dev != parent_status.st_dev or opened.st_ino != parent_status.st_ino:
|
||||
with suppress(OSError):
|
||||
os.close(directory_fd)
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output parent changed while it was opened",
|
||||
)
|
||||
return absolute, directory_fd
|
||||
|
||||
|
||||
def _read_existing(
|
||||
directory_fd: int,
|
||||
name: str,
|
||||
signature: _FileIdentity,
|
||||
) -> bytes:
|
||||
if signature.size > MAX_CLIENT_FRAGMENT_BYTES:
|
||||
raise DocForgeError(
|
||||
"output_oversized",
|
||||
"Existing configuration output exceeds the bounded fragment limit",
|
||||
maximum_bytes=MAX_CLIENT_FRAGMENT_BYTES,
|
||||
)
|
||||
try:
|
||||
descriptor = os.open(
|
||||
name,
|
||||
os.O_RDONLY | os.O_NOFOLLOW,
|
||||
dir_fd=directory_fd,
|
||||
)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"unsafe_output",
|
||||
"Configuration output cannot be opened safely",
|
||||
) from error
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
opened_signature = _file_identity(opened)
|
||||
if opened_signature != signature:
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output changed while it was opened",
|
||||
)
|
||||
remaining = MAX_CLIENT_FRAGMENT_BYTES + 1
|
||||
chunks: list[bytes] = []
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(65_536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
raw = b"".join(chunks)
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
os.close(descriptor)
|
||||
if len(raw) > MAX_CLIENT_FRAGMENT_BYTES or _signature(directory_fd, name) != signature:
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output changed while it was read",
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _private_existing(identity: _FileIdentity) -> bool:
|
||||
return (
|
||||
identity.uid == os.geteuid()
|
||||
and stat.S_IMODE(identity.mode) & 0o077 == 0
|
||||
and identity.link_count == 1
|
||||
)
|
||||
|
||||
|
||||
def _rollback_link(
|
||||
directory_fd: int,
|
||||
name: str,
|
||||
expected: _FileIdentity,
|
||||
) -> bool:
|
||||
try:
|
||||
current = _signature(directory_fd, name)
|
||||
if current is None:
|
||||
return True
|
||||
if current.device != expected.device or current.inode != expected.inode:
|
||||
return True
|
||||
os.unlink(name, dir_fd=directory_fd)
|
||||
return True
|
||||
except (DocForgeError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _rollback_and_sync(
|
||||
directory_fd: int,
|
||||
name: str,
|
||||
expected: _FileIdentity,
|
||||
) -> bool:
|
||||
if not _rollback_link(directory_fd, name, expected):
|
||||
return False
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _atomic_write(
|
||||
path: Path,
|
||||
content: str,
|
||||
*,
|
||||
validate_binding: Callable[[], None],
|
||||
) -> tuple[str, str, str | None, Path | None]:
|
||||
target, directory_fd = _bound_parent(path)
|
||||
encoded = content.encode("utf-8")
|
||||
if len(encoded) > MAX_CLIENT_FRAGMENT_BYTES:
|
||||
with suppress(OSError):
|
||||
os.close(directory_fd)
|
||||
raise DocForgeError(
|
||||
"output_oversized",
|
||||
"Generated configuration fragment exceeds the bounded limit",
|
||||
maximum_bytes=MAX_CLIENT_FRAGMENT_BYTES,
|
||||
)
|
||||
temporary_name = f".docforge-client-{secrets.token_hex(12)}"
|
||||
temporary_created = False
|
||||
committed = False
|
||||
linked_identity: _FileIdentity | None = None
|
||||
try:
|
||||
_require_parent_binding(target.parent, directory_fd)
|
||||
before = _signature(directory_fd, target.name)
|
||||
if before is not None:
|
||||
if not _private_existing(before):
|
||||
raise DocForgeError(
|
||||
"unsafe_output",
|
||||
(
|
||||
"Existing configuration fragment must be owned by the current user, "
|
||||
"private, and singly linked"
|
||||
),
|
||||
)
|
||||
existing = _read_existing(directory_fd, target.name, before)
|
||||
if existing == encoded:
|
||||
validate_binding()
|
||||
_require_parent_binding(target.parent, directory_fd)
|
||||
current = _signature(directory_fd, target.name)
|
||||
if (
|
||||
current != before
|
||||
or current is None
|
||||
or not _private_existing(current)
|
||||
or _read_existing(directory_fd, target.name, current) != encoded
|
||||
):
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output changed before unchanged publication was confirmed",
|
||||
)
|
||||
validate_binding()
|
||||
_require_parent_binding(target.parent, directory_fd)
|
||||
return "unchanged", "not_applicable", None, target
|
||||
raise DocForgeError(
|
||||
"output_conflict",
|
||||
"Configuration fragment already exists with different content",
|
||||
existing_sha256=hashlib.sha256(existing).hexdigest(),
|
||||
generated_sha256=hashlib.sha256(encoded).hexdigest(),
|
||||
)
|
||||
|
||||
try:
|
||||
temporary_fd = os.open(
|
||||
temporary_name,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600,
|
||||
dir_fd=directory_fd,
|
||||
)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"output_publication_failed",
|
||||
"Configuration fragment temporary file could not be created",
|
||||
) from error
|
||||
temporary_created = True
|
||||
try:
|
||||
with os.fdopen(temporary_fd, "wb", closefd=True) as handle:
|
||||
handle.write(encoded)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"output_publication_failed",
|
||||
"Configuration fragment temporary file could not be written durably",
|
||||
) from error
|
||||
temporary_identity = _signature(directory_fd, temporary_name)
|
||||
if temporary_identity is None:
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration fragment temporary file disappeared before publication",
|
||||
)
|
||||
if _signature(directory_fd, target.name) is not None:
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output appeared before atomic publication",
|
||||
)
|
||||
_require_parent_binding(target.parent, directory_fd)
|
||||
validate_binding()
|
||||
try:
|
||||
os.link(
|
||||
temporary_name,
|
||||
target.name,
|
||||
src_dir_fd=directory_fd,
|
||||
dst_dir_fd=directory_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except FileExistsError as error:
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output appeared during atomic publication",
|
||||
) from error
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"output_publication_failed",
|
||||
"Configuration fragment could not be published atomically",
|
||||
) from error
|
||||
linked_identity = temporary_identity
|
||||
try:
|
||||
validate_binding()
|
||||
except DocForgeError:
|
||||
if _rollback_link(directory_fd, target.name, temporary_identity):
|
||||
raise
|
||||
committed = True
|
||||
return (
|
||||
"created",
|
||||
"unconfirmed",
|
||||
"publication_binding_unconfirmed",
|
||||
None,
|
||||
)
|
||||
linked = _signature(directory_fd, target.name)
|
||||
if (
|
||||
linked is None
|
||||
or linked.device != temporary_identity.device
|
||||
or linked.inode != temporary_identity.inode
|
||||
or _read_existing(directory_fd, target.name, linked) != encoded
|
||||
or not _parent_binding_current(target.parent, directory_fd)
|
||||
):
|
||||
if _rollback_link(directory_fd, target.name, temporary_identity):
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output changed during atomic publication",
|
||||
)
|
||||
committed = True
|
||||
return (
|
||||
"created",
|
||||
"unconfirmed",
|
||||
"publication_location_unconfirmed",
|
||||
None,
|
||||
)
|
||||
durability = "confirmed"
|
||||
warning: str | None = None
|
||||
try:
|
||||
os.unlink(temporary_name, dir_fd=directory_fd)
|
||||
temporary_created = False
|
||||
published = _signature(directory_fd, target.name)
|
||||
if (
|
||||
published is None
|
||||
or not _private_existing(published)
|
||||
or published.device != temporary_identity.device
|
||||
or published.inode != temporary_identity.inode
|
||||
or _read_existing(directory_fd, target.name, published) != encoded
|
||||
or not _parent_binding_current(target.parent, directory_fd)
|
||||
):
|
||||
if _rollback_link(directory_fd, target.name, temporary_identity):
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output changed after atomic publication",
|
||||
)
|
||||
committed = True
|
||||
return (
|
||||
"created",
|
||||
"unconfirmed",
|
||||
"publication_location_unconfirmed",
|
||||
None,
|
||||
)
|
||||
try:
|
||||
validate_binding()
|
||||
except DocForgeError:
|
||||
if _rollback_link(directory_fd, target.name, temporary_identity):
|
||||
raise
|
||||
committed = True
|
||||
return (
|
||||
"created",
|
||||
"unconfirmed",
|
||||
"publication_binding_unconfirmed",
|
||||
None,
|
||||
)
|
||||
os.fsync(directory_fd)
|
||||
except OSError:
|
||||
durability = "unconfirmed"
|
||||
warning = "publication_durability_unconfirmed"
|
||||
try:
|
||||
validate_binding()
|
||||
except DocForgeError:
|
||||
if _rollback_and_sync(directory_fd, target.name, temporary_identity):
|
||||
raise
|
||||
committed = True
|
||||
return (
|
||||
"created",
|
||||
"unconfirmed",
|
||||
"publication_binding_unconfirmed",
|
||||
None,
|
||||
)
|
||||
try:
|
||||
published = _signature(directory_fd, target.name)
|
||||
publication_current = (
|
||||
published is not None
|
||||
and _private_existing(published)
|
||||
and published.device == temporary_identity.device
|
||||
and published.inode == temporary_identity.inode
|
||||
and _read_existing(directory_fd, target.name, published) == encoded
|
||||
and _parent_binding_current(target.parent, directory_fd)
|
||||
)
|
||||
except DocForgeError:
|
||||
publication_current = False
|
||||
if not publication_current:
|
||||
if _rollback_and_sync(directory_fd, target.name, temporary_identity):
|
||||
raise DocForgeError(
|
||||
"output_changed",
|
||||
"Configuration output changed before publication was finalized",
|
||||
)
|
||||
committed = True
|
||||
return (
|
||||
"created",
|
||||
"unconfirmed",
|
||||
"publication_location_unconfirmed",
|
||||
None,
|
||||
)
|
||||
committed = True
|
||||
return "created", durability, warning, target
|
||||
except Exception:
|
||||
if not committed and linked_identity is not None:
|
||||
_rollback_link(directory_fd, target.name, linked_identity)
|
||||
if not committed and temporary_created:
|
||||
with suppress(OSError):
|
||||
os.unlink(temporary_name, dir_fd=directory_fd)
|
||||
raise
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def _validate_configuration_result(result: dict[str, object]) -> None:
|
||||
artifact = cast(dict[str, object], result["artifact"])
|
||||
binding = cast(dict[str, object], result["binding"])
|
||||
policy = cast(dict[str, object], result["effective_policy"])
|
||||
project = cast(dict[str, object], result["project"])
|
||||
content = cast(str, artifact["content"])
|
||||
if artifact["content_sha256"] != hashlib.sha256(content.encode("utf-8")).hexdigest():
|
||||
raise AssertionError("Generated client content hash drifted")
|
||||
timeouts = cast(dict[str, object], binding["timeouts"])
|
||||
expected_format, expected_content, _ = _artifact(
|
||||
cast(ClientName, result["client"]),
|
||||
server_name=cast(str, result["server_name"]),
|
||||
command=cast(str, binding["command"]),
|
||||
arguments=cast(list[str], binding["args"]),
|
||||
startup_timeout=cast(int, timeouts["startup_seconds"]),
|
||||
tool_timeout=cast(int, timeouts["tool_seconds"]),
|
||||
)
|
||||
if artifact["format"] != expected_format or content != expected_content:
|
||||
raise AssertionError("Generated client artifact drifted from its binding")
|
||||
adapter_policy = cast(dict[str, object], binding["adapter_policy"])
|
||||
render_policy = cast(dict[str, object], binding["render_policy"])
|
||||
arguments = cast(list[str], binding["args"])
|
||||
prefix = [
|
||||
"-I",
|
||||
"-m",
|
||||
"docforge.mcp_server",
|
||||
"--project-root",
|
||||
cast(str, project["project_root"]),
|
||||
"--capability-mode",
|
||||
cast(str, binding["capability_mode"]),
|
||||
]
|
||||
if arguments[:7] != prefix:
|
||||
raise AssertionError("Generated client arguments drifted from their binding")
|
||||
remaining = arguments[7:]
|
||||
no_ast_argument = "--no-ast" in arguments
|
||||
if no_ast_argument:
|
||||
if remaining[-1:] != ["--no-ast"] or arguments.count("--no-ast") != 1:
|
||||
raise AssertionError("Generated no-AST argument layout drifted")
|
||||
remaining = remaining[:-1]
|
||||
mode = binding["capability_mode"]
|
||||
if (
|
||||
(mode == "read" and remaining)
|
||||
or (
|
||||
mode == "proposal"
|
||||
and (len(remaining) != 2 or remaining[0] != "--proposal-writer" or not remaining[1])
|
||||
)
|
||||
or (
|
||||
mode == "application"
|
||||
and (
|
||||
len(remaining) != 4
|
||||
or remaining[0] != "--proposal-writer"
|
||||
or remaining[2] != "--canonical-applier"
|
||||
or not remaining[1]
|
||||
or remaining[1] != remaining[3]
|
||||
)
|
||||
)
|
||||
):
|
||||
raise AssertionError("Generated authority argument layout drifted")
|
||||
composed_policy = compose_effective_policy(
|
||||
selected_mode=cast(CapabilityMode, mode),
|
||||
capability_source="explicit",
|
||||
no_ast=adapter_policy["mode"] == "preserve-no-ast",
|
||||
diagnostics=False,
|
||||
render_configured=render_policy["manual"] != "disabled",
|
||||
application_enabled=mode == "application",
|
||||
)
|
||||
expected_policy = composed_policy.as_dict()
|
||||
if (
|
||||
policy != expected_policy
|
||||
or adapter_policy != composed_policy.adapter_policy()
|
||||
or binding["capability_mode"] != policy["capability_mode"]
|
||||
or no_ast_argument != (adapter_policy["mode"] == "preserve-no-ast")
|
||||
or render_policy["manual"] != policy["manual_render"]
|
||||
or render_policy["graph"] != policy["graph_render"]
|
||||
or render_policy["live_viewer"] != policy["live_viewer"]
|
||||
or (
|
||||
adapter_policy["mode"] == "preserve-no-ast"
|
||||
and (
|
||||
policy["adapter_evolution"] != "preserve"
|
||||
or policy["ast_analysis"] != "forbidden"
|
||||
or policy["logic_indexing"] != "off"
|
||||
)
|
||||
)
|
||||
or (
|
||||
adapter_policy["mode"] == "standard"
|
||||
and (
|
||||
policy["adapter_evolution"] != "allowed"
|
||||
or policy["ast_analysis"] != "allowed"
|
||||
or policy["logic_indexing"] != "full"
|
||||
)
|
||||
)
|
||||
):
|
||||
raise AssertionError("Generated client policy drifted from its binding")
|
||||
expected_hash = document_hash(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"client": result["client"],
|
||||
"server_name": result["server_name"],
|
||||
"project": project,
|
||||
"binding": binding,
|
||||
"effective_policy": policy,
|
||||
"artifact_format": artifact["format"],
|
||||
"artifact_content_sha256": artifact["content_sha256"],
|
||||
}
|
||||
)
|
||||
if result["configuration_hash"] != expected_hash:
|
||||
raise AssertionError("Generated client configuration hash drifted")
|
||||
|
||||
|
||||
def generate_client_configuration(
|
||||
project: ProjectService,
|
||||
client: str,
|
||||
*,
|
||||
server_name: str | None = None,
|
||||
capability_mode: str = "read",
|
||||
proposal_writer: str | None = None,
|
||||
canonical_applier: str | None = None,
|
||||
no_ast: bool = False,
|
||||
startup_timeout: int = 30,
|
||||
tool_timeout: int = 300,
|
||||
output: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build one deterministic client fragment and optionally publish it explicitly."""
|
||||
|
||||
validate_descriptor_binding(project.descriptor)
|
||||
selected_client = _client_name(client)
|
||||
selected_mode = _capability_mode(capability_mode)
|
||||
startup_seconds = _bounded_seconds(
|
||||
startup_timeout,
|
||||
field="startup_timeout",
|
||||
maximum=3_600,
|
||||
)
|
||||
tool_seconds = _bounded_seconds(
|
||||
tool_timeout,
|
||||
field="tool_timeout",
|
||||
maximum=86_400,
|
||||
)
|
||||
descriptor = project.descriptor
|
||||
if descriptor.adapter != "generic":
|
||||
raise DocForgeError(
|
||||
"client_configuration_unavailable",
|
||||
"Generic CLI configuration cannot reconstruct a project-owned adapter",
|
||||
adapter=descriptor.adapter,
|
||||
)
|
||||
writer_ids = {writer.writer_id for writer in descriptor.proposal_writers}
|
||||
if selected_mode == "read":
|
||||
if proposal_writer is not None or canonical_applier is not None:
|
||||
raise DocForgeError(
|
||||
"invalid_capability_binding",
|
||||
"Read configuration cannot bind proposal or application authority",
|
||||
)
|
||||
elif selected_mode == "proposal":
|
||||
if proposal_writer is None or proposal_writer not in writer_ids:
|
||||
raise DocForgeError(
|
||||
"capability_unavailable",
|
||||
"Proposal configuration requires a descriptor-declared writer",
|
||||
required="proposal_writer",
|
||||
)
|
||||
if canonical_applier is not None:
|
||||
raise DocForgeError(
|
||||
"invalid_capability_binding",
|
||||
"Proposal configuration cannot bind a canonical applier",
|
||||
)
|
||||
else:
|
||||
if (
|
||||
proposal_writer is None
|
||||
or canonical_applier is None
|
||||
or proposal_writer != canonical_applier
|
||||
or proposal_writer not in writer_ids
|
||||
):
|
||||
raise DocForgeError(
|
||||
"capability_unavailable",
|
||||
"Application configuration requires one declared writer/applier identity",
|
||||
required="matching_declared_writer_and_applier",
|
||||
)
|
||||
|
||||
fingerprint = project_root_fingerprint(descriptor.root)
|
||||
selected_name = _validated_server_name(
|
||||
server_name,
|
||||
project_id=descriptor.project_id,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
executable = Path(os.path.abspath(sys.executable))
|
||||
try:
|
||||
executable_status = executable.stat()
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"client_configuration_unavailable",
|
||||
"Current Python executable cannot be inspected",
|
||||
) from error
|
||||
if not stat.S_ISREG(executable_status.st_mode) or not os.access(executable, os.X_OK):
|
||||
raise DocForgeError(
|
||||
"client_configuration_unavailable",
|
||||
"Current Python executable is not a runnable regular file",
|
||||
)
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[
|
||||
str(executable),
|
||||
"-I",
|
||||
"-B",
|
||||
"-c",
|
||||
"import docforge.mcp_server",
|
||||
],
|
||||
check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
raise DocForgeError(
|
||||
"client_configuration_unavailable",
|
||||
"Current isolated Python executable could not be probed safely",
|
||||
) from error
|
||||
if probe.returncode != 0:
|
||||
raise DocForgeError(
|
||||
"client_configuration_unavailable",
|
||||
"Current isolated Python executable cannot import docforge.mcp_server",
|
||||
)
|
||||
arguments = [
|
||||
"-I",
|
||||
"-m",
|
||||
"docforge.mcp_server",
|
||||
"--project-root",
|
||||
str(descriptor.root),
|
||||
"--capability-mode",
|
||||
selected_mode,
|
||||
]
|
||||
if proposal_writer is not None:
|
||||
arguments.extend(("--proposal-writer", proposal_writer))
|
||||
if canonical_applier is not None:
|
||||
arguments.extend(("--canonical-applier", canonical_applier))
|
||||
if no_ast:
|
||||
arguments.append("--no-ast")
|
||||
|
||||
policy = compose_effective_policy(
|
||||
selected_mode=selected_mode,
|
||||
capability_source="explicit",
|
||||
no_ast=no_ast,
|
||||
diagnostics=False,
|
||||
render_configured=descriptor.render is not None,
|
||||
application_enabled=canonical_applier is not None,
|
||||
)
|
||||
artifact_format, content, warning = _artifact(
|
||||
selected_client,
|
||||
server_name=selected_name,
|
||||
command=str(executable),
|
||||
arguments=arguments,
|
||||
startup_timeout=startup_seconds,
|
||||
tool_timeout=tool_seconds,
|
||||
)
|
||||
if output is None:
|
||||
validate_descriptor_binding(descriptor)
|
||||
write_state = "not_requested"
|
||||
durability = "not_applicable"
|
||||
publication_warning = None
|
||||
output_path = None
|
||||
else:
|
||||
validate_descriptor_binding(descriptor)
|
||||
write_state, durability, publication_warning, published_path = _atomic_write(
|
||||
output,
|
||||
content,
|
||||
validate_binding=lambda: validate_descriptor_binding(descriptor),
|
||||
)
|
||||
output_path = str(published_path) if published_path is not None else None
|
||||
artifact = {
|
||||
"format": artifact_format,
|
||||
"content": content,
|
||||
"content_sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||
"output_path": output_path,
|
||||
"write_state": write_state,
|
||||
"durability": durability,
|
||||
}
|
||||
binding = {
|
||||
"transport": "stdio",
|
||||
"capability_mode": selected_mode,
|
||||
"adapter_policy": policy.adapter_policy(),
|
||||
"render_policy": {
|
||||
"manual": policy.manual_render,
|
||||
"graph": policy.graph_render,
|
||||
"live_viewer": policy.live_viewer,
|
||||
},
|
||||
"command": str(executable),
|
||||
"args": arguments,
|
||||
"environment": {},
|
||||
"timeouts": {
|
||||
"startup_seconds": startup_seconds,
|
||||
"tool_seconds": tool_seconds,
|
||||
},
|
||||
}
|
||||
project_binding = {
|
||||
"project_id": descriptor.project_id,
|
||||
"project_root": str(descriptor.root),
|
||||
"project_root_fingerprint": fingerprint,
|
||||
"adapter": descriptor.adapter,
|
||||
}
|
||||
policy_payload = policy.as_dict()
|
||||
plan_hash = document_hash(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"client": selected_client,
|
||||
"server_name": selected_name,
|
||||
"project": project_binding,
|
||||
"binding": binding,
|
||||
"effective_policy": policy_payload,
|
||||
"artifact_format": artifact_format,
|
||||
"artifact_content_sha256": artifact["content_sha256"],
|
||||
}
|
||||
)
|
||||
result: dict[str, object] = {
|
||||
"status": "ok",
|
||||
"schema_version": 1,
|
||||
"operation": "client.configure",
|
||||
"action": "write" if output is not None else "preview",
|
||||
"client": selected_client,
|
||||
"server_name": selected_name,
|
||||
"project": project_binding,
|
||||
"binding": binding,
|
||||
"effective_policy": policy_payload,
|
||||
"artifact": artifact,
|
||||
"configuration_hash": plan_hash,
|
||||
"warnings": [
|
||||
*([] if warning is None else [{"code": "timeout_format_unverified"}]),
|
||||
*([] if publication_warning is None else [{"code": publication_warning}]),
|
||||
],
|
||||
}
|
||||
_validate_configuration_result(result)
|
||||
return result
|
||||
1287
src/docforge/doctor.py
Normal file
1287
src/docforge/doctor.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ import tempfile
|
|||
import tomllib
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, cast
|
||||
|
|
@ -39,6 +40,7 @@ from .telemetry import increment, stage
|
|||
|
||||
SOURCE_GENERATION_SCHEMA_VERSION = 1
|
||||
GENERIC_SOURCE_CONTRACT = "docforge-core:0.7.1:index:1"
|
||||
MAX_PROJECT_DESCRIPTOR_BYTES = 1_000_000
|
||||
|
||||
_CORE_METADATA = frozenset(
|
||||
{
|
||||
|
|
@ -210,12 +212,167 @@ def _receipt_signature(path: Path) -> tuple[int, int, int, int, int] | None:
|
|||
)
|
||||
|
||||
|
||||
def _read_descriptor(descriptor_path: Path) -> bytes:
|
||||
try:
|
||||
parent = descriptor_path.parent
|
||||
parent_status = parent.lstat()
|
||||
if (
|
||||
stat.S_ISLNK(parent_status.st_mode)
|
||||
or not stat.S_ISDIR(parent_status.st_mode)
|
||||
or parent.resolve(strict=True) != parent
|
||||
):
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor parent must be one real confined directory",
|
||||
)
|
||||
directory_fd = os.open(
|
||||
parent,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise DocForgeError("missing_config", "Missing .docforge/project.toml") from error
|
||||
except DocForgeError:
|
||||
raise
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor cannot be inspected safely",
|
||||
) from error
|
||||
opened_parent = os.fstat(directory_fd)
|
||||
if opened_parent.st_dev != parent_status.st_dev or opened_parent.st_ino != parent_status.st_ino:
|
||||
with suppress(OSError):
|
||||
os.close(directory_fd)
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor parent changed while it was opened",
|
||||
)
|
||||
try:
|
||||
|
||||
def parent_current() -> bool:
|
||||
try:
|
||||
before = parent.lstat()
|
||||
resolved = parent.resolve(strict=True)
|
||||
after = parent.lstat()
|
||||
opened = os.fstat(directory_fd)
|
||||
return (
|
||||
not stat.S_ISLNK(before.st_mode)
|
||||
and stat.S_ISDIR(before.st_mode)
|
||||
and resolved == parent
|
||||
and (before.st_dev, before.st_ino, before.st_mode)
|
||||
== (after.st_dev, after.st_ino, after.st_mode)
|
||||
== (opened.st_dev, opened.st_ino, opened.st_mode)
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
if not parent_current():
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor parent changed before it was read",
|
||||
)
|
||||
try:
|
||||
before = os.stat(
|
||||
descriptor_path.name,
|
||||
dir_fd=directory_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise DocForgeError("missing_config", "Missing .docforge/project.toml") from error
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor cannot be inspected safely",
|
||||
) from error
|
||||
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor must be a regular file and not a symbolic link",
|
||||
)
|
||||
if before.st_size > MAX_PROJECT_DESCRIPTOR_BYTES:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_oversized",
|
||||
"Project descriptor exceeds the bounded configuration limit",
|
||||
maximum_bytes=MAX_PROJECT_DESCRIPTOR_BYTES,
|
||||
)
|
||||
try:
|
||||
descriptor = os.open(
|
||||
descriptor_path.name,
|
||||
os.O_RDONLY | os.O_NOFOLLOW,
|
||||
dir_fd=directory_fd,
|
||||
)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor cannot be opened safely",
|
||||
) from error
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if opened.st_dev != before.st_dev or opened.st_ino != before.st_ino:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor changed while it was opened",
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
remaining = MAX_PROJECT_DESCRIPTOR_BYTES + 1
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(65_536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
raw = b"".join(chunks)
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
os.close(descriptor)
|
||||
if len(raw) > MAX_PROJECT_DESCRIPTOR_BYTES:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_oversized",
|
||||
"Project descriptor exceeds the bounded configuration limit",
|
||||
maximum_bytes=MAX_PROJECT_DESCRIPTOR_BYTES,
|
||||
)
|
||||
try:
|
||||
after = os.stat(
|
||||
descriptor_path.name,
|
||||
dir_fd=directory_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor changed while it was read",
|
||||
) from error
|
||||
if (
|
||||
before.st_dev,
|
||||
before.st_ino,
|
||||
before.st_size,
|
||||
before.st_mtime_ns,
|
||||
before.st_ctime_ns,
|
||||
) != (
|
||||
after.st_dev,
|
||||
after.st_ino,
|
||||
after.st_size,
|
||||
after.st_mtime_ns,
|
||||
after.st_ctime_ns,
|
||||
):
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor changed while it was read",
|
||||
)
|
||||
if not parent_current():
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor parent changed while it was read",
|
||||
)
|
||||
return raw
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
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")
|
||||
descriptor_bytes = _read_descriptor(descriptor_path)
|
||||
try:
|
||||
descriptor_bytes = descriptor_path.read_bytes()
|
||||
document = cast(dict[str, object], tomllib.loads(descriptor_bytes.decode("utf-8")))
|
||||
except UnicodeDecodeError as error:
|
||||
raise DocForgeError("invalid_config", "Project descriptor is not UTF-8") from error
|
||||
|
|
@ -472,6 +629,17 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
|
|||
)
|
||||
|
||||
|
||||
def validate_descriptor_binding(descriptor: ProjectDescriptor) -> None:
|
||||
"""Require the bounded descriptor bytes to match one opened project binding."""
|
||||
|
||||
descriptor_bytes = _read_descriptor(descriptor.descriptor_path)
|
||||
if hashlib.sha256(descriptor_bytes).hexdigest() != descriptor.descriptor_hash:
|
||||
raise DocForgeError(
|
||||
"source_changed",
|
||||
"Project descriptor changed after the project was opened",
|
||||
)
|
||||
|
||||
|
||||
def _markdown_record(path: Path, text: str) -> tuple[dict[str, Any], str]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0] != "+++":
|
||||
|
|
@ -739,11 +907,7 @@ class Project:
|
|||
|
||||
def load(self) -> ProjectSnapshot:
|
||||
increment("project_loads")
|
||||
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"
|
||||
)
|
||||
validate_descriptor_binding(self.descriptor)
|
||||
ordered_sources, ordered_directories = self._canonical_inventory()
|
||||
generation_paths = (
|
||||
self.descriptor.descriptor_path,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ OPERATION_NAMES = frozenset(
|
|||
{
|
||||
"test",
|
||||
"benchmark.m1",
|
||||
"benchmark.m2",
|
||||
"mcp.invoke",
|
||||
"mcp.bootstrap",
|
||||
"mcp.sync",
|
||||
|
|
@ -116,6 +117,8 @@ OPERATION_NAMES = frozenset(
|
|||
"cli.impact",
|
||||
"cli.context",
|
||||
"cli.generation-diff",
|
||||
"cli.configure",
|
||||
"cli.doctor",
|
||||
"cli.render",
|
||||
"cli.render-status",
|
||||
"cli.preview",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue