233 lines
8.2 KiB
Python
233 lines
8.2 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import contextlib
|
||
|
|
import hashlib
|
||
|
|
import io
|
||
|
|
import os
|
||
|
|
import tempfile
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from docforge.mcp_server import ALL_TOOLS, APPLICATION_TOOLS
|
||
|
|
from tools.generate_command_reference import (
|
||
|
|
EXPECTED_CLI_ROWS,
|
||
|
|
EXPECTED_MCP_ROWS,
|
||
|
|
CommandReferenceDrift,
|
||
|
|
CommandReferenceToolError,
|
||
|
|
collect_command_reference_rows,
|
||
|
|
generate_command_reference_bytes,
|
||
|
|
main,
|
||
|
|
publish_or_check_command_reference,
|
||
|
|
)
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
ALPHA = ROOT / "tests" / "fixtures" / "alpha"
|
||
|
|
|
||
|
|
|
||
|
|
def _tree_snapshot(root: Path) -> tuple[tuple[str, str, str], ...]:
|
||
|
|
records: list[tuple[str, str, str]] = []
|
||
|
|
for path in sorted(root.rglob("*")):
|
||
|
|
relative = path.relative_to(root).as_posix()
|
||
|
|
if path.is_symlink():
|
||
|
|
records.append((relative, "symlink", os.readlink(path)))
|
||
|
|
elif path.is_dir():
|
||
|
|
records.append((relative, "directory", ""))
|
||
|
|
else:
|
||
|
|
records.append(
|
||
|
|
(
|
||
|
|
relative,
|
||
|
|
"file",
|
||
|
|
hashlib.sha256(path.read_bytes()).hexdigest(),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return tuple(records)
|
||
|
|
|
||
|
|
|
||
|
|
class CommandReferenceToolTests(unittest.TestCase):
|
||
|
|
def test_generated_bytes_are_stable_and_never_mutate_the_fixture(self) -> None:
|
||
|
|
before = _tree_snapshot(ALPHA)
|
||
|
|
|
||
|
|
first = generate_command_reference_bytes(ALPHA)
|
||
|
|
middle = _tree_snapshot(ALPHA)
|
||
|
|
second = generate_command_reference_bytes(ALPHA)
|
||
|
|
|
||
|
|
self.assertEqual(first, second)
|
||
|
|
self.assertEqual(before, middle)
|
||
|
|
self.assertEqual(before, _tree_snapshot(ALPHA))
|
||
|
|
self.assertTrue(first.endswith(b"\n"))
|
||
|
|
self.assertEqual(1, first.count(b"## CLI commands"))
|
||
|
|
self.assertEqual(1, first.count(b"## MCP tools"))
|
||
|
|
|
||
|
|
def test_registration_collection_is_complete(self) -> None:
|
||
|
|
cli_rows, mcp_rows = asyncio.run(
|
||
|
|
collect_command_reference_rows(
|
||
|
|
ALPHA,
|
||
|
|
proposal_writer="alpha-editor",
|
||
|
|
canonical_applier="alpha-editor",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
self.assertEqual(EXPECTED_CLI_ROWS, len(cli_rows))
|
||
|
|
self.assertEqual(EXPECTED_MCP_ROWS, len(mcp_rows))
|
||
|
|
self.assertEqual(len(cli_rows), len({item.name for item in cli_rows}))
|
||
|
|
self.assertEqual(
|
||
|
|
set((*ALL_TOOLS, *APPLICATION_TOOLS)),
|
||
|
|
{item.name for item in mcp_rows},
|
||
|
|
)
|
||
|
|
self.assertEqual(
|
||
|
|
{"read", "proposal", "application"},
|
||
|
|
{item.surface for item in mcp_rows},
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_atomic_write_check_and_drift_detection(self) -> None:
|
||
|
|
content = generate_command_reference_bytes(ALPHA)
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
repository = Path(directory).resolve()
|
||
|
|
(repository / "generated").mkdir()
|
||
|
|
output = Path("generated/command-reference.md")
|
||
|
|
target = repository / output
|
||
|
|
|
||
|
|
self.assertEqual(
|
||
|
|
"written",
|
||
|
|
publish_or_check_command_reference(
|
||
|
|
content,
|
||
|
|
repository_root=repository,
|
||
|
|
project_root=ALPHA,
|
||
|
|
output=output,
|
||
|
|
check=False,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
self.assertEqual(content, target.read_bytes())
|
||
|
|
self.assertEqual(
|
||
|
|
"current",
|
||
|
|
publish_or_check_command_reference(
|
||
|
|
content,
|
||
|
|
repository_root=repository,
|
||
|
|
project_root=ALPHA,
|
||
|
|
output=output,
|
||
|
|
check=True,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
target.write_text("stale\n", encoding="utf-8")
|
||
|
|
with self.assertRaises(CommandReferenceDrift):
|
||
|
|
publish_or_check_command_reference(
|
||
|
|
content,
|
||
|
|
repository_root=repository,
|
||
|
|
project_root=ALPHA,
|
||
|
|
output=output,
|
||
|
|
check=True,
|
||
|
|
)
|
||
|
|
self.assertEqual(b"stale\n", target.read_bytes())
|
||
|
|
self.assertEqual(
|
||
|
|
"written",
|
||
|
|
publish_or_check_command_reference(
|
||
|
|
content,
|
||
|
|
repository_root=repository,
|
||
|
|
project_root=ALPHA,
|
||
|
|
output=output,
|
||
|
|
check=False,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
self.assertEqual(content, target.read_bytes())
|
||
|
|
self.assertEqual(
|
||
|
|
"unchanged",
|
||
|
|
publish_or_check_command_reference(
|
||
|
|
content,
|
||
|
|
repository_root=repository,
|
||
|
|
project_root=ALPHA,
|
||
|
|
output=output,
|
||
|
|
check=False,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_output_confinement_rejects_escapes_and_symlinks(self) -> None:
|
||
|
|
content = b"reference\n"
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
repository = Path(directory).resolve()
|
||
|
|
generated = repository / "generated"
|
||
|
|
generated.mkdir()
|
||
|
|
outside = repository.parent / f"{repository.name}-outside.md"
|
||
|
|
symlink = generated / "reference.md"
|
||
|
|
symlink.symlink_to(outside)
|
||
|
|
|
||
|
|
for output in (Path("../escape.md"), Path("generated/reference.md")):
|
||
|
|
with (
|
||
|
|
self.subTest(output=output),
|
||
|
|
self.assertRaises(CommandReferenceToolError),
|
||
|
|
):
|
||
|
|
publish_or_check_command_reference(
|
||
|
|
content,
|
||
|
|
repository_root=repository,
|
||
|
|
project_root=ALPHA,
|
||
|
|
output=output,
|
||
|
|
check=False,
|
||
|
|
)
|
||
|
|
symlink.unlink()
|
||
|
|
(repository / "linked").symlink_to(generated, target_is_directory=True)
|
||
|
|
with self.assertRaises(CommandReferenceToolError):
|
||
|
|
publish_or_check_command_reference(
|
||
|
|
content,
|
||
|
|
repository_root=repository,
|
||
|
|
project_root=ALPHA,
|
||
|
|
output=Path("linked/reference.md"),
|
||
|
|
check=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
with self.assertRaises(CommandReferenceToolError):
|
||
|
|
publish_or_check_command_reference(
|
||
|
|
content,
|
||
|
|
repository_root=ROOT,
|
||
|
|
project_root=ALPHA,
|
||
|
|
output=Path("tests/fixtures/alpha/generated-reference.md"),
|
||
|
|
check=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_command_runs_with_explicit_repository_and_project_roots(self) -> None:
|
||
|
|
with tempfile.TemporaryDirectory() as directory:
|
||
|
|
repository = Path(directory).resolve()
|
||
|
|
(repository / "generated").mkdir()
|
||
|
|
output = Path("generated/reference.md")
|
||
|
|
stdout = io.StringIO()
|
||
|
|
|
||
|
|
with contextlib.redirect_stdout(stdout):
|
||
|
|
status = main(
|
||
|
|
(
|
||
|
|
"--repository-root",
|
||
|
|
str(repository),
|
||
|
|
"--project-root",
|
||
|
|
str(ALPHA),
|
||
|
|
"--output",
|
||
|
|
str(output),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
self.assertEqual(0, status)
|
||
|
|
self.assertTrue((repository / output).is_file())
|
||
|
|
self.assertIn('"cli_rows":28', stdout.getvalue())
|
||
|
|
self.assertIn('"mcp_rows":36', stdout.getvalue())
|
||
|
|
|
||
|
|
target = repository / output
|
||
|
|
target.write_text("drift\n", encoding="utf-8")
|
||
|
|
stderr = io.StringIO()
|
||
|
|
with contextlib.redirect_stderr(stderr):
|
||
|
|
check_status = main(
|
||
|
|
(
|
||
|
|
"--repository-root",
|
||
|
|
str(repository),
|
||
|
|
"--project-root",
|
||
|
|
str(ALPHA),
|
||
|
|
"--output",
|
||
|
|
str(output),
|
||
|
|
"--check",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
self.assertEqual(1, check_status)
|
||
|
|
self.assertEqual("drift\n", target.read_text(encoding="utf-8"))
|
||
|
|
self.assertIn("missing or stale", stderr.getvalue())
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|