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

370 lines
13 KiB
Python
Raw Normal View History

"""Generate or verify the deterministic DocForge CLI and MCP command reference."""
from __future__ import annotations
import argparse
import asyncio
import contextlib
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
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
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
size: int
modified_ns: int
changed_ns: 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:
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)
return "written"
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,
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,
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,
) -> 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)
try:
if _file_identity(directory_fd, name) != expected:
raise CommandReferenceToolError("Output target changed before atomic publication")
os.replace(
temporary_name,
name,
src_dir_fd=directory_fd,
dst_dir_fd=directory_fd,
)
os.fsync(directory_fd)
except Exception:
_unlink_at(directory_fd, temporary_name)
raise
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())