1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/tools/generate_command_reference.py

536 lines
18 KiB
Python

"""Generate or verify the deterministic DocForge CLI and MCP command reference."""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import ctypes
import errno
import fcntl
import json
import os
import secrets
import shutil
import stat
import sys
import tempfile
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, Protocol, cast
from mcp.shared.memory import create_connected_server_and_client_session
from docforge.command_reference import (
CliCommandReference,
McpToolReference,
cli_command_references,
mcp_tool_references,
render_command_reference_markdown,
)
from docforge.index import ProjectIndex
from docforge.mcp_server import ALL_TOOLS, APPLICATION_TOOLS, create_server
from docforge.project import Project
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PROJECT_ROOT = REPOSITORY_ROOT / "tests" / "fixtures" / "alpha"
EXPECTED_CLI_ROWS = 28
EXPECTED_MCP_ROWS = 36
MAX_REFERENCE_BYTES = 5_000_000
RENAME_EXCHANGE = 2
class CommandReferenceToolError(RuntimeError):
"""One safe repository-tool validation or publication failure."""
class CommandReferenceDrift(CommandReferenceToolError):
"""The checked output does not match current registered command metadata."""
@dataclass(frozen=True)
class _FileIdentity:
device: int
inode: int
mode: int
owner: int
group: int
size: int
modified_ns: int
changed_ns: int
class _RenameAt2(Protocol):
argtypes: list[object]
restype: object
def __call__(
self,
old_directory_fd: int,
old_name: bytes,
new_directory_fd: int,
new_name: bytes,
flags: int,
/,
) -> int: ...
async def collect_command_reference_rows(
project_root: Path,
*,
proposal_writer: str,
canonical_applier: str,
) -> tuple[tuple[CliCommandReference, ...], tuple[McpToolReference, ...]]:
"""Collect the real CLI parser and full project-bound MCP registration surface."""
source = _safe_project_root(project_root)
with tempfile.TemporaryDirectory(prefix="docforge-command-reference-") as directory:
copied = Path(directory) / "project"
shutil.copytree(source, copied, symlinks=True)
ProjectIndex(Project.open(copied)).build()
server = create_server(
copied,
proposal_writer,
canonical_applier_id=canonical_applier,
)
async with create_connected_server_and_client_session(
server,
raise_exceptions=True,
) as session:
tools = tuple((await session.list_tools()).tools)
cli_rows = cli_command_references()
expected_mcp_names = (*ALL_TOOLS, *APPLICATION_TOOLS)
mcp_rows = mcp_tool_references(tools, expected_names=expected_mcp_names)
if len(cli_rows) != EXPECTED_CLI_ROWS:
raise CommandReferenceToolError(
f"Expected {EXPECTED_CLI_ROWS} CLI rows, found {len(cli_rows)}"
)
if len(mcp_rows) != EXPECTED_MCP_ROWS:
raise CommandReferenceToolError(
f"Expected {EXPECTED_MCP_ROWS} MCP rows, found {len(mcp_rows)}"
)
return cli_rows, mcp_rows
def generate_command_reference_bytes(
project_root: Path,
*,
proposal_writer: str = "alpha-editor",
canonical_applier: str = "alpha-editor",
) -> bytes:
"""Render one stable Markdown document from an isolated project copy."""
cli_rows, mcp_rows = asyncio.run(
collect_command_reference_rows(
project_root,
proposal_writer=proposal_writer,
canonical_applier=canonical_applier,
)
)
rendered = render_command_reference_markdown(cli_rows, mcp_rows).encode("utf-8")
if len(rendered) > MAX_REFERENCE_BYTES:
raise CommandReferenceToolError("Generated command reference exceeds its byte limit")
return rendered
def publish_or_check_command_reference(
content: bytes,
*,
repository_root: Path,
project_root: Path,
output: Path,
check: bool,
) -> Literal["current", "unchanged", "written"]:
"""Safely check or atomically publish one repository-confined Markdown file."""
if len(content) > MAX_REFERENCE_BYTES:
raise CommandReferenceToolError("Command reference exceeds its byte limit")
root = _safe_repository_root(repository_root)
source = _safe_project_root(project_root)
relative = _safe_output_relative(root, source, output)
parent_parts = relative.parts[:-1]
target_name = relative.name
parent_fd = _open_relative_directory(root, parent_parts)
try:
fcntl.flock(parent_fd, fcntl.LOCK_EX)
try:
initial = _file_identity(parent_fd, target_name)
existing = (
_read_regular_file(parent_fd, target_name, limit=MAX_REFERENCE_BYTES)
if initial is not None
else None
)
if _file_identity(parent_fd, target_name) != initial:
raise CommandReferenceToolError("Output target changed during inspection")
if check:
if existing != content:
raise CommandReferenceDrift(
f"Command reference is missing or stale: {relative.as_posix()}"
)
return "current"
if existing == content:
return "unchanged"
_atomic_replace(
parent_fd,
target_name,
content,
expected=initial,
expected_content=existing,
)
return "written"
finally:
fcntl.flock(parent_fd, fcntl.LOCK_UN)
finally:
os.close(parent_fd)
def _safe_repository_root(path: Path) -> Path:
if path.is_symlink():
raise CommandReferenceToolError("Repository root must not be a symbolic link")
try:
resolved = path.resolve(strict=True)
except OSError as error:
raise CommandReferenceToolError("Repository root does not exist") from error
if not resolved.is_dir():
raise CommandReferenceToolError("Repository root must be a directory")
return resolved
def _safe_project_root(path: Path) -> Path:
if path.is_symlink():
raise CommandReferenceToolError("Project fixture root must not be a symbolic link")
try:
resolved = path.resolve(strict=True)
except OSError as error:
raise CommandReferenceToolError("Project fixture root does not exist") from error
if not resolved.is_dir():
raise CommandReferenceToolError("Project fixture root must be a directory")
return resolved
def _safe_output_relative(root: Path, project_root: Path, output: Path) -> Path:
candidate = output if output.is_absolute() else root / output
absolute = Path(os.path.abspath(candidate))
if absolute == root or not absolute.is_relative_to(root):
raise CommandReferenceToolError("Output must remain inside the repository root")
if absolute == project_root or absolute.is_relative_to(project_root):
raise CommandReferenceToolError("Output must not modify the source project fixture")
relative = absolute.relative_to(root)
if relative.suffix != ".md":
raise CommandReferenceToolError("Output must be one Markdown file")
if not relative.name or len(relative.parts) < 2:
raise CommandReferenceToolError("Output must be below an existing repository directory")
return relative
def _open_relative_directory(root: Path, parts: tuple[str, ...]) -> int:
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
descriptor = os.open(root, flags)
try:
for part in parts:
if part in {"", ".", ".."}:
raise CommandReferenceToolError("Output directory is unsafe")
try:
next_descriptor = os.open(part, flags, dir_fd=descriptor)
except OSError as error:
raise CommandReferenceToolError(
"Output parent must be an existing non-symlink directory"
) from error
os.close(descriptor)
descriptor = next_descriptor
return descriptor
except Exception:
os.close(descriptor)
raise
def _file_identity(directory_fd: int, name: str) -> _FileIdentity | None:
try:
status = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
except FileNotFoundError:
return None
if not stat.S_ISREG(status.st_mode):
raise CommandReferenceToolError("Output target must be a regular file")
return _FileIdentity(
device=status.st_dev,
inode=status.st_ino,
mode=status.st_mode,
owner=status.st_uid,
group=status.st_gid,
size=status.st_size,
modified_ns=status.st_mtime_ns,
changed_ns=status.st_ctime_ns,
)
def _read_regular_file(directory_fd: int, name: str, *, limit: int) -> bytes:
try:
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd)
except OSError as error:
raise CommandReferenceToolError("Output target could not be opened safely") from error
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or before.st_size > limit:
raise CommandReferenceToolError("Output target is not a bounded regular file")
chunks: list[bytes] = []
remaining = limit + 1
while remaining:
chunk = os.read(descriptor, min(remaining, 64 * 1024))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
content = b"".join(chunks)
after = os.fstat(descriptor)
if len(content) > limit or _identity_from_stat(before) != _identity_from_stat(after):
raise CommandReferenceToolError("Output target changed during inspection")
return content
finally:
os.close(descriptor)
def _identity_from_stat(status: os.stat_result) -> _FileIdentity:
return _FileIdentity(
device=status.st_dev,
inode=status.st_ino,
mode=status.st_mode,
owner=status.st_uid,
group=status.st_gid,
size=status.st_size,
modified_ns=status.st_mtime_ns,
changed_ns=status.st_ctime_ns,
)
def _atomic_replace(
directory_fd: int,
name: str,
content: bytes,
*,
expected: _FileIdentity | None,
expected_content: bytes | None,
) -> None:
temporary_name = f".{name}.docforge-command-reference-{secrets.token_hex(12)}"
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
try:
descriptor = os.open(temporary_name, flags, 0o644, dir_fd=directory_fd)
except OSError as error:
raise CommandReferenceToolError("Could not create atomic output temporary") from error
try:
view = memoryview(content)
while view:
written = os.write(descriptor, view)
if written <= 0:
raise CommandReferenceToolError("Could not write command reference output")
view = view[written:]
os.fsync(descriptor)
except Exception:
os.close(descriptor)
_unlink_at(directory_fd, temporary_name)
raise
else:
os.close(descriptor)
temporary_contains_only_staged_content = True
try:
staged = _file_identity(directory_fd, temporary_name)
if staged is None:
raise CommandReferenceToolError("Atomic output temporary disappeared")
if _file_identity(directory_fd, name) != expected:
raise CommandReferenceToolError("Output target changed before atomic publication")
if expected is None:
_link_no_replace(directory_fd, temporary_name, name)
_unlink_at(directory_fd, temporary_name)
os.fsync(directory_fd)
return
if expected_content is None:
raise CommandReferenceToolError("Expected output content was not captured")
_rename_exchange(directory_fd, temporary_name, name)
temporary_contains_only_staged_content = False
displaced = _file_identity(directory_fd, temporary_name)
published = _file_identity(directory_fd, name)
displaced_content = _read_regular_file(
directory_fd,
temporary_name,
limit=MAX_REFERENCE_BYTES,
)
if (
displaced is None
or published is None
or not _same_identity_after_rename(displaced, expected)
or displaced_content != expected_content
or not _same_identity_after_rename(published, staged)
):
try:
_rename_exchange(directory_fd, temporary_name, name)
except Exception as error:
raise CommandReferenceToolError(
"Output target raced publication; displaced data was retained "
f"in {temporary_name}"
) from error
temporary_contains_only_staged_content = True
if (
not _same_identity_after_rename(
_file_identity(directory_fd, name),
displaced,
)
or not _same_identity_after_rename(
_file_identity(directory_fd, temporary_name),
staged,
)
):
raise CommandReferenceToolError(
"Output target raced publication and could not be safely restored"
)
raise CommandReferenceToolError("Output target changed during atomic publication")
_unlink_at(directory_fd, temporary_name)
os.fsync(directory_fd)
except Exception:
if temporary_contains_only_staged_content:
_unlink_at(directory_fd, temporary_name)
raise
def _same_identity_after_rename(
actual: _FileIdentity | None,
expected: _FileIdentity | None,
) -> bool:
if actual is None or expected is None:
return False
return (
actual.device,
actual.inode,
actual.mode,
actual.owner,
actual.group,
actual.size,
actual.modified_ns,
) == (
expected.device,
expected.inode,
expected.mode,
expected.owner,
expected.group,
expected.size,
expected.modified_ns,
)
def _link_no_replace(directory_fd: int, source: str, target: str) -> None:
try:
os.link(
source,
target,
src_dir_fd=directory_fd,
dst_dir_fd=directory_fd,
follow_symlinks=False,
)
except FileExistsError as error:
raise CommandReferenceToolError(
"Output target appeared during atomic publication"
) from error
except OSError as error:
raise CommandReferenceToolError("Could not publish atomic output") from error
def _rename_exchange(directory_fd: int, first: str, second: str) -> None:
rename_at2 = _load_rename_at2()
ctypes.set_errno(0)
result = rename_at2(
directory_fd,
os.fsencode(first),
directory_fd,
os.fsencode(second),
RENAME_EXCHANGE,
)
if result == 0:
return
error_number = ctypes.get_errno()
if error_number in {errno.ENOSYS, errno.EINVAL, errno.EOPNOTSUPP}:
raise CommandReferenceToolError(
"Atomic exchange publication is unavailable on this filesystem"
)
raise CommandReferenceToolError("Could not exchange atomic output") from OSError(
error_number,
os.strerror(error_number),
)
def _load_rename_at2() -> _RenameAt2:
library = ctypes.CDLL(None, use_errno=True)
try:
rename_at2 = cast(_RenameAt2, library.renameat2)
except AttributeError as error:
raise CommandReferenceToolError(
"Atomic exchange publication is unavailable on this platform"
) from error
rename_at2.argtypes = [
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_uint,
]
rename_at2.restype = ctypes.c_int
return rename_at2
def _unlink_at(directory_fd: int, name: str) -> None:
with contextlib.suppress(FileNotFoundError):
os.unlink(name, dir_fd=directory_fd)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate or check deterministic DocForge command-reference Markdown."
)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--check", action="store_true")
parser.add_argument("--repository-root", type=Path, default=REPOSITORY_ROOT)
parser.add_argument("--project-root", type=Path, default=DEFAULT_PROJECT_ROOT)
parser.add_argument("--proposal-writer", default="alpha-editor")
parser.add_argument("--canonical-applier", default="alpha-editor")
return parser
def main(arguments: Sequence[str] | None = None) -> int:
parsed = _parser().parse_args(arguments)
try:
content = generate_command_reference_bytes(
parsed.project_root,
proposal_writer=parsed.proposal_writer,
canonical_applier=parsed.canonical_applier,
)
state = publish_or_check_command_reference(
content,
repository_root=parsed.repository_root,
project_root=parsed.project_root,
output=parsed.output,
check=parsed.check,
)
except CommandReferenceDrift as error:
print(str(error), file=sys.stderr)
return 1
except Exception as error:
print(f"command-reference generation failed: {error}", file=sys.stderr)
return 2
print(
json.dumps(
{
"status": state,
"output": str(parsed.output),
"cli_rows": EXPECTED_CLI_ROWS,
"mcp_rows": EXPECTED_MCP_ROWS,
},
sort_keys=True,
separators=(",", ":"),
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())