From fb0df5e4a1c591c2a84788fd4814d98550f11863 Mon Sep 17 00:00:00 2001 From: Andraxion Date: Wed, 29 Jul 2026 10:15:27 -0400 Subject: [PATCH 1/2] Add deterministic client integration diagnostics --- ACTIVE_SLICE.md | 13 +- DEVELOPMENT_NOTES.md | 60 + Makefile | 16 +- schemas/client-configuration.schema.json | 894 +++++++++++++++ schemas/doctor-result.schema.json | 466 ++++++++ schemas/result.schema.json | 3 + src/docforge/cli.py | 56 +- src/docforge/client_config.py | 937 ++++++++++++++++ src/docforge/doctor.py | 1287 ++++++++++++++++++++++ src/docforge/project.py | 180 ++- src/docforge/telemetry.py | 3 + tests/test_client_integration.py | 1264 +++++++++++++++++++++ tests/test_observability.py | 14 + tests/test_public_contract.py | 4 + tools/milestone2_benchmark.py | 1014 +++++++++++++++++ 15 files changed, 6193 insertions(+), 18 deletions(-) create mode 100644 schemas/client-configuration.schema.json create mode 100644 schemas/doctor-result.schema.json create mode 100644 src/docforge/client_config.py create mode 100644 src/docforge/doctor.py create mode 100644 tests/test_client_integration.py create mode 100644 tools/milestone2_benchmark.py diff --git a/ACTIVE_SLICE.md b/ACTIVE_SLICE.md index 01a3810..97be32f 100644 --- a/ACTIVE_SLICE.md +++ b/ACTIVE_SLICE.md @@ -6,11 +6,14 @@ Goal: Let one project-bound server return compact, task-shaped, explainable cont In scope: Capability modes; capability-aware bootstrap; versioned retrieval plans and context capsules; task-shaped context; generation diffs; evidence-gap diagnostics; generated client configuration; doctor checks. Out of scope: Independent render-plan packages; adapter SDK expansion; self-hosting; storage replacement; embeddings; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases. Done when: Policy and capabilities are explicit; bootstrap recommends only available actions; task context is compact, deterministic, provenance-bearing, and bounded; generation and evidence gaps are explainable; generated configuration and doctor checks are safe and tested; the complete repository gate and Milestone 2 benchmark pass. -Status: Active. Effective policy and versioned task retrieval are committed. The latest-generation -diff receipt is implemented with focused contract, failure, CLI, MCP, legacy-adapter, no-AST, and -zero-work tests. The full repository gate passes with 176 tests and 113 subtests. Independent -publication, contract, and performance audits approve the hardened tree for commit. Generated -client configuration and doctor checks follow. +Status: Candidate frozen. Effective policy, versioned task retrieval, latest-generation diff +receipts, and logarithmic task-context page packing are committed and pushed on `dev`. +Deterministic client configuration and the read-only integration doctor now pass their bounded +publication, path-race, malformed-input, redaction, and no-hidden-work audits. The complete +repository gate passes with 205 tests and 120 subtests. A disposable 1,000-node audit sample passes +the maintained task-context, generation-diff, response-size, counter, and memory gates. Final +clean-revision benchmark evidence and documentation closeout remain before the milestone is marked +complete. ``` Milestones 3–5 remain directional context and are not active. diff --git a/DEVELOPMENT_NOTES.md b/DEVELOPMENT_NOTES.md index 7eb8d0e..536100e 100644 --- a/DEVELOPMENT_NOTES.md +++ b/DEVELOPMENT_NOTES.md @@ -523,3 +523,63 @@ fell from 272.06 ms p95 to 56.617 ms p95, while full traversal fell from roughly 203.55 ms p95. Regression tests require one receipt validation and at most 15 response encodes for 1,000 page candidates. Final independent publication, contract, and performance audits approve the slice for commit. + +### Deterministic client configuration and read-only doctor + +Client integration remains an explicit machine-local boundary rather than canonical project +content. `docforge configure {codex,claude,openclaw} --project PATH` previews a deterministic +version-1 fragment by default. An optional output path publishes only a standalone fragment into +an existing real directory. Publication is create-only, private-mode, no-follow, bounded, and +conflict-aware. Existing differing client configuration is never merged, replaced, or silently +overwritten. + +Generated commands use the exact current virtual-environment Python executable with isolated +module startup. The binding records explicit read, proposal, or application mode, no-AST policy, +render policy, empty environment, and bounded timeouts. Proposal and application generation fail +closed unless the descriptor declares the required writer and matching applier identity. A generic +CLI cannot reconstruct project-owned adapter composition, so custom adapters return an explicit +unavailable result instead of generating a misleading command. + +Codex and OpenClaw fragments include their verified timeout fields. Claude JSON fragment syntax is +supported, while its timeout representation remains an explicit warning. The configuration result +has a strict JSON schema and canonical plan hash. Diagnostics are additive and remain disabled by +default. + +`docforge doctor --client CLIENT` performs bounded, non-mutating inspection only. It reads the +project descriptor and selected client file through stable, directory-bound, no-follow handles; +parses at most 1 MiB and 256 server entries; selects at most one exact project binding; validates +the closed server argument set; checks executable, capability, declared authority, no-AST, +timeouts, environment-key names, and tool-filter presence; and performs only a stat-level index +presence check. It never loads canonical sources, opens SQLite, starts MCP, executes the configured +command, synchronizes, builds, renders, starts a viewer, or writes client configuration. + +Doctor reports healthy, degraded, or unhealthy with stable process exit codes 0, 1, and 2. Secret +environment values are parsed only to enforce bounded string limits and are never returned. +Unknown or unverified client tool filtering, Claude timeout representation, implicit legacy +capability mode, shadowed authority, and missing disposable indexes are warnings. Unsafe paths, +malformed matching entries, unexpected executables, wrong project roots, invalid authorities, and +missing configuration are failures. + +The first benchmark smoke failed for the correct product reason: its disposable doctor fragment +used the shared path `/tmp/doctor-codex.toml`, where a previous run had left different content. The +harness now creates a project subdirectory inside one unique temporary root and places the client +fragment beside it. This preserves create-only conflict safety and makes every run disposable. + +The final pre-commit 1,000-node audit sample passes every provisional Milestone 2 gate. Task +context reconstructs 1,000 candidates as 108 cited evidence records and 892 explicit bounded +omissions across 11 pages in 703.808 ms. Generation diff reconstructs 1,000 changed details across +10 pages in 427.450 ms. Maximum pages remain below the 200,000-byte MCP budget; generation diff +uses 199,566 bytes and proves that diagnostics are discarded before the primary result. Isolated +peak RSS is 86,168 KiB. + +Configuration preview now includes a bounded real import probe of the exact isolated interpreter, +so its provisional single-sample latency is about 315 ms rather than the earlier sub-millisecond +derivation-only figure. Doctor remains below 1 ms on generated disposable configurations. +Every configuration and doctor hidden-work counter is zero. + +The aggregate `make gate` includes the Milestone 2 smoke benchmark. The frozen candidate passes +205 tests and 120 schema subtests, strict warnings, Ruff, formatting, Pyright, web checks, +compilation, lock and dependency checks, package builds, and all three milestone smoke benchmarks. +Three independent final audits approve client publication and policy binding, doctor fail-closed +behavior, and benchmark/contract coverage. Clean-revision benchmark evidence is still required +before closeout. diff --git a/Makefile b/Makefile index 572cf59..ab909e4 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ NPM := npm PYTHONPYCACHEPREFIX := /tmp/docforge-quality-pycache PYTEST_BASETEMP := /tmp/docforge-quality-pytest -.PHONY: benchmark benchmark-m1 benchmark-m1-smoke benchmark-smoke build compile contract dependencies format-check gate lint lock test type +.PHONY: benchmark benchmark-m1 benchmark-m1-smoke benchmark-m2 benchmark-m2-smoke benchmark-smoke build compile contract dependencies format-check gate lint lock test type format-check: $(PYTHON) -m ruff format --check src tests tools @@ -24,6 +24,11 @@ contract: PYTHONPYCACHEPREFIX=$(PYTHONPYCACHEPREFIX) $(PYTHON) -m pytest -q \ -p no:cacheprovider --basetemp=$(PYTEST_BASETEMP) \ tests/test_public_contract.py \ + tests/test_policy.py \ + tests/test_retrieval.py \ + tests/test_generation_diff.py \ + tests/test_client_integration.py \ + tests/test_observability.py::TelemetryContractTests::test_schema_fixed_names_match_the_implementation \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_policy_rejects_logic_publication \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_rejects_preexisting_logic_index_and_viewer_snapshot \ @@ -56,4 +61,11 @@ benchmark-m1-smoke: benchmark-m1: $(PYTHON) tools/milestone1_benchmark.py --nodes 1000 --samples 10 -gate: format-check lint type compile contract test lock dependencies build benchmark-smoke benchmark-m1-smoke +benchmark-m2-smoke: + $(PYTHON) tools/milestone2_benchmark.py --nodes 25 --samples 1 \ + --output /tmp/docforge-milestone2-smoke.json > /dev/null + +benchmark-m2: + $(PYTHON) tools/milestone2_benchmark.py --nodes 1000 --samples 10 + +gate: format-check lint type compile contract test lock dependencies build benchmark-smoke benchmark-m1-smoke benchmark-m2-smoke diff --git a/schemas/client-configuration.schema.json b/schemas/client-configuration.schema.json new file mode 100644 index 0000000..b4cf208 --- /dev/null +++ b/schemas/client-configuration.schema.json @@ -0,0 +1,894 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docforge.local/schema/client-configuration-v1.json", + "title": "DocForge deterministic client configuration plan", + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "adapter_policy": { + "oneOf": [ + { + "type": "object", + "required": [ + "mode", + "ast_analysis", + "logic_projection", + "incremental_extraction", + "adapter_rewrite" + ], + "properties": { + "mode": { "const": "standard" }, + "ast_analysis": { "const": "allowed" }, + "logic_projection": { "const": "allowed" }, + "incremental_extraction": { "const": "allowed" }, + "adapter_rewrite": { "const": "not_requested" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "mode", + "ast_analysis", + "logic_projection", + "incremental_extraction", + "adapter_rewrite", + "blocked_tools", + "instruction" + ], + "properties": { + "mode": { "const": "preserve-no-ast" }, + "ast_analysis": { "const": "forbidden" }, + "logic_projection": { "const": "forbidden" }, + "incremental_extraction": { "const": "allowed" }, + "adapter_rewrite": { "const": "forbidden" }, + "blocked_tools": { + "const": ["docforge_get_logic"] + }, + "instruction": { + "const": "Preserve the existing adapter extraction strategy. Do not add Python AST, Tree-sitter, compiler-AST, or function-Logic extraction. Non-AST incremental fingerprinting and caching remain allowed." + } + }, + "additionalProperties": false + } + ] + }, + "effective_policy": { + "type": "object", + "required": [ + "schema_version", + "capability_mode", + "capability_source", + "adapter_evolution", + "ast_analysis", + "logic_indexing", + "synchronization", + "integrity", + "manual_render", + "graph_render", + "live_viewer", + "profiling", + "blocked_tools", + "prohibitions", + "precedence" + ], + "properties": { + "schema_version": { "const": 1 }, + "capability_mode": { + "enum": ["read", "proposal", "application"] + }, + "capability_source": { "const": "explicit" }, + "adapter_evolution": { "enum": ["allowed", "preserve"] }, + "ast_analysis": { "enum": ["allowed", "forbidden"] }, + "logic_indexing": { "enum": ["full", "off"] }, + "synchronization": { "const": "automatic" }, + "integrity": { "const": "validated" }, + "manual_render": { "enum": ["auto", "explicit", "disabled"] }, + "graph_render": { "const": "disabled" }, + "live_viewer": { "const": "on-demand" }, + "profiling": { "const": "disabled" }, + "blocked_tools": { + "type": "array", + "maxItems": 1, + "items": { "const": "docforge_get_logic" }, + "uniqueItems": true + }, + "prohibitions": { + "type": "array", + "minItems": 7, + "maxItems": 11, + "items": { + "enum": [ + "arbitrary_file_access", + "arbitrary_renderer_execution", + "shell_execution", + "git_mutation", + "deployment", + "publication", + "project_switching", + "adapter_ast_upgrade", + "tree_sitter_upgrade", + "compiler_ast_upgrade", + "function_logic_extraction" + ] + }, + "uniqueItems": true + }, + "precedence": { + "const": [ + "core_safety", + "explicit_binding", + "no_ast_shorthand", + "resource_availability" + ] + } + }, + "additionalProperties": false + }, + "diagnostics": { + "type": "object", + "required": [ + "schema_version", + "operation", + "outcome", + "elapsed_ns", + "stages", + "counters" + ], + "properties": { + "schema_version": { "const": 1 }, + "operation": { "const": "cli.configure" }, + "outcome": { "const": "ok" }, + "elapsed_ns": { "type": "integer", "minimum": 0 }, + "stages": { + "type": "object", + "maxProperties": 14, + "propertyNames": { + "enum": [ + "source.generation", + "source.parse", + "adapter.projection", + "adapter.extract", + "index.check", + "index.synchronize", + "index.build", + "index.read", + "render.status", + "render.prepare", + "render.output_hash", + "visualization.status", + "viewer.manager", + "mcp.runtime_validation" + ] + }, + "additionalProperties": { + "type": "object", + "required": ["calls", "elapsed_ns"], + "properties": { + "calls": { "type": "integer", "minimum": 1 }, + "elapsed_ns": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } + }, + "counters": { + "type": "object", + "required": [ + "project_loads", + "source_files_parsed", + "source_bytes_parsed", + "adapter_projection_loads", + "adapter_source_extractions", + "source_generation_checks", + "index_checks", + "index_synchronizations", + "index_builds", + "render_prepare_calls", + "render_output_bytes_built", + "render_output_bytes_hashed", + "viewer_manager_requests" + ], + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "maxProperties": 13 + } + }, + "additionalProperties": false + } + }, + "type": "object", + "required": [ + "status", + "schema_version", + "operation", + "action", + "client", + "server_name", + "project", + "binding", + "effective_policy", + "artifact", + "configuration_hash", + "warnings" + ], + "properties": { + "status": { "const": "ok" }, + "schema_version": { "const": 1 }, + "operation": { "const": "client.configure" }, + "action": { "enum": ["preview", "write"] }, + "client": { "enum": ["codex", "claude", "openclaw"] }, + "server_name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$" + }, + "project": { + "type": "object", + "required": [ + "project_id", + "project_root", + "project_root_fingerprint", + "adapter" + ], + "properties": { + "project_id": { "type": "string", "minLength": 1 }, + "project_root": { "type": "string", "minLength": 1 }, + "project_root_fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{16}$" + }, + "adapter": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "binding": { + "type": "object", + "required": [ + "transport", + "capability_mode", + "adapter_policy", + "render_policy", + "command", + "args", + "environment", + "timeouts" + ], + "properties": { + "transport": { "const": "stdio" }, + "capability_mode": { + "enum": ["read", "proposal", "application"] + }, + "adapter_policy": { "$ref": "#/$defs/adapter_policy" }, + "render_policy": { + "type": "object", + "required": ["manual", "graph", "live_viewer"], + "properties": { + "manual": { "enum": ["auto", "explicit", "disabled"] }, + "graph": { "const": "disabled" }, + "live_viewer": { "const": "on-demand" } + }, + "additionalProperties": false + }, + "command": { "type": "string", "minLength": 1 }, + "args": { + "type": "array", + "minItems": 7, + "maxItems": 64, + "prefixItems": [ + { "const": "-I" }, + { "const": "-m" }, + { "const": "docforge.mcp_server" }, + { "const": "--project-root" }, + { "type": "string", "minLength": 1, "maxLength": 4096 }, + { "const": "--capability-mode" }, + { "enum": ["read", "proposal", "application"] } + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "environment": { + "type": "object", + "maxProperties": 0 + }, + "timeouts": { + "type": "object", + "required": ["startup_seconds", "tool_seconds"], + "properties": { + "startup_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + }, + "tool_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "effective_policy": { "$ref": "#/$defs/effective_policy" }, + "artifact": { + "type": "object", + "required": [ + "format", + "content", + "content_sha256", + "output_path", + "write_state", + "durability" + ], + "properties": { + "format": { + "enum": [ + "codex-toml-fragment-v1", + "claude-json-fragment-v1", + "openclaw-json-fragment-v1" + ] + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "content_sha256": { "$ref": "#/$defs/sha256" }, + "output_path": { + "type": ["string", "null"], + "minLength": 1 + }, + "write_state": { + "enum": ["not_requested", "created", "unchanged"] + }, + "durability": { + "enum": ["not_applicable", "confirmed", "unconfirmed"] + } + }, + "additionalProperties": false + }, + "configuration_hash": { "$ref": "#/$defs/sha256" }, + "warnings": { + "type": "array", + "maxItems": 8, + "items": { + "type": "object", + "required": ["code"], + "properties": { + "code": { + "enum": [ + "timeout_format_unverified", + "publication_durability_unconfirmed", + "publication_location_unconfirmed", + "publication_binding_unconfirmed" + ] + } + }, + "additionalProperties": false + } + }, + "diagnostics": { "$ref": "#/$defs/diagnostics" } + }, + "allOf": [ + { + "if": { + "properties": { + "warnings": { + "contains": { + "properties": { + "code": { "const": "publication_binding_unconfirmed" } + }, + "required": ["code"] + } + } + }, + "required": ["warnings"] + }, + "then": { + "properties": { + "artifact": { + "properties": { + "output_path": { "type": "null" }, + "write_state": { "const": "created" }, + "durability": { "const": "unconfirmed" } + } + } + } + } + }, + { + "if": { + "properties": { "client": { "const": "codex" } }, + "required": ["client"] + }, + "then": { + "properties": { + "artifact": { + "properties": { + "format": { "const": "codex-toml-fragment-v1" } + } + }, + "warnings": { + "not": { + "contains": { + "properties": { + "code": { "const": "timeout_format_unverified" } + }, + "required": ["code"] + } + } + } + } + } + }, + { + "if": { + "properties": { "client": { "const": "openclaw" } }, + "required": ["client"] + }, + "then": { + "properties": { + "artifact": { + "properties": { + "format": { "const": "openclaw-json-fragment-v1" } + } + }, + "warnings": { + "not": { + "contains": { + "properties": { + "code": { "const": "timeout_format_unverified" } + }, + "required": ["code"] + } + } + } + } + } + }, + { + "if": { + "properties": { "client": { "const": "claude" } }, + "required": ["client"] + }, + "then": { + "properties": { + "artifact": { + "properties": { + "format": { "const": "claude-json-fragment-v1" } + } + }, + "warnings": { + "contains": { + "properties": { + "code": { "const": "timeout_format_unverified" } + }, + "required": ["code"] + } + } + } + } + }, + { + "if": { + "properties": { "action": { "const": "preview" } }, + "required": ["action"] + }, + "then": { + "properties": { + "artifact": { + "properties": { + "output_path": { "type": "null" }, + "write_state": { "const": "not_requested" }, + "durability": { "const": "not_applicable" } + } + } + } + }, + "else": { + "properties": { + "artifact": { + "properties": { + "output_path": { + "type": ["string", "null"], + "minLength": 1 + }, + "write_state": { "enum": ["created", "unchanged"] } + }, + "allOf": [ + { + "if": { + "properties": { "write_state": { "const": "created" } }, + "required": ["write_state"] + }, + "then": { + "properties": { + "durability": { "enum": ["confirmed", "unconfirmed"] } + } + }, + "else": { + "properties": { + "durability": { "const": "not_applicable" } + } + } + } + ] + } + } + } + }, + { + "if": { + "properties": { + "binding": { + "properties": { "capability_mode": { "const": "read" } }, + "required": ["capability_mode"] + } + }, + "required": ["binding"] + }, + "then": { + "properties": { + "binding": { + "properties": { + "args": { + "prefixItems": [{}, {}, {}, {}, {}, {}, { "const": "read" }] + } + } + }, + "effective_policy": { + "properties": { "capability_mode": { "const": "read" } } + } + } + } + }, + { + "if": { + "properties": { + "binding": { + "properties": { "capability_mode": { "const": "proposal" } }, + "required": ["capability_mode"] + } + }, + "required": ["binding"] + }, + "then": { + "properties": { + "binding": { + "properties": { + "args": { + "prefixItems": [{}, {}, {}, {}, {}, {}, { "const": "proposal" }] + } + } + }, + "effective_policy": { + "properties": { "capability_mode": { "const": "proposal" } } + } + } + } + }, + { + "if": { + "properties": { + "binding": { + "properties": { "capability_mode": { "const": "application" } }, + "required": ["capability_mode"] + } + }, + "required": ["binding"] + }, + "then": { + "properties": { + "binding": { + "properties": { + "args": { + "prefixItems": [{}, {}, {}, {}, {}, {}, { "const": "application" }] + } + } + }, + "effective_policy": { + "properties": { "capability_mode": { "const": "application" } } + } + } + } + }, + { + "if": { + "properties": { + "binding": { + "properties": { + "adapter_policy": { + "properties": { "mode": { "const": "preserve-no-ast" } }, + "required": ["mode"] + } + }, + "required": ["adapter_policy"] + } + }, + "required": ["binding"] + }, + "then": { + "properties": { + "binding": { + "properties": { + "args": { + "contains": { "const": "--no-ast" }, + "minContains": 1, + "maxContains": 1 + } + } + }, + "effective_policy": { + "properties": { + "adapter_evolution": { "const": "preserve" }, + "ast_analysis": { "const": "forbidden" }, + "logic_indexing": { "const": "off" }, + "blocked_tools": { "const": ["docforge_get_logic"] }, + "prohibitions": { + "const": [ + "arbitrary_file_access", + "arbitrary_renderer_execution", + "shell_execution", + "git_mutation", + "deployment", + "publication", + "project_switching", + "adapter_ast_upgrade", + "tree_sitter_upgrade", + "compiler_ast_upgrade", + "function_logic_extraction" + ] + } + } + } + } + }, + "else": { + "properties": { + "binding": { + "properties": { + "args": { + "not": { + "contains": { "const": "--no-ast" } + } + } + } + }, + "effective_policy": { + "properties": { + "adapter_evolution": { "const": "allowed" }, + "ast_analysis": { "const": "allowed" }, + "logic_indexing": { "const": "full" }, + "blocked_tools": { "const": [] }, + "prohibitions": { + "const": [ + "arbitrary_file_access", + "arbitrary_renderer_execution", + "shell_execution", + "git_mutation", + "deployment", + "publication", + "project_switching" + ] + } + } + } + } + } + }, + { + "if": { + "properties": { + "binding": { + "properties": { + "render_policy": { + "properties": { "manual": { "const": "auto" } }, + "required": ["manual"] + } + }, + "required": ["render_policy"] + } + }, + "required": ["binding"] + }, + "then": { + "properties": { + "effective_policy": { + "properties": { "manual_render": { "const": "auto" } } + } + } + } + }, + { + "if": { + "properties": { + "binding": { + "properties": { + "render_policy": { + "properties": { "manual": { "const": "explicit" } }, + "required": ["manual"] + } + }, + "required": ["render_policy"] + } + }, + "required": ["binding"] + }, + "then": { + "properties": { + "effective_policy": { + "properties": { "manual_render": { "const": "explicit" } } + } + } + } + }, + { + "if": { + "properties": { + "binding": { + "properties": { + "render_policy": { + "properties": { "manual": { "const": "disabled" } }, + "required": ["manual"] + } + }, + "required": ["render_policy"] + } + }, + "required": ["binding"] + }, + "then": { + "properties": { + "effective_policy": { + "properties": { "manual_render": { "const": "disabled" } } + } + } + } + }, + { + "if": { + "properties": { + "artifact": { + "properties": { + "durability": { "const": "unconfirmed" } + }, + "required": ["durability"] + } + }, + "required": ["artifact"] + }, + "then": { + "properties": { + "artifact": { + "properties": { + "write_state": { "const": "created" } + } + }, + "warnings": { + "anyOf": [ + { + "contains": { + "properties": { + "code": { "const": "publication_durability_unconfirmed" } + }, + "required": ["code"] + } + }, + { + "contains": { + "properties": { + "code": { "const": "publication_location_unconfirmed" } + }, + "required": ["code"] + } + }, + { + "contains": { + "properties": { + "code": { "const": "publication_binding_unconfirmed" } + }, + "required": ["code"] + } + } + ] + } + } + } + }, + { + "if": { + "properties": { + "warnings": { + "contains": { + "properties": { + "code": { "const": "publication_location_unconfirmed" } + }, + "required": ["code"] + } + } + }, + "required": ["warnings"] + }, + "then": { + "properties": { + "artifact": { + "properties": { + "output_path": { "type": "null" }, + "write_state": { "const": "created" }, + "durability": { "const": "unconfirmed" } + } + } + } + } + }, + { + "if": { + "properties": { + "action": { "const": "write" }, + "artifact": { + "properties": { "output_path": { "type": "null" } }, + "required": ["output_path"] + } + }, + "required": ["action", "artifact"] + }, + "then": { + "properties": { + "warnings": { + "anyOf": [ + { + "contains": { + "properties": { + "code": { "const": "publication_location_unconfirmed" } + }, + "required": ["code"] + } + }, + { + "contains": { + "properties": { + "code": { "const": "publication_binding_unconfirmed" } + }, + "required": ["code"] + } + } + ] + } + } + } + }, + { + "if": { + "properties": { + "warnings": { + "contains": { + "properties": { + "code": { "const": "publication_durability_unconfirmed" } + }, + "required": ["code"] + } + } + }, + "required": ["warnings"] + }, + "then": { + "properties": { + "artifact": { + "properties": { + "write_state": { "const": "created" }, + "durability": { "const": "unconfirmed" } + } + } + } + } + } + ], + "additionalProperties": false +} diff --git a/schemas/doctor-result.schema.json b/schemas/doctor-result.schema.json new file mode 100644 index 0000000..6e7eb4a --- /dev/null +++ b/schemas/doctor-result.schema.json @@ -0,0 +1,466 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docforge.local/schema/doctor-result-v1.json", + "title": "DocForge bounded read-only integration doctor result", + "$defs": { + "diagnostics": { + "type": "object", + "required": [ + "schema_version", + "operation", + "outcome", + "elapsed_ns", + "stages", + "counters" + ], + "properties": { + "schema_version": { "const": 1 }, + "operation": { "const": "cli.doctor" }, + "outcome": { "const": "ok" }, + "elapsed_ns": { "type": "integer", "minimum": 0 }, + "stages": { + "type": "object", + "maxProperties": 14, + "propertyNames": { + "enum": [ + "source.generation", + "source.parse", + "adapter.projection", + "adapter.extract", + "index.check", + "index.synchronize", + "index.build", + "index.read", + "render.status", + "render.prepare", + "render.output_hash", + "visualization.status", + "viewer.manager", + "mcp.runtime_validation" + ] + }, + "additionalProperties": { + "type": "object", + "required": ["calls", "elapsed_ns"], + "properties": { + "calls": { "type": "integer", "minimum": 1 }, + "elapsed_ns": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } + }, + "counters": { + "type": "object", + "required": [ + "project_loads", + "source_files_parsed", + "source_bytes_parsed", + "adapter_projection_loads", + "adapter_source_extractions", + "source_generation_checks", + "index_checks", + "index_synchronizations", + "index_builds", + "render_prepare_calls", + "render_output_bytes_built", + "render_output_bytes_hashed", + "viewer_manager_requests" + ], + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "maxProperties": 13 + } + }, + "additionalProperties": false + } + }, + "type": "object", + "required": [ + "status", + "schema_version", + "doctor_state", + "client", + "project", + "config", + "summary", + "guarantees", + "checks" + ], + "properties": { + "status": { "const": "ok" }, + "schema_version": { "const": 1 }, + "doctor_state": { "enum": ["healthy", "degraded", "unhealthy"] }, + "client": { "enum": ["codex", "claude", "openclaw"] }, + "project": { + "type": "object", + "required": [ + "project_id", + "project_root", + "project_root_fingerprint", + "adapter" + ], + "properties": { + "project_id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "project_root": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "project_root_fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{16}$" + }, + "adapter": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "additionalProperties": false + }, + "config": { + "type": "object", + "required": ["path", "server_name"], + "properties": { + "path": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "server_name": { + "type": ["string", "null"], + "maxLength": 256 + } + }, + "additionalProperties": false + }, + "summary": { + "type": "object", + "required": ["passed", "warning", "failed", "skipped"], + "properties": { + "passed": { "type": "integer", "minimum": 0, "maximum": 14 }, + "warning": { "type": "integer", "minimum": 0, "maximum": 14 }, + "failed": { "type": "integer", "minimum": 0, "maximum": 14 }, + "skipped": { "type": "integer", "minimum": 0, "maximum": 14 } + }, + "additionalProperties": false + }, + "guarantees": { + "type": "object", + "required": [ + "read_only", + "project_loads", + "adapter_projection_loads", + "adapter_source_extractions", + "sqlite_opens", + "index_checks", + "index_synchronizations", + "index_builds", + "renders", + "viewer_operations", + "client_config_writes", + "configured_command_executions" + ], + "properties": { + "read_only": { "const": true }, + "project_loads": { "const": 0 }, + "adapter_projection_loads": { "const": 0 }, + "adapter_source_extractions": { "const": 0 }, + "sqlite_opens": { "const": 0 }, + "index_checks": { "const": 0 }, + "index_synchronizations": { "const": 0 }, + "index_builds": { "const": 0 }, + "renders": { "const": 0 }, + "viewer_operations": { "const": 0 }, + "client_config_writes": { "const": 0 }, + "configured_command_executions": { "const": 0 } + }, + "additionalProperties": false + }, + "checks": { + "type": "array", + "minItems": 14, + "maxItems": 14, + "items": { + "type": "object", + "required": ["check_id", "state", "code", "message", "details"], + "properties": { + "check_id": { + "enum": [ + "project.binding", + "project.canonical_validation", + "client.driver", + "client.config", + "client.entry", + "server.executable", + "server.arguments", + "server.project_binding", + "policy.effective", + "policy.no_ast", + "client.timeouts", + "client.environment", + "client.tool_filter", + "derived.index" + ] + }, + "state": { + "enum": ["passed", "warning", "failed", "skipped"] + }, + "code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "details": { + "type": "object", + "maxProperties": 16, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "additionalProperties": { + "oneOf": [ + { "type": "string", "maxLength": 512 }, + { "type": "integer" }, + { "type": "boolean" }, + { "type": "null" }, + { + "type": "array", + "maxItems": 16, + "items": { + "oneOf": [ + { "type": "string", "maxLength": 256 }, + { "type": "integer" }, + { "type": "boolean" }, + { "type": "null" } + ] + } + } + ] + } + } + }, + "additionalProperties": false + } + }, + "diagnostics": { "$ref": "#/$defs/diagnostics" } + }, + "allOf": [ + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "project.binding" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { + "check_id": { "const": "project.canonical_validation" } + }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "client.driver" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "client.config" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "client.entry" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "server.executable" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "server.arguments" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { + "check_id": { "const": "server.project_binding" } + }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "policy.effective" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "policy.no_ast" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "client.timeouts" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "client.environment" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "client.tool_filter" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "properties": { + "checks": { + "contains": { + "properties": { "check_id": { "const": "derived.index" } }, + "required": ["check_id"] + }, + "minContains": 1, + "maxContains": 1 + } + } + }, + { + "if": { + "properties": { "doctor_state": { "const": "healthy" } }, + "required": ["doctor_state"] + }, + "then": { + "properties": { + "summary": { + "properties": { + "warning": { "const": 0 }, + "failed": { "const": 0 } + } + } + } + } + }, + { + "if": { + "properties": { "doctor_state": { "const": "degraded" } }, + "required": ["doctor_state"] + }, + "then": { + "properties": { + "summary": { + "properties": { + "warning": { "minimum": 1 }, + "failed": { "const": 0 } + } + } + } + } + }, + { + "if": { + "properties": { "doctor_state": { "const": "unhealthy" } }, + "required": ["doctor_state"] + }, + "then": { + "properties": { + "summary": { + "properties": { + "failed": { "minimum": 1 } + } + } + } + } + } + ], + "additionalProperties": false +} diff --git a/schemas/result.schema.json b/schemas/result.schema.json index 0a8057e..aac219b 100644 --- a/schemas/result.schema.json +++ b/schemas/result.schema.json @@ -19,6 +19,7 @@ "enum": [ "test", "benchmark.m1", + "benchmark.m2", "mcp.invoke", "mcp.bootstrap", "mcp.sync", @@ -57,6 +58,8 @@ "cli.impact", "cli.context", "cli.generation-diff", + "cli.configure", + "cli.doctor", "cli.render", "cli.render-status", "cli.preview", diff --git a/src/docforge/cli.py b/src/docforge/cli.py index 03e1f62..35b55d2 100644 --- a/src/docforge/cli.py +++ b/src/docforge/cli.py @@ -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 diff --git a/src/docforge/client_config.py b/src/docforge/client_config.py new file mode 100644 index 0000000..7b482d5 --- /dev/null +++ b/src/docforge/client_config.py @@ -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 diff --git a/src/docforge/doctor.py b/src/docforge/doctor.py new file mode 100644 index 0000000..e79ed61 --- /dev/null +++ b/src/docforge/doctor.py @@ -0,0 +1,1287 @@ +"""Bounded, non-mutating checks for one project-bound client integration.""" + +from __future__ import annotations + +import hashlib +import json +import os +import stat +import sys +import tomllib +from contextlib import suppress +from pathlib import Path +from typing import Literal, cast + +from .client_config import CLIENT_NAMES, ClientName +from .errors import DocForgeError +from .models import ProjectService +from .policy import CapabilityMode, compose_effective_policy +from .project import ( + project_root_fingerprint, + validate_descriptor_binding, +) + +MAX_CLIENT_CONFIG_BYTES = 1_000_000 +MAX_CLIENT_SERVERS = 256 +MAX_CLIENT_ARGUMENTS = 64 +MAX_CLIENT_ARGUMENT_CHARS = 4_096 +MAX_CLIENT_ENVIRONMENT_KEYS = 64 +RISKY_ENVIRONMENT_KEYS = frozenset({"LD_PRELOAD", "PYTHONHOME", "PYTHONPATH", "PYTHONSTARTUP"}) + +CheckState = Literal["passed", "warning", "failed", "skipped"] +DOCTOR_RESULT_LIMIT_BYTES = 32_768 +CHECK_IDS = ( + "project.binding", + "project.canonical_validation", + "client.driver", + "client.config", + "client.entry", + "server.executable", + "server.arguments", + "server.project_binding", + "policy.effective", + "policy.no_ast", + "client.timeouts", + "client.environment", + "client.tool_filter", + "derived.index", +) + + +def _check( + check_id: str, + state: CheckState, + code: str, + message: str, + **details: object, +) -> dict[str, object]: + bounded_details: dict[str, object] = {} + for key, value in sorted(details.items())[:16]: + if isinstance(value, str): + bounded_details[key] = value[:512] + elif isinstance(value, (bool, int)) or value is None: + bounded_details[key] = value + elif isinstance(value, list): + items = cast(list[object], value) + bounded_details[key] = [ + item[:256] if isinstance(item, str) else item + for item in items[:16] + if isinstance(item, (str, bool, int)) or item is None + ] + return { + "check_id": check_id, + "state": state, + "code": code, + "message": message, + "details": bounded_details, + } + + +def _replace_check( + checks: list[dict[str, object]], + replacement: dict[str, object], +) -> None: + check_id = replacement["check_id"] + for index, check in enumerate(checks): + if check["check_id"] == check_id: + checks[index] = replacement + return + raise AssertionError(f"Doctor check {check_id!r} was not initialized") + + +def _skipped(check_id: str, code: str, message: str) -> dict[str, object]: + return _check(check_id, "skipped", code, message) + + +def _validate_doctor_result(result: dict[str, object]) -> None: + checks = cast(list[dict[str, object]], result["checks"]) + check_ids = [cast(str, check["check_id"]) for check in checks] + if tuple(check_ids) != CHECK_IDS: + raise AssertionError("Doctor did not emit its exact ordered check inventory") + expected = { + state: sum(check["state"] == state for check in checks) + for state in ("passed", "warning", "failed", "skipped") + } + if result["summary"] != expected: + raise AssertionError("Doctor summary does not match its checks") + expected_state = ( + "unhealthy" if expected["failed"] else ("degraded" if expected["warning"] else "healthy") + ) + if result["doctor_state"] != expected_state: + raise AssertionError("Doctor health does not match its checks") + if len(json.dumps(result, sort_keys=True, separators=(",", ":")).encode("utf-8")) > ( + DOCTOR_RESULT_LIMIT_BYTES + ): + raise AssertionError("Doctor result exceeded its bounded response contract") + + +def _default_config(client: ClientName) -> Path: + if client == "codex": + codex_home = os.environ.get("CODEX_HOME") + if codex_home: + return Path(codex_home).expanduser() / "config.toml" + return Path.home() / ".codex" / "config.toml" + if client == "openclaw": + explicit = os.environ.get("OPENCLAW_CONFIG_PATH") + if explicit: + return Path(explicit).expanduser() + state_root = os.environ.get("OPENCLAW_STATE_DIR") + if state_root: + return Path(state_root).expanduser() / "openclaw.json" + return Path.home() / ".openclaw" / "openclaw.json" + claude_root = os.environ.get("CLAUDE_CONFIG_DIR") + if claude_root: + return Path(claude_root).expanduser() / ".claude.json" + return Path.home() / ".claude.json" + + +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 _read_stable_regular( + path: Path, +) -> tuple[bytes, tuple[int, int, int, int, int]]: + absolute = Path(os.path.abspath(path.expanduser())) + try: + parent = absolute.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( + "client_config_unsafe", + "Client configuration parent is not one real directory", + ) + directory_fd = os.open( + parent, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + ) + except FileNotFoundError as error: + raise DocForgeError( + "client_config_missing", + "Client configuration does not exist", + ) from error + except OSError as error: + raise DocForgeError( + "client_config_unsafe", + "Client configuration 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( + "client_config_changed", + "Client configuration parent changed while it was opened", + ) + try: + if not _parent_binding_current(parent, directory_fd): + raise DocForgeError( + "client_config_changed", + "Client configuration parent changed before it was read", + ) + try: + before = os.stat( + absolute.name, + dir_fd=directory_fd, + follow_symlinks=False, + ) + except FileNotFoundError as error: + raise DocForgeError( + "client_config_missing", + "Client configuration does not exist", + ) from error + except OSError as error: + raise DocForgeError( + "client_config_unsafe", + "Client configuration cannot be inspected safely", + ) from error + if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode): + raise DocForgeError( + "client_config_unsafe", + "Client configuration must be a regular file and not a symbolic link", + ) + if before.st_size > MAX_CLIENT_CONFIG_BYTES: + raise DocForgeError( + "client_config_oversized", + "Client configuration exceeds the bounded doctor limit", + maximum_bytes=MAX_CLIENT_CONFIG_BYTES, + ) + try: + descriptor = os.open( + absolute.name, + os.O_RDONLY | os.O_NOFOLLOW, + dir_fd=directory_fd, + ) + except OSError as error: + raise DocForgeError( + "client_config_unsafe", + "Client configuration 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( + "client_config_changed", + "Client configuration changed while it was opened", + ) + chunks: list[bytes] = [] + remaining = MAX_CLIENT_CONFIG_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_CLIENT_CONFIG_BYTES: + raise DocForgeError( + "client_config_oversized", + "Client configuration exceeds the bounded doctor limit", + maximum_bytes=MAX_CLIENT_CONFIG_BYTES, + ) + try: + after = os.stat( + absolute.name, + dir_fd=directory_fd, + follow_symlinks=False, + ) + except OSError as error: + raise DocForgeError( + "client_config_changed", + "Client configuration changed while it was read", + ) from error + before_identity = ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) + after_identity = ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ) + if before_identity != after_identity: + raise DocForgeError( + "client_config_changed", + "Client configuration changed while it was read", + ) + if not _parent_binding_current(parent, directory_fd): + raise DocForgeError( + "client_config_changed", + "Client configuration parent changed while it was read", + ) + return raw, before_identity + finally: + with suppress(OSError): + os.close(directory_fd) + + +def _safe_regular_state(path: Path) -> Literal["missing", "present", "unsafe"]: + absolute = Path(os.path.abspath(path)) + parent = absolute.parent + try: + 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 + ): + return "unsafe" + directory_fd = os.open( + parent, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + ) + except FileNotFoundError: + return "missing" + except OSError: + return "unsafe" + try: + opened = os.fstat(directory_fd) + if opened.st_dev != parent_status.st_dev or opened.st_ino != parent_status.st_ino: + return "unsafe" + if not _parent_binding_current(parent, directory_fd): + return "unsafe" + try: + before = os.stat( + absolute.name, + dir_fd=directory_fd, + follow_symlinks=False, + ) + except FileNotFoundError: + return "missing" if _parent_binding_current(parent, directory_fd) else "unsafe" + except OSError: + return "unsafe" + state: Literal["present", "unsafe"] = ( + "present" + if stat.S_ISREG(before.st_mode) and not stat.S_ISLNK(before.st_mode) + else "unsafe" + ) + try: + after = os.stat( + absolute.name, + dir_fd=directory_fd, + follow_symlinks=False, + ) + except OSError: + return "unsafe" + if ( + before.st_dev, + before.st_ino, + before.st_mode, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) != ( + after.st_dev, + after.st_ino, + after.st_mode, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ): + return "unsafe" + return state if _parent_binding_current(parent, directory_fd) else "unsafe" + finally: + with suppress(OSError): + os.close(directory_fd) + + +def _safe_directory(path: Path) -> bool: + if not path.is_absolute(): + return False + try: + before = path.lstat() + if stat.S_ISLNK(before.st_mode) or not stat.S_ISDIR(before.st_mode): + return False + directory_fd = os.open( + path, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + ) + except OSError: + return False + try: + resolved = path.resolve(strict=True) + after = path.lstat() + opened = os.fstat(directory_fd) + return 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 + finally: + with suppress(OSError): + os.close(directory_fd) + + +def _server_documents(client: ClientName, raw: bytes) -> dict[str, object]: + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as error: + raise DocForgeError( + "client_config_invalid", + "Client configuration is not UTF-8", + ) from error + try: + if client == "codex": + document = cast(dict[str, object], tomllib.loads(text)) + servers = document.get("mcp_servers", {}) + else: + loaded = cast(object, json.loads(text)) + if not isinstance(loaded, dict): + raise TypeError + document = cast(dict[str, object], loaded) + if client == "openclaw": + mcp_value = document.get("mcp", {}) + if not isinstance(mcp_value, dict): + raise TypeError + mcp = cast(dict[str, object], mcp_value) + servers = mcp.get("servers", {}) + else: + servers = document.get("mcpServers", {}) + except ( + json.JSONDecodeError, + tomllib.TOMLDecodeError, + RecursionError, + TypeError, + ValueError, + ) as error: + raise DocForgeError( + "client_config_invalid", + "Client configuration has invalid syntax or structure", + ) from error + if not isinstance(servers, dict): + raise DocForgeError( + "client_config_invalid", + "Client MCP server collection must be an object", + ) + server_map = cast(dict[object, object], servers) + if len(server_map) > MAX_CLIENT_SERVERS: + raise DocForgeError( + "client_config_oversized", + "Client configuration declares too many MCP servers", + maximum_servers=MAX_CLIENT_SERVERS, + ) + if any(not isinstance(name, str) or len(name) > 256 for name in server_map): + raise DocForgeError( + "client_config_invalid", + "Client MCP server names are invalid", + ) + return cast(dict[str, object], server_map) + + +def _normalize_entry(name: str, value: object, *, client: ClientName) -> dict[str, object]: + if not isinstance(value, dict): + raise DocForgeError( + "client_entry_invalid", + "Client MCP server entry must be an object", + server_name=name, + ) + entry = cast(dict[str, object], value) + allowed_fields = { + "codex": { + "command", + "args", + "env", + "startup_timeout_sec", + "tool_timeout_sec", + "tools", + }, + "openclaw": { + "command", + "args", + "env", + "connectTimeout", + "timeout", + "supportsParallelToolCalls", + "cwd", + "toolFilter", + }, + "claude": {"command", "args", "env"}, + }[client] + unknown_fields = sorted(set(entry) - allowed_fields) + if unknown_fields: + raise DocForgeError( + "client_entry_invalid", + "Client MCP server entry contains unsupported fields", + server_name=name, + field_count=len(unknown_fields), + fields_sha256=hashlib.sha256("\0".join(unknown_fields).encode("utf-8")).hexdigest(), + ) + command = entry.get("command") + arguments_value = entry.get("args", []) + environment_value = entry.get("env", {}) + if ( + not isinstance(command, str) + or not command + or len(command) > MAX_CLIENT_ARGUMENT_CHARS + or "\0" in command + or not isinstance(arguments_value, list) + or not isinstance(environment_value, dict) + ): + raise DocForgeError( + "client_entry_invalid", + "Client MCP server command, arguments, or environment is invalid", + server_name=name, + ) + arguments = cast(list[object], arguments_value) + if len(arguments) > MAX_CLIENT_ARGUMENTS or any( + not isinstance(argument, str) + or len(argument) > MAX_CLIENT_ARGUMENT_CHARS + or "\0" in argument + for argument in arguments + ): + raise DocForgeError( + "client_entry_invalid", + "Client MCP server arguments exceed the bounded contract", + server_name=name, + ) + environment = cast(dict[object, object], environment_value) + if len(environment) > MAX_CLIENT_ENVIRONMENT_KEYS or any( + not isinstance(value, str) or len(value) > MAX_CLIENT_ARGUMENT_CHARS or "\0" in value + for value in environment.values() + ): + raise DocForgeError( + "client_entry_invalid", + "Client MCP server environment values exceed the bounded contract", + server_name=name, + ) + raw_environment_keys = list(environment) + if any(not isinstance(key, str) or len(key) > 256 for key in raw_environment_keys): + raise DocForgeError( + "client_entry_invalid", + "Client MCP server environment keys are invalid", + server_name=name, + ) + environment_keys = sorted(cast(list[str], raw_environment_keys)) + startup: object = None + tool: object = None + if client == "codex": + startup = entry.get("startup_timeout_sec") + tool = entry.get("tool_timeout_sec") + elif client == "openclaw": + startup = entry.get("connectTimeout") + tool = entry.get("timeout") + cwd = entry.get("cwd") + if cwd is not None and ( + not isinstance(cwd, str) or not cwd or len(cwd) > MAX_CLIENT_ARGUMENT_CHARS or "\0" in cwd + ): + raise DocForgeError( + "client_entry_invalid", + "Client MCP working directory is invalid", + server_name=name, + ) + parallel_calls = entry.get("supportsParallelToolCalls") + if parallel_calls is not None and type(parallel_calls) is not bool: + raise DocForgeError( + "client_entry_invalid", + "Client parallel-call setting must be Boolean", + server_name=name, + ) + filter_value = entry.get("toolFilter", entry.get("tools")) + filter_valid = True + if filter_value is not None: + if not isinstance(filter_value, dict): + filter_valid = False + elif client == "openclaw": + filter_document = cast(dict[object, object], filter_value) + filter_valid = set(filter_document).issubset({"include", "exclude"}) and all( + isinstance(values, list) + and len(cast(list[object], values)) <= 256 + and all( + isinstance(item, str) and 0 < len(item) <= 256 and "\0" not in item + for item in cast(list[object], values) + ) + for values in filter_document.values() + ) + if not filter_valid: + raise DocForgeError( + "client_entry_invalid", + "Client tool-filter setting is malformed", + server_name=name, + ) + return { + "server_name": name, + "command": command, + "args": cast(list[str], arguments), + "environment_keys": environment_keys, + "startup_timeout": startup, + "tool_timeout": tool, + "cwd": cwd, + "tool_filter_present": filter_value is not None, + "parallel_calls": parallel_calls, + } + + +def _parse_binding(arguments: list[str]) -> dict[str, object]: + remaining = list(arguments) + if remaining[:3] != ["-I", "-m", "docforge.mcp_server"]: + raise DocForgeError( + "server_arguments_invalid", + "Python launch arguments must begin with the isolated DocForge module prefix", + ) + remaining = remaining[3:] + values: dict[str, str] = {} + flags: set[str] = set() + value_options = { + "--project-root", + "--proposal-writer", + "--canonical-applier", + "--capability-mode", + } + flag_options = {"--no-ast", "--diagnostics"} + position = 0 + while position < len(remaining): + option = remaining[position] + if option in value_options: + if option in values or position + 1 >= len(remaining): + raise DocForgeError( + "server_arguments_invalid", + "Server arguments contain a duplicate or missing option value", + option=option, + ) + values[option] = remaining[position + 1] + position += 2 + continue + if option in flag_options: + if option in flags: + raise DocForgeError( + "server_arguments_invalid", + "Server arguments contain a duplicate flag", + option=option, + ) + flags.add(option) + position += 1 + continue + raise DocForgeError( + "server_arguments_invalid", + "Server arguments contain an unsupported option", + argument_index=position + 3, + argument_sha256=hashlib.sha256(option.encode("utf-8")).hexdigest(), + ) + selected_mode = values.get("--capability-mode") + implicit = selected_mode is None + if selected_mode is None: + selected_mode = "application" if "--canonical-applier" in values else "proposal" + if selected_mode not in {"read", "proposal", "application", "operator"}: + raise DocForgeError( + "server_arguments_invalid", + "Configured capability mode is unsupported", + ) + return { + "project_root": values.get("--project-root"), + "proposal_writer": values.get("--proposal-writer"), + "canonical_applier": values.get("--canonical-applier"), + "capability_mode": selected_mode, + "capability_mode_implicit": implicit, + "no_ast": "--no-ast" in flags, + "diagnostics": "--diagnostics" in flags, + } + + +def _partial_project_roots(value: object) -> tuple[str, ...]: + if not isinstance(value, dict): + return () + entry = cast(dict[str, object], value) + arguments_value = entry.get("args") + if not isinstance(arguments_value, list): + return () + arguments = cast(list[object], arguments_value) + roots: list[str] = [] + for position, argument in enumerate(arguments[:-1]): + if argument == "--project-root" and isinstance(arguments[position + 1], str): + roots.append(cast(str, arguments[position + 1])) + return tuple(roots) + + +def _entry_for_project( + servers: dict[str, object], + *, + client: ClientName, + project_root: Path, + server_name: str | None, +) -> dict[str, object] | None: + if server_name is not None: + value = servers.get(server_name) + return None if value is None else _normalize_entry(server_name, value, client=client) + candidates: list[tuple[str, object]] = [] + for name, value in sorted(servers.items()): + for configured_root in _partial_project_roots(value): + try: + configured_path = Path(os.path.normpath(configured_root)) + matches = configured_path.is_absolute() and configured_path == project_root + except ValueError: + matches = False + if matches: + candidates.append((name, value)) + break + if len(candidates) > 1: + raise DocForgeError( + "client_entry_ambiguous", + "More than one client entry binds the project; select --server-name", + count=len(candidates), + ) + if not candidates: + return None + name, value = candidates[0] + return _normalize_entry(name, value, client=client) + + +def _runtime_policy_check( + project: ProjectService, + binding: dict[str, object], +) -> tuple[CheckState, str, str]: + mode = cast(str, binding["capability_mode"]) + selected_mode = cast(CapabilityMode, mode) + proposal_writer = cast(str | None, binding["proposal_writer"]) + canonical_applier = cast(str | None, binding["canonical_applier"]) + writer_ids = {writer.writer_id for writer in project.descriptor.proposal_writers} + if proposal_writer is not None and proposal_writer not in writer_ids: + raise DocForgeError( + "effective_policy_invalid", + "Configured proposal writer is not declared by the project", + ) + if canonical_applier is not None and canonical_applier not in writer_ids: + raise DocForgeError( + "effective_policy_invalid", + "Configured canonical applier is not declared by the project", + ) + if selected_mode == "application" and canonical_applier is None: + raise DocForgeError( + "effective_policy_invalid", + "Application capability requires a canonical applier", + ) + compose_effective_policy( + selected_mode=selected_mode, + capability_source=( + "factory_default" if cast(bool, binding["capability_mode_implicit"]) else "explicit" + ), + no_ast=cast(bool, binding["no_ast"]), + diagnostics=cast(bool, binding["diagnostics"]), + render_configured=project.descriptor.render is not None, + application_enabled=( + canonical_applier is not None and selected_mode in {"application", "operator"} + ), + ) + if cast(bool, binding["capability_mode_implicit"]): + return ( + "warning", + "capability_mode_implicit", + "Legacy configuration infers capability mode.", + ) + if selected_mode == "read" and (proposal_writer is not None or canonical_applier is not None): + return ( + "warning", + "read_authority_shadowed", + "Read mode shadows configured proposal or application authority.", + ) + if selected_mode == "proposal" and proposal_writer is None: + return ( + "warning", + "proposal_access_disabled", + "Proposal surface is present, but mutation access has no writer.", + ) + if selected_mode == "operator": + return ( + "warning", + "operator_mode_reserved", + "Operator mode is valid but currently adds no tools.", + ) + return ( + "passed", + "effective_policy_valid", + "Configured capability and authority are valid.", + ) + + +def run_doctor( + project: ProjectService, + client: str, + *, + config_path: Path | None = None, + server_name: str | None = None, +) -> dict[str, object]: + """Inspect one client binding without loading project sources or mutating state.""" + + if client not in CLIENT_NAMES: + raise DocForgeError( + "unsupported_client", + "Doctor client is unsupported", + client=client, + allowed=list(CLIENT_NAMES), + ) + selected_client = client + descriptor = project.descriptor + validate_descriptor_binding(descriptor) + checks: list[dict[str, object]] = [ + _check( + "project.binding", + "passed", + "project_binding_valid", + "Project descriptor and root binding are valid.", + ), + _check( + "project.canonical_validation", + "skipped", + "canonical_validation_not_run", + "Doctor does not parse canonical project sources.", + ), + _check( + "client.driver", + "passed" if selected_client != "claude" else "warning", + ( + "client_driver_valid" + if selected_client != "claude" + else "client_driver_format_partially_verified" + ), + ( + "Client configuration format is supported." + if selected_client != "claude" + else "Claude fragment syntax is supported, but timeout fields are unverified." + ), + ), + ] + candidate_path = str((config_path or _default_config(selected_client)).expanduser()) + path_error: DocForgeError | None = None + if "\0" in candidate_path or len(candidate_path) > 4_096: + path_hash = hashlib.sha256(candidate_path.encode("utf-8", errors="replace")).hexdigest() + selected_path = Path.cwd() / ".invalid-docforge-client-config" + displayed_path = f"" + path_error = DocForgeError( + "client_config_invalid", + "Client configuration path is empty, oversized, or contains NUL", + path_sha256=path_hash, + ) + else: + selected_path = Path(os.path.abspath(candidate_path)) + displayed_path = str(selected_path) + selected_server_name = server_name + server_name_error: DocForgeError | None = None + if selected_server_name is not None and ( + not selected_server_name or len(selected_server_name) > 256 or "\0" in selected_server_name + ): + name_hash = hashlib.sha256( + selected_server_name.encode("utf-8", errors="replace") + ).hexdigest() + selected_server_name = None + server_name_error = DocForgeError( + "client_entry_invalid", + "Explicit server name is empty, oversized, or contains NUL", + server_name_sha256=name_hash, + ) + entry: dict[str, object] | None = None + binding: dict[str, object] | None = None + servers: dict[str, object] | None = None + initial_config: bytes | None = None + initial_config_identity: tuple[int, int, int, int, int] | None = None + try: + if path_error is not None: + raise path_error + initial_config, initial_config_identity = _read_stable_regular(selected_path) + servers = _server_documents(selected_client, initial_config) + checks.append( + _check( + "client.config", + "passed", + "client_config_valid", + "Client configuration is bounded, stable, and parseable.", + server_count=len(servers), + ) + ) + except DocForgeError as error: + checks.append( + _check( + "client.config", + "failed", + error.code, + error.message, + **error.details, + ) + ) + if servers is None: + checks.append( + _skipped( + "client.entry", + "client_config_unavailable", + "Client entry selection requires one valid configuration.", + ) + ) + elif server_name_error is not None: + checks.append( + _check( + "client.entry", + "failed", + server_name_error.code, + server_name_error.message, + **server_name_error.details, + ) + ) + else: + try: + entry = _entry_for_project( + servers, + client=selected_client, + project_root=descriptor.root, + server_name=selected_server_name, + ) + checks.append( + _check( + "client.entry", + "passed" if entry is not None else "failed", + "client_entry_valid" if entry is not None else "client_entry_missing", + ( + "One client entry uniquely binds this project." + if entry is not None + else "No client entry binds this project." + ), + **({"server_name": entry["server_name"]} if entry is not None else {}), + ) + ) + except DocForgeError as error: + checks.append( + _check( + "client.entry", + "failed", + error.code, + error.message, + **error.details, + ) + ) + + if entry is None: + checks.extend( + ( + _skipped( + "server.executable", + "client_entry_unavailable", + "Executable validation requires one selected client entry.", + ), + _skipped( + "server.arguments", + "client_entry_unavailable", + "Argument validation requires one selected client entry.", + ), + ) + ) + else: + command = cast(str, entry["command"]) + expected = os.path.abspath(sys.executable) + executable_valid = False + if command == expected: + try: + command_path = Path(expected) + command_status = command_path.stat() + executable_valid = ( + command_path.is_absolute() + and stat.S_ISREG(command_status.st_mode) + and os.access(command_path, os.X_OK) + ) + except (OSError, ValueError): + pass + checks.append( + _check( + "server.executable", + "passed" if executable_valid and command == expected else "failed", + ( + "server_executable_valid" + if executable_valid and command == expected + else "server_executable_unexpected" + ), + ( + "Configured server uses the current absolute Python executable." + if executable_valid and command == expected + else "Configured server executable is missing, unsafe, or unexpected." + ), + ) + ) + try: + binding = _parse_binding(cast(list[str], entry["args"])) + checks.append( + _check( + "server.arguments", + "passed", + "server_arguments_valid", + "Server arguments use the closed DocForge option set.", + ) + ) + except DocForgeError as error: + checks.append( + _check( + "server.arguments", + "failed", + error.code, + error.message, + **error.details, + ) + ) + + if entry is None or binding is None: + prerequisite = ( + "client_entry_unavailable" if entry is None else "server_arguments_unavailable" + ) + checks.extend( + _skipped( + check_id, + prerequisite, + "Check requires one selected entry with valid server arguments.", + ) + for check_id in ( + "server.project_binding", + "policy.effective", + "policy.no_ast", + "client.timeouts", + "client.environment", + "client.tool_filter", + ) + ) + else: + configured_root = binding["project_root"] + root_matches = False + if isinstance(configured_root, str): + try: + configured_path = Path(os.path.normpath(configured_root)) + root_matches = configured_path.is_absolute() and configured_path == descriptor.root + except ValueError: + root_matches = False + checks.append( + _check( + "server.project_binding", + "passed" if root_matches else "failed", + "project_binding_matches" if root_matches else "project_binding_mismatch", + ( + "Configured project root matches the inspected project." + if root_matches + else "Configured project root does not match the inspected project." + ), + ) + ) + try: + policy_state, policy_code, policy_message = _runtime_policy_check( + project, + binding, + ) + checks.append( + _check( + "policy.effective", + policy_state, + policy_code, + policy_message, + ) + ) + except DocForgeError as error: + checks.append( + _check( + "policy.effective", + "failed", + error.code, + error.message, + **error.details, + ) + ) + no_ast = cast(bool, binding["no_ast"]) + checks.append( + _check( + "policy.no_ast", + "passed", + "no_ast_policy_valid" if no_ast else "no_ast_disabled", + ( + "No-AST binding blocks Logic publication and retrieval surfaces." + if no_ast + else "No-AST compatibility shorthand is not enabled." + ), + adapter_internals="unverifiable" if no_ast else "not_applicable", + ) + ) + startup = entry["startup_timeout"] + tool = entry["tool_timeout"] + timeouts_valid = ( + type(startup) is int + and 1 <= startup <= 3_600 + and type(tool) is int + and 1 <= tool <= 86_400 + ) + timeouts_invalid = ( + startup is not None and not (type(startup) is int and 1 <= startup <= 3_600) + ) or (tool is not None and not (type(tool) is int and 1 <= tool <= 86_400)) + checks.append( + _check( + "client.timeouts", + ("passed" if timeouts_valid else ("failed" if timeouts_invalid else "warning")), + ( + "client_timeouts_valid" + if timeouts_valid + else ( + "client_timeouts_invalid" + if timeouts_invalid + else "client_timeouts_unverified" + ) + ), + ( + "Client timeouts are explicit and bounded." + if timeouts_valid + else ( + "Client timeout representation is invalid." + if timeouts_invalid + else "Client timeout representation is missing or unverified." + ) + ), + ) + ) + environment_keys = cast(list[str], entry["environment_keys"]) + risky_environment = sorted(set(environment_keys).intersection(RISKY_ENVIRONMENT_KEYS)) + checks.append( + _check( + "client.environment", + "passed" if not environment_keys else "warning", + ( + "client_environment_valid" + if not environment_keys + else ( + "client_environment_risky" + if risky_environment + else "client_environment_present" + ) + ), + ( + "Client entry inherits no secret-bearing environment values." + if not environment_keys + else ( + "Client entry declares environment keys; " + "values were not returned or logged." + ) + ), + environment_keys=environment_keys, + risky_environment_keys=risky_environment, + ) + ) + tool_filter_present = cast(bool, entry["tool_filter_present"]) + parallel_calls = entry["parallel_calls"] + cwd = entry["cwd"] + cwd_valid = True + if cwd is not None: + try: + cwd_valid = _safe_directory(Path(cast(str, cwd))) + except ValueError: + cwd_valid = False + runtime_controls_present = tool_filter_present or parallel_calls is True + checks.append( + _check( + "client.tool_filter", + ( + "failed" + if not cwd_valid + else ("warning" if runtime_controls_present else "passed") + ), + ( + "client_cwd_invalid" + if not cwd_valid + else ( + "client_tool_filter_unverified" + if tool_filter_present + else ( + "client_parallel_calls_unverified" + if parallel_calls is True + else "client_tool_filter_not_configured" + ) + ) + ), + ( + "Client working directory is missing, unsafe, or not absolute." + if not cwd_valid + else ( + "Client runtime filtering or parallel-call controls are not interpreted." + if runtime_controls_present + else "Server-side capability registration is authoritative." + ) + ), + parallel_calls=parallel_calls, + tool_filter_present=tool_filter_present, + ) + ) + + index_state = _safe_regular_state(descriptor.index_path) + checks.append( + _check( + "derived.index", + "warning" if index_state != "present" else "passed", + { + "present": "index_present_unverified", + "missing": "index_missing", + "unsafe": "index_unsafe", + }[index_state], + ( + "Index exists but was not opened or validated." + if index_state == "present" + else ( + "Index is absent and may be built by an explicit bootstrap." + if index_state == "missing" + else "Index path is not a safe regular file." + ) + ), + ) + ) + if initial_config is not None and initial_config_identity is not None: + try: + final_config, final_config_identity = _read_stable_regular(selected_path) + if final_config != initial_config or final_config_identity != initial_config_identity: + raise DocForgeError( + "client_config_changed", + "Client configuration changed during doctor inspection", + ) + except DocForgeError as error: + _replace_check( + checks, + _check( + "client.config", + "failed", + error.code, + error.message, + **error.details, + ), + ) + counts = { + state: sum(check["state"] == state for check in checks) + for state in ("passed", "warning", "failed", "skipped") + } + doctor_state = ( + "unhealthy" if counts["failed"] else ("degraded" if counts["warning"] else "healthy") + ) + validate_descriptor_binding(descriptor) + result: dict[str, object] = { + "status": "ok", + "schema_version": 1, + "doctor_state": doctor_state, + "client": selected_client, + "project": { + "project_id": descriptor.project_id, + "project_root": str(descriptor.root), + "project_root_fingerprint": project_root_fingerprint(descriptor.root), + "adapter": descriptor.adapter, + }, + "config": { + "path": displayed_path, + "server_name": ( + entry["server_name"] + if entry is not None + else ( + selected_server_name + if selected_server_name is not None + else ( + None + if server_name is None + else ( + "" + ) + ) + ) + ), + }, + "summary": counts, + "guarantees": { + "read_only": True, + "project_loads": 0, + "adapter_projection_loads": 0, + "adapter_source_extractions": 0, + "sqlite_opens": 0, + "index_checks": 0, + "index_synchronizations": 0, + "index_builds": 0, + "renders": 0, + "viewer_operations": 0, + "client_config_writes": 0, + "configured_command_executions": 0, + }, + "checks": checks, + } + _validate_doctor_result(result) + return result diff --git a/src/docforge/project.py b/src/docforge/project.py index 29995cc..3d689bd 100644 --- a/src/docforge/project.py +++ b/src/docforge/project.py @@ -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, diff --git a/src/docforge/telemetry.py b/src/docforge/telemetry.py index c40cb7b..4c9c6d8 100644 --- a/src/docforge/telemetry.py +++ b/src/docforge/telemetry.py @@ -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", diff --git a/tests/test_client_integration.py b/tests/test_client_integration.py new file mode 100644 index 0000000..778b96a --- /dev/null +++ b/tests/test_client_integration.py @@ -0,0 +1,1264 @@ +from __future__ import annotations + +import contextlib +import io +import json +import os +import shutil +import stat +import tempfile +import tomllib +import unittest +from collections.abc import Callable +from dataclasses import replace +from pathlib import Path +from unittest import mock + +from jsonschema import Draft202012Validator +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +from docforge.cli import _parser, _run, main +from docforge.client_config import ( + _read_existing, + _validate_configuration_result, + generate_client_configuration, +) +from docforge.doctor import run_doctor +from docforge.errors import DocForgeError +from docforge.index import ProjectIndex +from docforge.mcp_server import READ_TOOLS +from docforge.project import MAX_PROJECT_DESCRIPTOR_BYTES, Project + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests" / "fixtures" +SCHEMAS = ROOT / "schemas" +CONFIGURATION_SCHEMA = json.loads( + (SCHEMAS / "client-configuration.schema.json").read_text(encoding="utf-8") +) +DOCTOR_SCHEMA = json.loads((SCHEMAS / "doctor-result.schema.json").read_text(encoding="utf-8")) +POLICY_SCHEMA = json.loads((SCHEMAS / "policy.schema.json").read_text(encoding="utf-8")) + + +class ClientIntegrationTests(unittest.TestCase): + def copy_fixture(self, destination: Path) -> Path: + root = destination / "alpha" + shutil.copytree(FIXTURES / "alpha", root) + return root + + @staticmethod + def tree_snapshot(root: Path) -> dict[str, tuple[int, int, str]]: + return { + path.relative_to(root).as_posix(): ( + path.stat().st_mode, + path.stat().st_size, + path.read_bytes().hex(), + ) + for path in sorted(root.rglob("*")) + if path.is_file() + } + + def test_exact_outline_cli_forms_and_legacy_project_root_forms_parse(self) -> None: + parser = _parser() + for client in ("codex", "claude", "openclaw"): + configured = parser.parse_args(["configure", client, "--project", "/tmp/project"]) + self.assertEqual("configure", configured.command) + self.assertEqual(client, configured.client) + self.assertEqual(Path("/tmp/project"), configured.project) + doctor = parser.parse_args(["doctor", "--client", "codex"]) + self.assertEqual("doctor", doctor.command) + self.assertIsNone(doctor.project) + legacy = parser.parse_args(["--project-root", "/tmp/project", "info"]) + self.assertEqual(Path("/tmp/project"), legacy.project_root) + with self.assertRaises(DocForgeError) as missing: + _run(parser.parse_args(["info"])) + self.assertEqual("missing_project_root", missing.exception.code) + + def test_configuration_preview_is_deterministic_parseable_and_secret_free(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture(Path(directory)) + project = Project.open(root) + before = self.tree_snapshot(root) + previous = os.environ.get("DOCFORGE_TEST_SECRET") + os.environ["DOCFORGE_TEST_SECRET"] = "must-not-appear" + try: + results: dict[str, dict[str, object]] = {} + for client in ("codex", "claude", "openclaw"): + first = generate_client_configuration(project, client, no_ast=True) + second = generate_client_configuration(project, client, no_ast=True) + self.assertEqual(first, second) + Draft202012Validator(CONFIGURATION_SCHEMA).validate(first) + Draft202012Validator(POLICY_SCHEMA).validate(first["effective_policy"]) + self.assertEqual("read", first["binding"]["capability_mode"]) + self.assertEqual( + "preserve-no-ast", + first["binding"]["adapter_policy"]["mode"], + ) + self.assertEqual({}, first["binding"]["environment"]) + self.assertNotIn( + "must-not-appear", + json.dumps(first, sort_keys=True), + ) + results[client] = first + finally: + if previous is None: + os.environ.pop("DOCFORGE_TEST_SECRET", None) + else: + os.environ["DOCFORGE_TEST_SECRET"] = previous + + codex_content = results["codex"]["artifact"]["content"] + self.assertIn("mcp_servers", tomllib.loads(codex_content)) + openclaw_content = results["openclaw"]["artifact"]["content"] + self.assertIn("mcp", json.loads(openclaw_content)) + claude_content = results["claude"]["artifact"]["content"] + self.assertIn("mcpServers", json.loads(claude_content)) + self.assertEqual(before, self.tree_snapshot(root)) + + def test_capability_bindings_fail_closed_and_render_policy_is_derived(self) -> None: + with tempfile.TemporaryDirectory() as directory: + project = Project.open(self.copy_fixture(Path(directory))) + with self.assertRaises(DocForgeError) as missing_writer: + generate_client_configuration( + project, + "codex", + capability_mode="proposal", + ) + self.assertEqual("capability_unavailable", missing_writer.exception.code) + proposal = generate_client_configuration( + project, + "codex", + capability_mode="proposal", + proposal_writer="alpha-editor", + ) + self.assertEqual("explicit", proposal["binding"]["render_policy"]["manual"]) + application = generate_client_configuration( + project, + "openclaw", + capability_mode="application", + proposal_writer="alpha-editor", + canonical_applier="alpha-editor", + ) + self.assertEqual("auto", application["binding"]["render_policy"]["manual"]) + with self.assertRaises(DocForgeError) as mismatch: + generate_client_configuration( + project, + "openclaw", + capability_mode="application", + proposal_writer="alpha-editor", + canonical_applier="other", + ) + self.assertEqual("capability_unavailable", mismatch.exception.code) + with self.assertRaises(DocForgeError) as escalated: + generate_client_configuration( + project, + "codex", + proposal_writer="alpha-editor", + ) + self.assertEqual("invalid_capability_binding", escalated.exception.code) + + def test_explicit_fragment_write_is_atomic_conflict_aware_and_private(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + project = Project.open(self.copy_fixture(parent)) + output = parent / "client" / "docforge.toml" + output.parent.mkdir() + created = generate_client_configuration( + project, + "codex", + output=output, + ) + self.assertEqual("created", created["artifact"]["write_state"]) + self.assertEqual( + stat.S_IMODE(output.stat().st_mode), + 0o600, + ) + unchanged = generate_client_configuration( + project, + "codex", + output=output, + ) + self.assertEqual("unchanged", unchanged["artifact"]["write_state"]) + + output.write_text("different\n", encoding="utf-8") + with self.assertRaises(DocForgeError) as conflict: + generate_client_configuration( + project, + "codex", + output=output, + ) + self.assertEqual("output_conflict", conflict.exception.code) + self.assertEqual("different\n", output.read_text(encoding="utf-8")) + output.unlink() + output.symlink_to(parent / "outside") + with self.assertRaises(DocForgeError) as unsafe: + generate_client_configuration( + project, + "codex", + output=output, + ) + self.assertEqual("unsafe_output", unsafe.exception.code) + self.assertEqual([], list(output.parent.glob(".docforge-client-*"))) + + linked_parent = parent / "linked-client" + outside = parent / "outside-client" + outside.mkdir() + linked_parent.symlink_to(outside, target_is_directory=True) + with self.assertRaises(DocForgeError) as escaped: + generate_client_configuration( + project, + "codex", + output=linked_parent / "fragment.toml", + ) + self.assertEqual("unsafe_output", escaped.exception.code) + self.assertFalse((outside / "fragment.toml").exists()) + + def test_committed_fragment_reports_unconfirmed_directory_durability(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + project = Project.open(self.copy_fixture(parent)) + output = parent / "client" / "docforge.toml" + output.parent.mkdir() + with mock.patch( + "docforge.client_config.os.fsync", + side_effect=[None, OSError("directory fsync unavailable")], + ): + created = generate_client_configuration( + project, + "codex", + output=output, + ) + Draft202012Validator(CONFIGURATION_SCHEMA).validate(created) + self.assertEqual("created", created["artifact"]["write_state"]) + self.assertEqual("unconfirmed", created["artifact"]["durability"]) + self.assertEqual( + [{"code": "publication_durability_unconfirmed"}], + created["warnings"], + ) + self.assertTrue(output.is_file()) + self.assertEqual( + created["artifact"]["content"], + output.read_text(encoding="utf-8"), + ) + unchanged = generate_client_configuration( + project, + "codex", + output=output, + ) + self.assertEqual("unchanged", unchanged["artifact"]["write_state"]) + + def test_configuration_fails_closed_on_stale_or_unimportable_runtime(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture(Path(directory)) + project = Project.open(root) + descriptor = root / ".docforge" / "project.toml" + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + "\n", + encoding="utf-8", + ) + with self.assertRaises(DocForgeError) as stale: + generate_client_configuration(project, "codex") + self.assertEqual("source_changed", stale.exception.code) + + current = Project.open(root) + failed_probe = mock.Mock(returncode=1) + with ( + mock.patch( + "docforge.client_config.subprocess.run", + return_value=failed_probe, + ), + self.assertRaises(DocForgeError) as unavailable, + ): + generate_client_configuration(current, "codex") + self.assertEqual( + "client_configuration_unavailable", + unavailable.exception.code, + ) + + def test_fragment_publication_rejects_parent_replacement_and_os_errors(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + project = Project.open(self.copy_fixture(parent)) + output_parent = parent / "client" + output_parent.mkdir() + output = output_parent / "docforge.toml" + moved = parent / "moved-client" + original_link = os.link + swapped = False + + def swap_parent(*args: object, **kwargs: object) -> None: + nonlocal swapped + if not swapped: + output_parent.rename(moved) + output_parent.mkdir() + swapped = True + original_link(*args, **kwargs) + + with ( + mock.patch( + "docforge.client_config.os.link", + side_effect=swap_parent, + ), + self.assertRaises(DocForgeError) as changed, + ): + generate_client_configuration( + project, + "codex", + output=output, + ) + self.assertEqual("output_changed", changed.exception.code) + self.assertFalse(output.exists()) + self.assertEqual([], list(moved.glob("docforge.toml"))) + self.assertEqual([], list(moved.glob(".docforge-client-*"))) + + with ( + mock.patch( + "docforge.client_config.os.link", + side_effect=PermissionError("denied"), + ), + self.assertRaises(DocForgeError) as denied, + ): + generate_client_configuration( + project, + "codex", + output=output, + ) + self.assertEqual("output_publication_failed", denied.exception.code) + self.assertEqual([], list(output_parent.glob(".docforge-client-*"))) + + def test_identical_existing_fragment_must_already_be_private_and_singly_linked( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + project = Project.open(self.copy_fixture(parent)) + output = parent / "docforge.toml" + preview = generate_client_configuration(project, "codex") + output.write_text(preview["artifact"]["content"], encoding="utf-8") + output.chmod(0o644) + with self.assertRaises(DocForgeError) as public: + generate_client_configuration(project, "codex", output=output) + self.assertEqual("unsafe_output", public.exception.code) + + def test_unchanged_fragment_revalidates_binding_after_read(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + output = parent / "docforge.toml" + generate_client_configuration(project, "codex", output=output) + descriptor = root / ".docforge" / "project.toml" + + def mutate_after_read(*args: object, **kwargs: object) -> bytes: + content = _read_existing(*args, **kwargs) # type: ignore[arg-type] + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + "\n", + encoding="utf-8", + ) + return content + + with ( + mock.patch( + "docforge.client_config._read_existing", + side_effect=mutate_after_read, + ), + self.assertRaises(DocForgeError) as stale, + ): + generate_client_configuration(project, "codex", output=output) + self.assertEqual("source_changed", stale.exception.code) + + def test_fragment_publication_revalidates_descriptor_after_link(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + output_parent = parent / "client" + output_parent.mkdir() + output = output_parent / "docforge.toml" + descriptor = root / ".docforge" / "project.toml" + original_link = os.link + + def mutate_descriptor(*args: object, **kwargs: object) -> None: + original_link(*args, **kwargs) + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + "\n", + encoding="utf-8", + ) + + with ( + mock.patch( + "docforge.client_config.os.link", + side_effect=mutate_descriptor, + ), + self.assertRaises(DocForgeError) as stale, + ): + generate_client_configuration( + project, + "codex", + output=output, + ) + self.assertEqual("source_changed", stale.exception.code) + self.assertFalse(output.exists()) + self.assertEqual([], list(output_parent.glob(".docforge-client-*"))) + + def test_unprovable_postlink_binding_returns_schema_valid_degraded_evidence( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + output_parent = parent / "client" + output_parent.mkdir() + output = output_parent / "docforge.toml" + descriptor = root / ".docforge" / "project.toml" + original_link = os.link + + def mutate_descriptor(*args: object, **kwargs: object) -> None: + original_link(*args, **kwargs) + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + "\n", + encoding="utf-8", + ) + + with ( + mock.patch( + "docforge.client_config.os.link", + side_effect=mutate_descriptor, + ), + mock.patch( + "docforge.client_config._rollback_link", + return_value=False, + ), + ): + result = generate_client_configuration( + project, + "codex", + output=output, + ) + Draft202012Validator(CONFIGURATION_SCHEMA).validate(result) + self.assertEqual("created", result["artifact"]["write_state"]) + self.assertEqual("unconfirmed", result["artifact"]["durability"]) + self.assertIsNone(result["artifact"]["output_path"]) + self.assertIn( + {"code": "publication_binding_unconfirmed"}, + result["warnings"], + ) + + def test_fragment_publication_revalidates_after_directory_fsync(self) -> None: + for scenario in ("descriptor", "target", "parent"): + with self.subTest(scenario=scenario), tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + output_parent = parent / "client" + output_parent.mkdir() + output = output_parent / "docforge.toml" + descriptor = root / ".docforge" / "project.toml" + moved = parent / "moved-client" + original_fsync = os.fsync + calls = 0 + + def mutate_at_directory_fsync( + file_descriptor: int, + selected_scenario: str = scenario, + selected_descriptor: Path = descriptor, + selected_output: Path = output, + selected_parent: Path = output_parent, + selected_moved: Path = moved, + selected_fsync: Callable[[int], None] = original_fsync, + ) -> None: + nonlocal calls + calls += 1 + selected_fsync(file_descriptor) + if calls != 2: + return + if selected_scenario == "descriptor": + selected_descriptor.write_text( + selected_descriptor.read_text(encoding="utf-8") + "\n", + encoding="utf-8", + ) + elif selected_scenario == "target": + selected_output.write_text("tampered\n", encoding="utf-8") + else: + selected_parent.rename(selected_moved) + selected_parent.mkdir() + + with ( + mock.patch( + "docforge.client_config.os.fsync", + side_effect=mutate_at_directory_fsync, + ), + self.assertRaises(DocForgeError) as changed, + ): + generate_client_configuration( + project, + "codex", + output=output, + ) + self.assertIn(changed.exception.code, {"source_changed", "output_changed"}) + self.assertFalse(output.exists()) + self.assertFalse((moved / "docforge.toml").exists()) + + def test_doctor_round_trip_is_schema_valid_and_performs_no_hidden_work(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + ProjectIndex(project).build() + config = parent / "codex.toml" + generated = generate_client_configuration( + project, + "codex", + no_ast=True, + output=config, + ) + before_project = self.tree_snapshot(root) + before_config = config.read_bytes() + with ( + mock.patch.object( + Project, + "load", + side_effect=AssertionError("doctor must not load project sources"), + ), + mock.patch( + "docforge.index.ProjectIndex.check", + side_effect=AssertionError("doctor must not check SQLite"), + ), + mock.patch( + "docforge.index.ProjectIndex.synchronize", + side_effect=AssertionError("doctor must not synchronize"), + ), + mock.patch( + "docforge.index.ProjectIndex.build", + side_effect=AssertionError("doctor must not build"), + ), + mock.patch( + "subprocess.run", + side_effect=AssertionError("doctor must not execute configured commands"), + ), + ): + result = run_doctor( + project, + "codex", + config_path=config, + server_name=generated["server_name"], + ) + Draft202012Validator(DOCTOR_SCHEMA).validate(result) + self.assertEqual("healthy", result["doctor_state"]) + self.assertEqual(0, result["summary"]["warning"]) + self.assertEqual(0, result["summary"]["failed"]) + self.assertTrue(result["guarantees"]["read_only"]) + no_ast = next( + check for check in result["checks"] if check["check_id"] == "policy.no_ast" + ) + self.assertEqual("no_ast_policy_valid", no_ast["code"]) + self.assertEqual("unverifiable", no_ast["details"]["adapter_internals"]) + self.assertEqual(before_project, self.tree_snapshot(root)) + self.assertEqual(before_config, config.read_bytes()) + + def test_doctor_reports_missing_malformed_and_secret_environment_without_echoing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + missing = run_doctor( + project, + "codex", + config_path=parent / "missing.toml", + ) + Draft202012Validator(DOCTOR_SCHEMA).validate(missing) + self.assertEqual("unhealthy", missing["doctor_state"]) + malformed_path = parent / "malformed.toml" + malformed_path.write_text("[invalid", encoding="utf-8") + malformed = run_doctor( + project, + "codex", + config_path=malformed_path, + ) + Draft202012Validator(DOCTOR_SCHEMA).validate(malformed) + self.assertEqual("unhealthy", malformed["doctor_state"]) + + generated = generate_client_configuration(project, "openclaw") + document = json.loads(generated["artifact"]["content"]) + entry = next(iter(document["mcp"]["servers"].values())) + entry["env"] = {"TOKEN": "top-secret-value"} + secret_path = parent / "openclaw.json" + secret_path.write_text( + json.dumps(document, sort_keys=True), + encoding="utf-8", + ) + secret = run_doctor( + project, + "openclaw", + config_path=secret_path, + server_name=generated["server_name"], + ) + Draft202012Validator(DOCTOR_SCHEMA).validate(secret) + self.assertEqual("degraded", secret["doctor_state"]) + encoded = json.dumps(secret, sort_keys=True) + self.assertIn("TOKEN", encoded) + self.assertNotIn("top-secret-value", encoded) + + def test_cli_doctor_exit_codes_are_stable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + ProjectIndex(project).build() + config = parent / "codex.toml" + generated = generate_client_configuration(project, "codex", output=config) + output = io.StringIO() + with contextlib.redirect_stdout(output): + healthy = main( + [ + "doctor", + "--client", + "codex", + "--project", + str(root), + "--config", + str(config), + "--server-name", + str(generated["server_name"]), + ] + ) + self.assertEqual(0, healthy) + self.assertEqual("healthy", json.loads(output.getvalue())["doctor_state"]) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + degraded = main( + [ + "doctor", + "--client", + "codex", + "--project", + str(root), + "--config", + str(parent / "missing.toml"), + ] + ) + self.assertEqual(2, degraded) + self.assertEqual("unhealthy", json.loads(output.getvalue())["doctor_state"]) + + malformed = parent / "malformed.toml" + malformed.write_text("[invalid", encoding="utf-8") + output = io.StringIO() + with contextlib.redirect_stdout(output): + unhealthy = main( + [ + "doctor", + "--client", + "codex", + "--project", + str(root), + "--config", + str(malformed), + ] + ) + self.assertEqual(2, unhealthy) + self.assertEqual("unhealthy", json.loads(output.getvalue())["doctor_state"]) + + def test_project_descriptor_read_is_bounded_stable_and_symlink_safe(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + descriptor = root / ".docforge" / "project.toml" + original = descriptor.read_bytes() + descriptor.unlink() + external = parent / "external.toml" + external.write_bytes(original) + descriptor.symlink_to(external) + with self.assertRaises(DocForgeError) as symlinked: + Project.open(root) + self.assertEqual("project_descriptor_unsafe", symlinked.exception.code) + + descriptor.unlink() + descriptor.write_bytes(b"x" * (MAX_PROJECT_DESCRIPTOR_BYTES + 1)) + with self.assertRaises(DocForgeError) as oversized: + Project.open(root) + self.assertEqual("project_descriptor_oversized", oversized.exception.code) + + shutil.rmtree(root / ".docforge") + external_directory = parent / "external-docforge" + external_directory.mkdir() + (external_directory / "project.toml").write_bytes(original) + (root / ".docforge").symlink_to(external_directory, target_is_directory=True) + with self.assertRaises(DocForgeError) as escaped: + Project.open(root) + self.assertEqual("project_descriptor_unsafe", escaped.exception.code) + + def test_project_load_revalidates_the_bounded_descriptor(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture(Path(directory)) + project = Project.open(root) + descriptor = root / ".docforge" / "project.toml" + descriptor.write_bytes(b"x" * (MAX_PROJECT_DESCRIPTOR_BYTES + 1)) + with self.assertRaises(DocForgeError) as oversized: + project.load() + self.assertEqual("project_descriptor_oversized", oversized.exception.code) + + def test_doctor_ignores_unrelated_entries_and_honors_codex_home(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + ProjectIndex(project).build() + codex_home = parent / "codex-home" + codex_home.mkdir() + config = codex_home / "config.toml" + generated = generate_client_configuration(project, "codex", output=config) + config.write_text( + config.read_text(encoding="utf-8") + + '\n[mcp_servers."remote"]\nurl = "https://example.invalid/mcp"\n', + encoding="utf-8", + ) + with mock.patch.dict(os.environ, {"CODEX_HOME": str(codex_home)}): + result = run_doctor(project, "codex") + Draft202012Validator(DOCTOR_SCHEMA).validate(result) + self.assertEqual("healthy", result["doctor_state"]) + self.assertEqual(generated["server_name"], result["config"]["server_name"]) + + openclaw_config = parent / "openclaw.json" + openclaw = generate_client_configuration( + project, + "openclaw", + output=openclaw_config, + ) + document = json.loads(openclaw_config.read_text(encoding="utf-8")) + document["mcp"]["servers"]["remote"] = { + "type": "http", + "url": "https://example.invalid/mcp", + "enabled": True, + } + openclaw_config.write_text( + json.dumps(document, sort_keys=True), + encoding="utf-8", + ) + openclaw_result = run_doctor( + project, + "openclaw", + config_path=openclaw_config, + ) + Draft202012Validator(DOCTOR_SCHEMA).validate(openclaw_result) + self.assertEqual("healthy", openclaw_result["doctor_state"]) + self.assertEqual( + openclaw["server_name"], + openclaw_result["config"]["server_name"], + ) + + selected = next(iter(document["mcp"]["servers"].values())) + selected["cwd"] = str(root) + selected["toolFilter"] = {"include": ["docforge_bootstrap"]} + openclaw_config.write_text( + json.dumps(document, sort_keys=True), + encoding="utf-8", + ) + filtered = run_doctor( + project, + "openclaw", + config_path=openclaw_config, + ) + Draft202012Validator(DOCTOR_SCHEMA).validate(filtered) + self.assertEqual("degraded", filtered["doctor_state"]) + self.assertIn( + "client_tool_filter_unverified", + [check["code"] for check in filtered["checks"]], + ) + + def test_doctor_rejects_nonlaunching_or_malformed_matching_arguments(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + ProjectIndex(project).build() + generated = generate_client_configuration(project, "openclaw") + document = json.loads(generated["artifact"]["content"]) + entry = next(iter(document["mcp"]["servers"].values())) + entry["args"] = entry["args"][3:] + config = parent / "missing-prefix.json" + config.write_text(json.dumps(document), encoding="utf-8") + missing_prefix = run_doctor( + project, + "openclaw", + config_path=config, + ) + self.assertEqual("unhealthy", missing_prefix["doctor_state"]) + self.assertIn( + "server_arguments_invalid", + [check["code"] for check in missing_prefix["checks"]], + ) + + entry["args"] = [ + "-I", + "-m", + "docforge.mcp_server", + "--project-root", + str(root), + "--bogus", + ] + config.write_text(json.dumps(document), encoding="utf-8") + malformed = run_doctor(project, "openclaw", config_path=config) + self.assertEqual("unhealthy", malformed["doctor_state"]) + self.assertIn( + "server_arguments_invalid", + [check["code"] for check in malformed["checks"]], + ) + + secret = "super-secret-argument" + entry["args"][-1] = secret + config.write_text(json.dumps(document), encoding="utf-8") + redacted = run_doctor(project, "openclaw", config_path=config) + self.assertEqual("unhealthy", redacted["doctor_state"]) + self.assertNotIn(secret, json.dumps(redacted, sort_keys=True)) + + def test_doctor_rejects_ambiguous_duplicate_roots_and_malformed_controls( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + ProjectIndex(project).build() + generated = generate_client_configuration(project, "openclaw") + document = json.loads(generated["artifact"]["content"]) + name, first = next(iter(document["mcp"]["servers"].items())) + duplicate = json.loads(json.dumps(first)) + root_value = duplicate["args"].index("--project-root") + 1 + duplicate["args"][root_value] = "/first/does/not/match" + for index in range(64): + duplicate["args"].extend(("--project-root", f"/duplicate/does/not/match/{index}")) + duplicate["args"].extend(("--project-root", str(root))) + document["mcp"]["servers"][f"{name}-duplicate"] = duplicate + config = parent / "openclaw.json" + config.write_text(json.dumps(document), encoding="utf-8") + ambiguous = run_doctor(project, "openclaw", config_path=config) + self.assertEqual("unhealthy", ambiguous["doctor_state"]) + self.assertIn( + "client_entry_ambiguous", + [check["code"] for check in ambiguous["checks"]], + ) + + document["mcp"]["servers"].pop(f"{name}-duplicate") + first["supportsParallelToolCalls"] = "not-a-bool" + config.write_text(json.dumps(document), encoding="utf-8") + malformed_parallel = run_doctor( + project, + "openclaw", + config_path=config, + ) + self.assertEqual("unhealthy", malformed_parallel["doctor_state"]) + + first["supportsParallelToolCalls"] = False + first["cwd"] = str(parent / "missing-directory") + config.write_text(json.dumps(document), encoding="utf-8") + missing_cwd = run_doctor(project, "openclaw", config_path=config) + self.assertEqual("unhealthy", missing_cwd["doctor_state"]) + self.assertIn( + "client_cwd_invalid", + [check["code"] for check in missing_cwd["checks"]], + ) + + for malformed_filter in ( + ["not-an-object"], + {"include": "not-a-list"}, + {"include": [1, 2]}, + ): + first.pop("cwd", None) + first["toolFilter"] = malformed_filter + config.write_text(json.dumps(document), encoding="utf-8") + malformed = run_doctor( + project, + "openclaw", + config_path=config, + ) + self.assertEqual("unhealthy", malformed["doctor_state"]) + self.assertIn( + "client_entry_invalid", + [check["code"] for check in malformed["checks"]], + ) + + def test_doctor_bounds_parser_failures_inputs_and_check_inventory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + project = Project.open(self.copy_fixture(parent)) + deeply_nested = parent / "deep.json" + deeply_nested.write_text("[" * 2_000 + "]" * 2_000, encoding="utf-8") + deep = run_doctor( + project, + "openclaw", + config_path=deeply_nested, + ) + self.assertEqual("unhealthy", deep["doctor_state"]) + + huge_integer = parent / "integer.json" + huge_integer.write_text( + '{"mcp":{"servers":{}},"value":' + "9" * 100_000 + "}", + encoding="utf-8", + ) + integer = run_doctor( + project, + "openclaw", + config_path=huge_integer, + ) + self.assertEqual("unhealthy", integer["doctor_state"]) + + malformed_openclaw = parent / "malformed-openclaw.json" + malformed_openclaw.write_text('{"mcp":"wrong"}', encoding="utf-8") + malformed_driver = run_doctor( + project, + "openclaw", + config_path=malformed_openclaw, + ) + self.assertEqual("unhealthy", malformed_driver["doctor_state"]) + self.assertIn( + "client_config_invalid", + [check["code"] for check in malformed_driver["checks"]], + ) + + oversized_name = run_doctor( + project, + "openclaw", + config_path=parent / "missing.json", + server_name="x" * 5_000, + ) + Draft202012Validator(DOCTOR_SCHEMA).validate(oversized_name) + self.assertEqual("unhealthy", oversized_name["doctor_state"]) + self.assertEqual( + 14, + len(oversized_name["checks"]), + ) + self.assertEqual( + len(oversized_name["checks"]), + len({check["check_id"] for check in oversized_name["checks"]}), + ) + oversized_path = run_doctor( + project, + "openclaw", + config_path=Path("/" + "x" * 40_000), + ) + Draft202012Validator(DOCTOR_SCHEMA).validate(oversized_path) + self.assertEqual("unhealthy", oversized_path["doctor_state"]) + self.assertLessEqual( + len(json.dumps(oversized_path, sort_keys=True).encode("utf-8")), + 32_768, + ) + + def test_doctor_rejects_descriptor_config_and_index_parent_swaps(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + descriptor_parent = root / ".docforge" + moved_descriptor = root / ".docforge-old" + original_project_open = os.open + descriptor_swapped = False + + def swap_descriptor_parent( + path: object, + flags: int, + *args: object, + **kwargs: object, + ) -> int: + nonlocal descriptor_swapped + descriptor = original_project_open(path, flags, *args, **kwargs) + if ( + not descriptor_swapped + and Path(path) == descriptor_parent + and flags & os.O_DIRECTORY + ): + descriptor_parent.rename(moved_descriptor) + descriptor_parent.mkdir() + shutil.copy2( + moved_descriptor / "project.toml", + descriptor_parent / "project.toml", + ) + descriptor_swapped = True + return descriptor + + with ( + mock.patch( + "docforge.project.os.open", + side_effect=swap_descriptor_parent, + ), + self.assertRaises(DocForgeError) as changed, + ): + run_doctor( + project, + "codex", + config_path=parent / "missing.toml", + ) + self.assertEqual("project_descriptor_changed", changed.exception.code) + + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + ProjectIndex(project).build() + config_parent = parent / "client" + config_parent.mkdir() + config = config_parent / "config.toml" + generated = generate_client_configuration(project, "codex", output=config) + moved_config = parent / "client-old" + original_doctor_open = os.open + config_swapped = False + + def swap_config_parent( + path: object, + flags: int, + *args: object, + **kwargs: object, + ) -> int: + nonlocal config_swapped + descriptor = original_doctor_open(path, flags, *args, **kwargs) + if not config_swapped and Path(path) == config_parent and flags & os.O_DIRECTORY: + config_parent.rename(moved_config) + config_parent.mkdir() + (config_parent / "config.toml").write_text("", encoding="utf-8") + config_swapped = True + return descriptor + + with mock.patch( + "docforge.doctor.os.open", + side_effect=swap_config_parent, + ): + changed_config = run_doctor( + project, + "codex", + config_path=config, + server_name=generated["server_name"], + ) + self.assertEqual("unhealthy", changed_config["doctor_state"]) + self.assertIn( + "client_config_changed", + [check["code"] for check in changed_config["checks"]], + ) + + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + ProjectIndex(project).build() + config = parent / "config.toml" + generated = generate_client_configuration(project, "codex", output=config) + index_parent = project.descriptor.index_path.parent + moved_index_parent = parent / "cache-old" + original_doctor_open = os.open + index_swapped = False + + def swap_index_parent( + path: object, + flags: int, + *args: object, + **kwargs: object, + ) -> int: + nonlocal index_swapped + descriptor = original_doctor_open(path, flags, *args, **kwargs) + if not index_swapped and Path(path) == index_parent and flags & os.O_DIRECTORY: + index_parent.rename(moved_index_parent) + index_parent.mkdir() + index_swapped = True + return descriptor + + with mock.patch( + "docforge.doctor.os.open", + side_effect=swap_index_parent, + ): + unsafe_index = run_doctor( + project, + "codex", + config_path=config, + server_name=generated["server_name"], + ) + self.assertEqual("degraded", unsafe_index["doctor_state"]) + self.assertIn( + "index_unsafe", + [check["code"] for check in unsafe_index["checks"]], + ) + + def test_doctor_rechecks_the_exact_client_file_before_return(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + project = Project.open(root) + ProjectIndex(project).build() + config = parent / "config.toml" + generated = generate_client_configuration(project, "codex", output=config) + raw = config.read_bytes() + with mock.patch( + "docforge.doctor._read_stable_regular", + side_effect=[ + (raw, (1, 1, len(raw), 1, 1)), + (raw, (1, 2, len(raw), 1, 1)), + ], + ): + result = run_doctor( + project, + "codex", + config_path=config, + server_name=generated["server_name"], + ) + self.assertEqual("unhealthy", result["doctor_state"]) + self.assertIn( + "client_config_changed", + [check["code"] for check in result["checks"]], + ) + + def test_descriptor_currency_and_custom_adapter_generation_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture(Path(directory)) + project = Project.open(root) + descriptor = root / ".docforge" / "project.toml" + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + "\n", + encoding="utf-8", + ) + with self.assertRaises(DocForgeError) as changed: + run_doctor(project, "codex", config_path=root / "missing.toml") + self.assertEqual("source_changed", changed.exception.code) + + current = Project.open(root) + current.descriptor = replace(current.descriptor, adapter="custom-adapter") + with self.assertRaises(DocForgeError) as custom: + generate_client_configuration(current, "codex") + self.assertEqual( + "client_configuration_unavailable", + custom.exception.code, + ) + + def test_diagnostic_cli_results_validate_their_dedicated_schemas(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + root = self.copy_fixture(parent) + preview_output = io.StringIO() + with contextlib.redirect_stdout(preview_output): + preview_code = main( + [ + "--diagnostics", + "configure", + "codex", + "--project", + str(root), + ] + ) + self.assertEqual(0, preview_code) + preview = json.loads(preview_output.getvalue()) + Draft202012Validator(CONFIGURATION_SCHEMA).validate(preview) + self.assertEqual("cli.configure", preview["diagnostics"]["operation"]) + + unhealthy_output = io.StringIO() + with contextlib.redirect_stdout(unhealthy_output): + unhealthy_code = main( + [ + "--diagnostics", + "doctor", + "--client", + "codex", + "--project", + str(root), + "--config", + str(parent / "missing.toml"), + ] + ) + self.assertEqual(2, unhealthy_code) + unhealthy = json.loads(unhealthy_output.getvalue()) + Draft202012Validator(DOCTOR_SCHEMA).validate(unhealthy) + self.assertEqual("cli.doctor", unhealthy["diagnostics"]["operation"]) + + def test_configuration_schema_rejects_cross_field_drift(self) -> None: + with tempfile.TemporaryDirectory() as directory: + project = Project.open(self.copy_fixture(Path(directory))) + result = generate_client_configuration(project, "codex") + validator = Draft202012Validator(CONFIGURATION_SCHEMA) + drifted = json.loads(json.dumps(result)) + drifted["artifact"]["format"] = "openclaw-json-fragment-v1" + self.assertTrue(list(validator.iter_errors(drifted))) + drifted = json.loads(json.dumps(result)) + drifted["effective_policy"] = {} + self.assertTrue(list(validator.iter_errors(drifted))) + drifted = json.loads(json.dumps(result)) + drifted["action"] = "write" + self.assertTrue(list(validator.iter_errors(drifted))) + drifted = json.loads(json.dumps(result)) + drifted["artifact"]["durability"] = "unconfirmed" + self.assertTrue(list(validator.iter_errors(drifted))) + drifted = json.loads(json.dumps(result)) + drifted["binding"]["capability_mode"] = "proposal" + self.assertTrue(list(validator.iter_errors(drifted))) + drifted = json.loads(json.dumps(result)) + drifted["binding"]["adapter_policy"] = { + "schema_version": 1, + "mode": "preserve-no-ast", + "adapter_evolution": "preserve", + "ast_forbidden": True, + "logic_indexing": "off", + "blocked_tools": ["docforge_get_logic"], + "instruction": "Preserve adapter behavior without AST or Logic publication.", + } + self.assertTrue(list(validator.iter_errors(drifted))) + drifted = json.loads(json.dumps(result)) + drifted["binding"]["render_policy"]["manual"] = "disabled" + self.assertTrue(list(validator.iter_errors(drifted))) + drifted = json.loads(json.dumps(result)) + drifted["binding"]["args"][6] = "proposal" + self.assertTrue(list(validator.iter_errors(drifted))) + no_ast = generate_client_configuration(project, "codex", no_ast=True) + drifted = json.loads(json.dumps(no_ast)) + drifted["binding"]["args"].remove("--no-ast") + self.assertTrue(list(validator.iter_errors(drifted))) + drifted = json.loads(json.dumps(result)) + drifted["effective_policy"]["prohibitions"][2] = "made_up_permission" + self.assertTrue(list(validator.iter_errors(drifted))) + with self.assertRaises(AssertionError): + _validate_configuration_result(drifted) + drifted = json.loads(json.dumps(no_ast)) + drifted["binding"]["adapter_policy"]["instruction"] = "AST use is allowed." + self.assertTrue(list(validator.iter_errors(drifted))) + with self.assertRaises(AssertionError): + _validate_configuration_result(drifted) + + def test_doctor_schema_rejects_duplicate_inventory_and_inflated_summary(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + project = Project.open(self.copy_fixture(parent)) + result = run_doctor( + project, + "codex", + config_path=parent / "missing.toml", + ) + validator = Draft202012Validator(DOCTOR_SCHEMA) + duplicate = json.loads(json.dumps(result)) + duplicate["checks"][1]["check_id"] = duplicate["checks"][0]["check_id"] + self.assertTrue(list(validator.iter_errors(duplicate))) + inflated = json.loads(json.dumps(result)) + inflated["summary"]["failed"] = 99 + self.assertTrue(list(validator.iter_errors(inflated))) + + +class GeneratedClientLaunchTests(unittest.IsolatedAsyncioTestCase): + async def test_generated_command_starts_real_project_bound_stdio_server(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "alpha" + shutil.copytree(FIXTURES / "alpha", root) + generated = generate_client_configuration(Project.open(root), "codex") + binding = generated["binding"] + parameters = StdioServerParameters( + command=binding["command"], + args=binding["args"], + ) + async with ( + stdio_client(parameters) as (read_stream, write_stream), + ClientSession(read_stream, write_stream) as session, + ): + await session.initialize() + tools = await session.list_tools() + bootstrap = await session.call_tool("docforge_bootstrap", {}) + self.assertEqual(list(READ_TOOLS), [tool.name for tool in tools.tools]) + self.assertEqual("ok", bootstrap.structuredContent["status"]) + self.assertEqual( + "read", bootstrap.structuredContent["effective_policy"]["capability_mode"] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_observability.py b/tests/test_observability.py index c4cf2c6..7b4d143 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -34,6 +34,10 @@ from docforge.viewer_manager import ViewerManagerClient ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "tests" / "fixtures" RESULT_SCHEMA = json.loads((ROOT / "schemas" / "result.schema.json").read_text()) +CLIENT_CONFIGURATION_SCHEMA = json.loads( + (ROOT / "schemas" / "client-configuration.schema.json").read_text() +) +DOCTOR_RESULT_SCHEMA = json.loads((ROOT / "schemas" / "doctor-result.schema.json").read_text()) ZERO_WORK_COUNTERS = ( "project_loads", "source_files_parsed", @@ -134,6 +138,16 @@ class TelemetryContractTests(unittest.TestCase): set(COUNTER_NAMES), set(properties["counters"]["properties"]), ) + for schema in (CLIENT_CONFIGURATION_SCHEMA, DOCTOR_RESULT_SCHEMA): + dedicated = schema["$defs"]["diagnostics"]["properties"] + self.assertEqual( + set(STAGE_NAMES), + set(dedicated["stages"]["propertyNames"]["enum"]), + ) + self.assertEqual( + set(COUNTER_NAMES), + set(dedicated["counters"]["required"]), + ) def test_thread_and_async_request_contexts_are_isolated(self) -> None: barrier = threading.Barrier(2) diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index b33bef6..e153b2c 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -61,6 +61,8 @@ PUBLIC_IMPORTS = { "CanonicalApplicationService", "GenericCanonicalApplier", ), + "docforge.client_config": ("generate_client_configuration",), + "docforge.doctor": ("run_doctor",), "docforge.index": ("ProjectIndex",), "docforge.mcp_server": ( "create_project_server", @@ -108,8 +110,10 @@ EXPECTED_CLI_COMMANDS = { "backlinks", "build", "check", + "configure", "context", "dependencies", + "doctor", "filter", "generation-diff", "impact", diff --git a/tools/milestone2_benchmark.py b/tools/milestone2_benchmark.py new file mode 100644 index 0000000..25ecd64 --- /dev/null +++ b/tools/milestone2_benchmark.py @@ -0,0 +1,1014 @@ +"""Milestone 2 agent-retrieval and client-integration benchmark gates.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +import resource +import subprocess +import sys +import tempfile +import time +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import cast + +from milestone0_baseline import ( + measure_operation, + synthetic_node_id, + write_synthetic_project, +) + +from docforge.client_config import generate_client_configuration +from docforge.doctor import run_doctor +from docforge.index import ProjectIndex +from docforge.mcp_server import DocForgeService +from docforge.pagination import canonical_hash +from docforge.project import Project +from docforge.retrieval import MAX_TASK_EVIDENCE, build_retrieval_plan +from docforge.telemetry import COUNTER_NAMES, request + +ROOT = Path(__file__).resolve().parents[1] +ZERO_WORK_COUNTERS = ( + "project_loads", + "source_files_parsed", + "source_bytes_parsed", + "adapter_projection_loads", + "adapter_source_extractions", + "index_synchronizations", + "index_builds", + "render_prepare_calls", + "render_output_bytes_built", + "render_output_bytes_hashed", + "viewer_manager_requests", +) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Gate DocForge2 Milestone 2 on disposable agent workflows." + ) + parser.add_argument("--nodes", type=int, default=1000) + parser.add_argument("--samples", type=int, default=10) + parser.add_argument("--output", type=Path) + parser.add_argument("--memory-probe", action="store_true", help=argparse.SUPPRESS) + return parser + + +def _git(command: list[str]) -> str: + return subprocess.run( + ["git", *command], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _compact_size(value: object) -> int: + return len( + json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + ) + + +def _prepare_fixture(root: Path, node_count: int) -> None: + write_synthetic_project(root, node_count) + descriptor = root / ".docforge" / "project.toml" + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + .replace("max_results = 100", "max_results = 1000") + .replace("max_tool_output_chars = 5000000", "max_tool_output_chars = 200000"), + encoding="utf-8", + ) + focus = synthetic_node_id(0) + for index in range(1, node_count): + path = root / "docs" / "content" / f"node-{index:04d}.md" + raw = path.read_text(encoding="utf-8") + previous = synthetic_node_id(index - 1) + path.write_text( + raw.replace( + f'depends_on = ["{previous}"]', + f'depends_on = ["{focus}"]', + ), + encoding="utf-8", + ) + + +def _tree_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + digest.update(path.relative_to(root).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def _assert_zero_work(diagnostics: Mapping[str, object]) -> None: + counters_value = diagnostics.get("counters") + if not isinstance(counters_value, Mapping): + raise RuntimeError("Measured operation did not expose telemetry counters") + counters = cast(Mapping[str, object], counters_value) + for counter in ZERO_WORK_COUNTERS: + if counters.get(counter) != 0: + raise RuntimeError(f"Milestone 2 operation performed forbidden work: {counter}") + + +def _result_diagnostics( + result: Mapping[str, object], + *, + expected_counters: Mapping[str, int], +) -> Mapping[str, object]: + diagnostics_value = result.get("diagnostics") + if not isinstance(diagnostics_value, Mapping): + raise RuntimeError("Measured MCP result did not include diagnostics") + diagnostics = cast(Mapping[str, object], diagnostics_value) + counters = cast(Mapping[str, object], diagnostics["counters"]) + for counter in ZERO_WORK_COUNTERS: + expected = expected_counters.get(counter, 0) + if counters.get(counter) != expected: + raise RuntimeError( + f"Measured MCP result expected {counter}={expected}, " + f"received {counters.get(counter)!r}" + ) + for counter, expected in expected_counters.items(): + if counters.get(counter) != expected: + raise RuntimeError( + f"Measured MCP result expected {counter}={expected}, " + f"received {counters.get(counter)!r}" + ) + return diagnostics + + +def _maximum_page_validator( + result: Mapping[str, object], + *, + expected_counters: Mapping[str, int], + diagnostics_dropped: list[bool], +) -> None: + if isinstance(result.get("diagnostics"), Mapping): + _result_diagnostics(result, expected_counters=expected_counters) + diagnostics_dropped.append(False) + return + if _compact_size(result) <= 190_000: + raise RuntimeError( + "Maximum-page diagnostics were absent before the primary result approached " + "the response budget" + ) + diagnostics_dropped.append(True) + + +def _measure( + operation: Callable[[], dict[str, object]], + *, + samples: int, + p95_limit_ms: float, + response_limit_bytes: int, + validator: Callable[[dict[str, object]], object] | None = None, +) -> tuple[dict[str, object], dict[str, object]]: + results: list[dict[str, object]] = [] + diagnostics_records: list[Mapping[str, object]] = [] + + def validated_operation() -> dict[str, object]: + result = operation() + if result.get("status") != "ok": + raise RuntimeError("Measured operation did not succeed") + response_bytes = _compact_size(result) + if response_bytes > response_limit_bytes: + raise RuntimeError( + f"Milestone 2 response {response_bytes} exceeds {response_limit_bytes} bytes" + ) + if validator is not None: + validator(result) + diagnostics_value = result.get("diagnostics", result.get("_benchmark_diagnostics")) + if isinstance(diagnostics_value, Mapping): + diagnostics_records.append(cast(Mapping[str, object], diagnostics_value)) + results.append(result) + return result + + measurement, last_value = measure_operation(validated_operation, samples=samples) + if not isinstance(last_value, Mapping): + raise RuntimeError("Measured operation returned a non-object result") + last = dict(cast(Mapping[str, object], last_value)) + p95 = cast(float, measurement["p95_ms"]) + if p95 > p95_limit_ms: + raise RuntimeError(f"Milestone 2 operation p95 {p95:.3f} ms exceeds {p95_limit_ms:.3f} ms") + counter_ranges: dict[str, dict[str, int]] = {} + if diagnostics_records: + for counter in COUNTER_NAMES: + values = [ + cast( + int, + cast(Mapping[str, object], record["counters"])[counter], + ) + for record in diagnostics_records + ] + counter_ranges[counter] = { + "minimum": min(values), + "maximum": max(values), + } + return ( + { + **measurement, + "p95_limit_ms": p95_limit_ms, + "response_limit_bytes": response_limit_bytes, + "validated_invocations": len(results), + "maximum_response_bytes": max(_compact_size(result) for result in results), + **({"counter_ranges": counter_ranges} if counter_ranges else {}), + }, + last, + ) + + +def _profiled( + operation: Callable[[], dict[str, object]], +) -> dict[str, object]: + with request("benchmark.m2", enabled=True) as collector: + result = operation() + if collector is None: + raise RuntimeError("Milestone 2 profiling collector was not created") + diagnostics = collector.as_dict(outcome="ok") + _assert_zero_work(diagnostics) + result["_benchmark_diagnostics"] = diagnostics + return result + + +def _page_summary( + pages: list[dict[str, object]], + *, + started_ns: int, +) -> dict[str, object]: + sizes = [_compact_size(page) for page in pages] + paginations = [cast(dict[str, object], page["pagination"]) for page in pages] + result: dict[str, object] = { + "status": "ok", + "page_count": len(pages), + "maximum_page_bytes": max(sizes), + "aggregate_page_bytes": sum(sizes), + "elapsed_ms": round((time.perf_counter_ns() - started_ns) / 1_000_000, 3), + "maximum_cursor_bytes": max( + ( + len(cast(str, page["next_cursor"]).encode("utf-8")) + for page in paginations + if page["next_cursor"] is not None + ), + default=0, + ), + } + if cast(int, result["maximum_cursor_bytes"]) > 4_096: + raise RuntimeError("Milestone 2 cursor exceeded 4,096 bytes") + return result + + +def _task_oracle( + service: DocForgeService, + focus: str, +) -> dict[str, object]: + plan = build_retrieval_plan( + service.project.descriptor, + task_kind="change", + task="Change the central synthetic workflow", + focus_node_id=focus, + budget=None, + limit=min( + service.project.descriptor.limits.max_results, + MAX_TASK_EVIDENCE, + ), + effective_policy=service.policy.as_dict(), + ) + result = service.index.task_context(plan) + capsule = cast(dict[str, object], result["capsule"]) + generation = cast(Mapping[str, object], capsule["generation"]) + plan_payload = cast(Mapping[str, object], capsule["plan"]) + evidence = cast(list[dict[str, object]], capsule["evidence"]) + gaps = cast(list[dict[str, object]], capsule["gaps"]) + omissions = cast(list[dict[str, object]], capsule["omissions"]) + evidence_hashes = [cast(str, item["evidence_hash"]) for item in evidence] + collection_hash = canonical_hash( + { + "generation": dict(generation), + "plan_hash": plan_payload["plan_hash"], + "evidence": evidence_hashes, + "gaps": gaps, + "omissions": omissions, + } + ) + if capsule["collection_hash"] != collection_hash: + raise RuntimeError("Task-context full oracle has an invalid collection hash") + return { + "generation": dict(generation), + "plan_hash": plan_payload["plan_hash"], + "evidence": evidence, + "evidence_hashes": evidence_hashes, + "gaps": gaps, + "omissions": omissions, + "collection_hash": collection_hash, + } + + +def _task_traversal( + service: DocForgeService, + focus: str, + *, + node_count: int, + oracle: Mapping[str, object], +) -> dict[str, object]: + started = time.perf_counter_ns() + oracle_generation = cast(Mapping[str, object], oracle["generation"]) + oracle_plan_hash = cast(str, oracle["plan_hash"]) + oracle_evidence = cast(list[dict[str, object]], oracle["evidence"]) + oracle_evidence_hashes = cast(list[str], oracle["evidence_hashes"]) + oracle_gaps = cast(list[dict[str, object]], oracle["gaps"]) + oracle_omissions = cast(list[dict[str, object]], oracle["omissions"]) + oracle_collection_hash = cast(str, oracle["collection_hash"]) + oracle_evidence_by_node = {cast(str, item["node_id"]): item for item in oracle_evidence} + pages: list[dict[str, object]] = [] + cursor: str | None = None + capsule_hash: str | None = None + collection_hash: str | None = None + plan_hash: str | None = None + returned = 0 + seen_cursors: set[str] = set() + evidence_hashes: list[str] = [] + evidence_nodes: list[str] = [] + page_omissions: list[dict[str, object]] = [] + omitted_nodes: list[str] = [] + reconstructed_evidence_hashes: list[str] = [] + reconstructed_omissions: list[dict[str, object]] = [] + diagnostics_records: list[Mapping[str, object]] = [] + first_generation: Mapping[str, object] | None = None + first_gaps: list[object] | None = None + while True: + page = service.task_context( + "change", + "Change the central synthetic workflow", + focus_node_id=focus, + limit=100, + cursor=cursor, + ) + if page.get("status") != "ok": + raise RuntimeError("Task-context page failed") + diagnostics_records.append( + _result_diagnostics( + page, + expected_counters={ + "index_checks": 1, + "index_synchronizations": 0, + "source_generation_checks": 2, + }, + ) + ) + if _compact_size(page) > 200_000: + raise RuntimeError("Task-context page exceeded the configured response budget") + capsule = cast(dict[str, object], page["capsule"]) + plan = cast(dict[str, object], capsule["plan"]) + current_capsule_hash = cast(str, capsule["capsule_hash"]) + current_collection_hash = cast(str, capsule["collection_hash"]) + current_plan_hash = cast(str, plan["plan_hash"]) + generation = cast(Mapping[str, object], capsule["generation"]) + gaps = cast(list[object], capsule["gaps"]) + if first_generation is None: + first_generation = generation + first_gaps = gaps + elif generation != first_generation or gaps != first_gaps: + raise RuntimeError("Task-context invariant payload changed during traversal") + if capsule_hash is None: + capsule_hash = current_capsule_hash + collection_hash = current_collection_hash + plan_hash = current_plan_hash + elif ( + current_capsule_hash != capsule_hash + or current_collection_hash != collection_hash + or current_plan_hash != plan_hash + ): + raise RuntimeError("Task-context page binding changed during traversal") + pagination = cast(dict[str, object], page["pagination"]) + current_evidence = cast(list[dict[str, object]], capsule["evidence"]) + current_omissions = cast(list[dict[str, object]], capsule["omissions"]) + if pagination["returned_count"] != len(current_evidence) + len(current_omissions): + raise RuntimeError("Task-context page count does not match its items") + for item in current_evidence: + node_id = cast(str, item["node_id"]) + if oracle_evidence_by_node.get(node_id) != item: + raise RuntimeError("Task-context page evidence drifted from the full oracle") + evidence_hash = cast(str, item["evidence_hash"]) + evidence_hashes.append(evidence_hash) + reconstructed_evidence_hashes.append(evidence_hash) + evidence_nodes.append(node_id) + for omission in current_omissions: + subject = omission.get("subject") + if not isinstance(subject, str) or omission.get("code") not in { + "response_limit", + "token_budget", + }: + raise RuntimeError("Task-context omission has unexpected semantics") + if omission["code"] == "response_limit": + replaced = oracle_evidence_by_node.get(subject) + if replaced is None or omission.get("detail_hash") != canonical_hash(replaced): + raise RuntimeError( + "Task-context response-limit omission does not attest its oracle item" + ) + reconstructed_evidence_hashes.append(cast(str, replaced["evidence_hash"])) + else: + reconstructed_omissions.append(omission) + omitted_nodes.append(subject) + page_omissions.extend(current_omissions) + returned += cast(int, pagination["returned_count"]) + pages.append(page) + next_cursor = pagination["next_cursor"] + if next_cursor is None: + if returned != pagination["total_count"]: + raise RuntimeError("Task-context traversal did not reconstruct every item") + break + cursor = cast(str, next_cursor) + if cursor in seen_cursors: + raise RuntimeError("Task-context pagination repeated a cursor") + seen_cursors.add(cursor) + if len(evidence_hashes) != len(set(evidence_hashes)): + raise RuntimeError("Task-context traversal repeated evidence") + if len(evidence_nodes) != len(set(evidence_nodes)): + raise RuntimeError("Task-context traversal repeated node evidence") + if len(omitted_nodes) != len(set(omitted_nodes)): + raise RuntimeError("Task-context traversal repeated an omitted subject") + expected_nodes = {synthetic_node_id(index) for index in range(node_count)} + reconstructed_nodes = evidence_nodes + omitted_nodes + if ( + returned != node_count + or len(reconstructed_nodes) != node_count + or len(reconstructed_nodes) != len(set(reconstructed_nodes)) + or set(reconstructed_nodes) != expected_nodes + ): + raise RuntimeError("Task-context traversal did not reconstruct every synthetic candidate") + assert first_generation is not None + assert first_gaps is not None + if ( + collection_hash != oracle_collection_hash + or dict(first_generation) != dict(oracle_generation) + or plan_hash != oracle_plan_hash + or first_gaps != oracle_gaps + or reconstructed_evidence_hashes != oracle_evidence_hashes + or reconstructed_omissions != oracle_omissions + or collection_hash + != canonical_hash( + { + "generation": dict(first_generation), + "plan_hash": plan_hash, + "evidence": reconstructed_evidence_hashes, + "gaps": first_gaps, + "omissions": reconstructed_omissions, + } + ) + ): + raise RuntimeError("Task-context traversal did not reconstruct its full-oracle binding") + summary = _page_summary(pages, started_ns=started) + if cast(float, summary["elapsed_ms"]) > 2_500: + raise RuntimeError("Complete task-context traversal exceeded 2,500 ms") + summary.update( + { + "capsule_hash": capsule_hash, + "collection_hash": collection_hash, + "plan_hash": plan_hash, + "item_count": returned, + "evidence_count": len(evidence_nodes), + "omission_count": len(omitted_nodes), + "ordered_evidence_hash": canonical_hash(reconstructed_evidence_hashes), + "ordered_candidate_hash": canonical_hash(reconstructed_nodes), + "collection_hash_reconstructed": True, + "counter_ranges": { + counter: { + "minimum": min( + cast(int, cast(Mapping[str, object], item["counters"])[counter]) + for item in diagnostics_records + ), + "maximum": max( + cast(int, cast(Mapping[str, object], item["counters"])[counter]) + for item in diagnostics_records + ), + } + for counter in COUNTER_NAMES + }, + } + ) + return summary + + +def _generation_traversal( + service: DocForgeService, + *, + node_count: int, +) -> dict[str, object]: + started = time.perf_counter_ns() + pages: list[dict[str, object]] = [] + cursor: str | None = None + receipt_hash: str | None = None + returned = 0 + seen_cursors: set[str] = set() + item_hashes: list[str] = [] + node_ids: list[str] = [] + diagnostics_records: list[Mapping[str, object]] = [] + retained_collection_hash: str | None = None + while True: + page = service.generation_diff(limit=100, cursor=cursor) + if page.get("status") != "ok": + raise RuntimeError("Generation-diff page failed") + diagnostics_records.append( + _result_diagnostics( + page, + expected_counters={ + "index_checks": 0, + "index_synchronizations": 0, + "source_generation_checks": 2, + }, + ) + ) + if _compact_size(page) > 200_000: + raise RuntimeError("Generation-diff page exceeded the configured response budget") + generation_diff = cast(dict[str, object], page["generation_diff"]) + header = cast(dict[str, object], generation_diff["receipt_header"]) + current_receipt_hash = cast(str, header["stored_receipt_hash"]) + current_retained_hash = cast(str, header["retained_collection_hash"]) + if receipt_hash is None: + receipt_hash = current_receipt_hash + retained_collection_hash = current_retained_hash + elif current_receipt_hash != receipt_hash: + raise RuntimeError("Generation-diff receipt changed during traversal") + elif current_retained_hash != retained_collection_hash: + raise RuntimeError("Generation-diff collection changed during traversal") + pagination = cast(dict[str, object], page["pagination"]) + items = cast(list[dict[str, object]], generation_diff["items"]) + omissions = cast(list[dict[str, object]], generation_diff["omissions"]) + if pagination["returned_count"] != len(items) + len(omissions): + raise RuntimeError("Generation-diff page count does not match its items") + if omissions: + raise RuntimeError("Generation-diff traversal omitted a retained item") + for item in items: + item_hashes.append(cast(str, item["item_hash"])) + node_id = item.get("node_id") + if ( + item.get("entity") != "node" + or item.get("change") != "changed" + or not isinstance(node_id, str) + ): + raise RuntimeError("Generation-diff synthetic item has unexpected semantics") + node_ids.append(node_id) + returned += cast(int, pagination["returned_count"]) + pages.append(page) + next_cursor = pagination["next_cursor"] + if next_cursor is None: + if returned != pagination["total_count"]: + raise RuntimeError("Generation-diff traversal did not reconstruct every item") + break + cursor = cast(str, next_cursor) + if cursor in seen_cursors: + raise RuntimeError("Generation-diff pagination repeated a cursor") + seen_cursors.add(cursor) + if len(item_hashes) != len(set(item_hashes)): + raise RuntimeError("Generation-diff traversal repeated a retained item") + if ( + returned != node_count + or len(item_hashes) != node_count + or len(node_ids) != node_count + or set(node_ids) != {synthetic_node_id(index) for index in range(node_count)} + ): + raise RuntimeError("Generation-diff traversal did not reconstruct every changed node") + if retained_collection_hash != canonical_hash(item_hashes): + raise RuntimeError("Generation-diff traversal did not reconstruct its collection hash") + summary = _page_summary(pages, started_ns=started) + if cast(float, summary["elapsed_ms"]) > 500: + raise RuntimeError("Complete generation-diff traversal exceeded 500 ms") + summary.update( + { + "receipt_hash": receipt_hash, + "item_count": returned, + "ordered_item_hash": canonical_hash(item_hashes), + "counter_ranges": { + counter: { + "minimum": min( + cast(int, cast(Mapping[str, object], item["counters"])[counter]) + for item in diagnostics_records + ), + "maximum": max( + cast(int, cast(Mapping[str, object], item["counters"])[counter]) + for item in diagnostics_records + ), + } + for counter in COUNTER_NAMES + }, + } + ) + return summary + + +def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]: + project = Project.open(root) + index = ProjectIndex(project) + initial_snapshot = project.load() + focus = synthetic_node_id(0) + if len(initial_snapshot.edges) != node_count - 1 or any( + edge.target_id != focus or edge.source_id == focus for edge in initial_snapshot.edges + ): + raise RuntimeError("Milestone 2 fixture is not the expected focus fan-in graph") + index.build() + service = DocForgeService(project, capability_mode_name="read", diagnostics=True) + no_ast_service = DocForgeService( + project, + capability_mode_name="read", + no_ast=True, + diagnostics=True, + ) + operations: dict[str, object] = {} + + operations["bootstrap_read"], bootstrap = _measure( + service.bootstrap, + samples=samples, + p95_limit_ms=100, + response_limit_bytes=32_768, + validator=lambda result: _result_diagnostics( + result, + expected_counters={ + "index_checks": 1, + "index_synchronizations": 1, + "source_generation_checks": 1, + }, + ), + ) + bootstrap_policy = cast(dict[str, object], bootstrap["effective_policy"]) + if bootstrap_policy["capability_mode"] != "read": + raise RuntimeError("Read bootstrap did not preserve the explicit capability mode") + operations["bootstrap_no_ast"], no_ast_bootstrap = _measure( + no_ast_service.bootstrap, + samples=samples, + p95_limit_ms=100, + response_limit_bytes=32_768, + validator=lambda result: _result_diagnostics( + result, + expected_counters={ + "index_checks": 1, + "index_synchronizations": 1, + "source_generation_checks": 1, + }, + ), + ) + no_ast_policy = cast(dict[str, object], no_ast_bootstrap["adapter_policy"]) + if no_ast_policy["mode"] != "preserve-no-ast": + raise RuntimeError("No-AST bootstrap did not preserve the adapter policy") + + task_oracle = _task_oracle(service, focus) + task_probe = service.task_context( + "change", + "Change the central synthetic workflow", + focus_node_id=focus, + limit=1, + ) + _result_diagnostics( + task_probe, + expected_counters={ + "index_checks": 1, + "index_synchronizations": 0, + "source_generation_checks": 2, + }, + ) + gap = service.task_context( + "implementation", + "Implement the central synthetic workflow", + focus_node_id=focus, + limit=1, + ) + gap_codes = { + cast(str, item["code"]) for item in cast(list[dict[str, object]], gap["capsule"]["gaps"]) + } + if "category_not_declared" not in gap_codes: + raise RuntimeError("Task-context evidence-gap diagnostic was not preserved") + operations["task_context_diagnostic_page"], _ = _measure( + lambda: service.task_context( + "change", + "Change the central synthetic workflow", + focus_node_id=focus, + limit=100, + ), + samples=samples, + p95_limit_ms=500, + response_limit_bytes=200_000, + validator=lambda result: _result_diagnostics( + result, + expected_counters={ + "index_checks": 1, + "index_synchronizations": 0, + "source_generation_checks": 2, + }, + ), + ) + task_diagnostics_dropped: list[bool] = [] + operations["task_context_maximum_page"], _ = _measure( + lambda: service.task_context( + "change", + "Change the central synthetic workflow", + focus_node_id=focus, + limit=1_000, + ), + samples=samples, + p95_limit_ms=500, + response_limit_bytes=200_000, + validator=lambda result: _maximum_page_validator( + result, + expected_counters={ + "index_checks": 1, + "index_synchronizations": 0, + "source_generation_checks": 2, + }, + diagnostics_dropped=task_diagnostics_dropped, + ), + ) + operations["task_context_maximum_page"]["diagnostics_dropped_for_budget"] = any( + task_diagnostics_dropped + ) + task_order_hash: str | None = None + + def validate_task_summary(result: dict[str, object]) -> None: + nonlocal task_order_hash + current = cast(str, result["ordered_evidence_hash"]) + if task_order_hash is None: + task_order_hash = current + elif current != task_order_hash: + raise RuntimeError("Task-context traversal order changed across samples") + + task_measurement, task_summary = _measure( + lambda: _task_traversal( + service, + focus, + node_count=node_count, + oracle=task_oracle, + ), + samples=samples, + p95_limit_ms=2_500, + response_limit_bytes=32_768, + validator=validate_task_summary, + ) + operations["task_context_complete"] = { + **task_measurement, + "result_summary": task_summary, + } + + baseline = service.generation_diff(limit=1) + if baseline.get("receipt_state") != "current": + raise RuntimeError("Initial generation-diff receipt is not current") + for path in sorted((root / "docs" / "content").glob("node-*.md")): + path.write_text( + path.read_text(encoding="utf-8") + "\nMilestone 2 transition generation.\n", + encoding="utf-8", + ) + index.build() + generation_probe = service.generation_diff(limit=1) + _result_diagnostics( + generation_probe, + expected_counters={ + "index_checks": 0, + "index_synchronizations": 0, + "source_generation_checks": 2, + }, + ) + operations["generation_diff_diagnostic_page"], _ = _measure( + lambda: service.generation_diff(limit=100), + samples=samples, + p95_limit_ms=100, + response_limit_bytes=200_000, + validator=lambda result: _result_diagnostics( + result, + expected_counters={ + "index_checks": 0, + "index_synchronizations": 0, + "source_generation_checks": 2, + }, + ), + ) + generation_diagnostics_dropped: list[bool] = [] + operations["generation_diff_maximum_page"], _ = _measure( + lambda: service.generation_diff(limit=1_000), + samples=samples, + p95_limit_ms=100, + response_limit_bytes=200_000, + validator=lambda result: _maximum_page_validator( + result, + expected_counters={ + "index_checks": 0, + "index_synchronizations": 0, + "source_generation_checks": 2, + }, + diagnostics_dropped=generation_diagnostics_dropped, + ), + ) + operations["generation_diff_maximum_page"]["diagnostics_dropped_for_budget"] = any( + generation_diagnostics_dropped + ) + generation_order_hash: str | None = None + + def validate_generation_summary(result: dict[str, object]) -> None: + nonlocal generation_order_hash + current = cast(str, result["ordered_item_hash"]) + if generation_order_hash is None: + generation_order_hash = current + elif current != generation_order_hash: + raise RuntimeError("Generation-diff traversal order changed across samples") + + generation_measurement, generation_summary = _measure( + lambda: _generation_traversal(service, node_count=node_count), + samples=samples, + p95_limit_ms=500, + response_limit_bytes=32_768, + validator=validate_generation_summary, + ) + operations["generation_diff_complete"] = { + **generation_measurement, + "result_summary": generation_summary, + } + + project_tree_before = _tree_hash(root) + configurations: dict[str, dict[str, object]] = {} + for client in ("codex", "claude", "openclaw"): + configuration_hash: str | None = None + + def validate_configuration( + result: dict[str, object], + ) -> None: + nonlocal configuration_hash + diagnostics = cast( + Mapping[str, object], + result["_benchmark_diagnostics"], + ) + _assert_zero_work(diagnostics) + counters = cast(Mapping[str, object], diagnostics["counters"]) + if counters["index_checks"] != 0 or counters["source_generation_checks"] != 0: + raise RuntimeError("Configuration preview performed hidden project work") + current = cast(str, result["configuration_hash"]) + if configuration_hash is None: + configuration_hash = current + elif current != configuration_hash: + raise RuntimeError("Configuration preview is not deterministic") + + measurement, result = _measure( + lambda selected=client: _profiled( + lambda: generate_client_configuration(project, selected) + ), + samples=samples, + p95_limit_ms=500, + response_limit_bytes=32_768, + validator=validate_configuration, + ) + artifact = cast(dict[str, object], result["artifact"]) + configurations[client] = { + **measurement, + "configuration_hash": result["configuration_hash"], + "artifact_format": artifact["format"], + } + operations["configuration_preview"] = configurations + if _tree_hash(root) != project_tree_before: + raise RuntimeError("Configuration preview changed the project tree") + + doctors: dict[str, dict[str, object]] = {} + for client in ("codex", "claude", "openclaw"): + config_path = root.parent / f"doctor-{client}.config" + generated = generate_client_configuration( + project, + client, + output=config_path, + ) + generated_name = cast(str, generated["server_name"]) + config_before = config_path.read_bytes() + expected_state = "degraded" if client == "claude" else "healthy" + + def validate_doctor( + result: dict[str, object], + *, + selected_client: str = client, + selected_state: str = expected_state, + ) -> None: + diagnostics = cast( + Mapping[str, object], + result["_benchmark_diagnostics"], + ) + _assert_zero_work(diagnostics) + counters = cast(Mapping[str, object], diagnostics["counters"]) + if counters["index_checks"] != 0 or counters["source_generation_checks"] != 0: + raise RuntimeError("Doctor performed hidden project work") + if result["doctor_state"] != selected_state: + raise RuntimeError(f"Generated {selected_client} configuration did not pass doctor") + + doctor_measurement, doctor_result = _measure( + lambda selected=client, path=config_path, name=generated_name: _profiled( + lambda: run_doctor( + project, + selected, + config_path=path, + server_name=name, + ) + ), + samples=samples, + p95_limit_ms=100, + response_limit_bytes=32_768, + validator=validate_doctor, + ) + if _tree_hash(root) != project_tree_before or config_path.read_bytes() != config_before: + raise RuntimeError("Doctor changed project or client configuration state") + doctors[client] = { + **doctor_measurement, + "doctor_state": doctor_result["doctor_state"], + "summary": doctor_result["summary"], + } + operations["doctor"] = doctors + return { + "fixture": { + "kind": "synthetic_generic_focus_fan_in", + "node_count": node_count, + "edge_count": node_count - 1, + "source_file_count": node_count, + "max_tool_output_chars": 200_000, + }, + "operations": operations, + "process_peak_rss_kib": int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss), + } + + +def _isolated_memory(nodes: int, samples: int) -> int: + completed = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve()), + "--nodes", + str(nodes), + "--samples", + str(samples), + "--memory-probe", + ], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + timeout=180, + ) + payload = cast(dict[str, object], json.loads(completed.stdout)) + return cast(int, payload["peak_rss_kib"]) + + +def main() -> int: + arguments = _parser().parse_args() + if arguments.nodes < 2: + raise SystemExit("--nodes must be at least 2") + if arguments.samples < 1: + raise SystemExit("--samples must be positive") + with tempfile.TemporaryDirectory(prefix="docforge-milestone2-") as directory: + root = (Path(directory) / "project").resolve() + _prepare_fixture(root, arguments.nodes) + measurement = _benchmark(root, arguments.nodes, arguments.samples) + if arguments.memory_probe: + sys.stdout.write( + json.dumps( + {"peak_rss_kib": measurement["process_peak_rss_kib"]}, + sort_keys=True, + ) + ) + return 0 + isolated_peak = _isolated_memory(arguments.nodes, arguments.samples) + if isolated_peak > 262_144: + raise RuntimeError("Milestone 2 isolated process exceeded 256 MiB peak RSS") + result = { + "schema_version": 1, + "benchmark": "docforge2_milestone2", + "source": { + "revision": _git(["rev-parse", "HEAD"]), + "dirty": bool(_git(["status", "--porcelain"])), + }, + "environment": { + "platform": platform.platform(), + "machine": platform.machine(), + "python": platform.python_version(), + "implementation": platform.python_implementation(), + }, + "method": { + "clock": "time.perf_counter_ns", + "response_size": "UTF-8 bytes of compact sorted JSON", + "samples": arguments.samples, + "memory_probe_samples": arguments.samples, + "warmups": 1, + "percentile": "nearest-rank", + "memory": "isolated child-process resource.getrusage(RUSAGE_SELF).ru_maxrss", + "memory_limit_kib": 262_144, + "zero_work_counters": list(ZERO_WORK_COUNTERS), + }, + **measurement, + "isolated_process_peak_rss_kib": isolated_peak, + } + encoded = json.dumps(result, sort_keys=True, indent=2) + "\n" + if arguments.output is not None: + output = arguments.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(encoded, encoding="utf-8") + sys.stdout.write(encoded) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 4c5773c8652aedfeab82cf87ee7da1b7bc01eddc Mon Sep 17 00:00:00 2001 From: Andraxion Date: Wed, 29 Jul 2026 10:22:56 -0400 Subject: [PATCH 2/2] Close Milestone 2 with measured evidence --- ACTIVE_SLICE.md | 16 +- DEVELOPMENT_NOTES.md | 27 ++ README.md | 20 +- benchmarks/README.md | 14 + benchmarks/milestone2-2026-07-29.json | 457 ++++++++++++++++++++++++++ docs/COMPATIBILITY.md | 11 + docs/CONTRACT.md | 21 ++ docs/MILESTONE_2_BASELINE.md | 85 +++++ docs/MILESTONE_2_CLOSEOUT.md | 68 ++++ docs/USER_MANUAL.md | 61 ++++ 10 files changed, 771 insertions(+), 9 deletions(-) create mode 100644 benchmarks/milestone2-2026-07-29.json create mode 100644 docs/MILESTONE_2_BASELINE.md create mode 100644 docs/MILESTONE_2_CLOSEOUT.md diff --git a/ACTIVE_SLICE.md b/ACTIVE_SLICE.md index 97be32f..71c0e5e 100644 --- a/ACTIVE_SLICE.md +++ b/ACTIVE_SLICE.md @@ -6,14 +6,14 @@ Goal: Let one project-bound server return compact, task-shaped, explainable cont In scope: Capability modes; capability-aware bootstrap; versioned retrieval plans and context capsules; task-shaped context; generation diffs; evidence-gap diagnostics; generated client configuration; doctor checks. Out of scope: Independent render-plan packages; adapter SDK expansion; self-hosting; storage replacement; embeddings; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases. Done when: Policy and capabilities are explicit; bootstrap recommends only available actions; task context is compact, deterministic, provenance-bearing, and bounded; generation and evidence gaps are explainable; generated configuration and doctor checks are safe and tested; the complete repository gate and Milestone 2 benchmark pass. -Status: Candidate frozen. Effective policy, versioned task retrieval, latest-generation diff -receipts, and logarithmic task-context page packing are committed and pushed on `dev`. -Deterministic client configuration and the read-only integration doctor now pass their bounded -publication, path-race, malformed-input, redaction, and no-hidden-work audits. The complete -repository gate passes with 205 tests and 120 subtests. A disposable 1,000-node audit sample passes -the maintained task-context, generation-diff, response-size, counter, and memory gates. Final -clean-revision benchmark evidence and documentation closeout remain before the milestone is marked -complete. +Status: Complete. Effective policy, versioned task retrieval, latest-generation diff receipts, +logarithmic bounded page packing, deterministic client configuration, and the read-only integration +doctor are implemented and contract-tested. The complete repository gate passes with 205 tests and +120 subtests. Three independent adversarial audits found no remaining implementation blocker. The +clean 1,000-node baseline is recorded against candidate commit +`fb0df5e4a1c591c2a84788fd4814d98550f11863`, including task/generation reconstruction, +response-size behavior, zero-hidden-work counters, and isolated memory. No tag or release was +created, no production integration was repointed, and self-hosting remains out of scope. ``` Milestones 3–5 remain directional context and are not active. diff --git a/DEVELOPMENT_NOTES.md b/DEVELOPMENT_NOTES.md index 536100e..8e15aea 100644 --- a/DEVELOPMENT_NOTES.md +++ b/DEVELOPMENT_NOTES.md @@ -583,3 +583,30 @@ compilation, lock and dependency checks, package builds, and all three milestone Three independent final audits approve client publication and policy binding, doctor fail-closed behavior, and benchmark/contract coverage. Clean-revision benchmark evidence is still required before closeout. + +### Milestone 2 closeout + +Candidate commit `fb0df5e4a1c591c2a84788fd4814d98550f11863` passed the clean ten-sample +Milestone 2 benchmark. Task-context complete traversal measured 703.561 ms median and 721.847 ms +p95 across 11 bounded pages. It reconstructed the exact 1,000-candidate collection from 108 cited +evidence records, 891 original token-budget omissions, and one hash-attested response-limit +surrogate. Generation-diff complete traversal measured 418.607 ms median and 425.315 ms p95 across +10 pages. + +Read and no-AST bootstrap remained below 10 ms p95. The maximum generation page used 199,566 bytes +of the 200,000-byte budget and correctly discarded diagnostics before primary evidence. +Configuration preview measured about 314 ms median and 365 ms p95 because it proves the real +isolated interpreter import on every invocation. Codex and OpenClaw doctor checks remained below +0.6 ms p95; Claude remained explicitly degraded because its timeout format is unverified. +Isolated-process peak RSS was 86,448 KiB against the 262,144 KiB gate. + +All measured configuration and doctor counters were zero. Task-context pages performed one index +check and two cheap generation checks with no loads, parses, synchronization, builds, extraction, +rendering, or viewer work. Generation-diff pages performed two cheap generation checks and no +index check. The canonical machine-readable result is +`benchmarks/milestone2-2026-07-29.json`. + +Milestone 2 is complete. Follow-up ideas stay explicitly later-scope: avoid recomputing the +task-shaped capsule for every continuation page, add authenticated continuation when the threat +model requires it, verify a native Claude timeout representation, and introduce adapter-owned +launcher metadata before generating configurations for custom adapters. diff --git a/README.md b/README.md index d08b2ab..16a03a6 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ declared manuals, visualizes project structure, and manages reviewable documenta continuation. - Records one bounded, versioned latest-generation graph transition without creating a history database. +- Generates deterministic project-bound Codex, Claude, and OpenClaw client fragments without + copying ambient secrets. +- Diagnoses one client binding through bounded read-only checks without starting MCP or rebuilding + project state. - Automatically synchronizes disposable indexes before MCP work. - Creates, validates, diffs, and previews isolated changesets. - Registers complete proposals atomically without caller-managed hash chaining. @@ -135,6 +139,16 @@ Start an MCP server for one project: Add `--canonical-applier project-editor` only when that MCP integration should expose the hash-bound `docforge_apply_changeset` tool. +Preview a read-only Codex fragment and diagnose an installed binding: + +```bash +.venv/bin/docforge configure codex --project "$PROJECT" +.venv/bin/docforge doctor --client codex --project "$PROJECT" +``` + +Pass `--output /absolute/path/docforge.toml` only when creating a standalone fragment. DocForge +never replaces or merges an existing different client file. + For an unconfigured codebase, begin with a read-only language and documentation assessment: ```bash @@ -156,6 +170,10 @@ DocForge describes them as a source graph. performance, memory, rendering and response sizes, bottlenecks, and missing coverage. - [Milestone 0 closeout](docs/MILESTONE_0_CLOSEOUT.md) — lineage, migration, security scan, repository state, and fresh-clone proof. +- [Milestone 2 baseline](docs/MILESTONE_2_BASELINE.md) — task context, generation diff, client + configuration, doctor, response-size, counter, and memory measurements. +- [Milestone 2 closeout](docs/MILESTONE_2_CLOSEOUT.md) — implemented contracts, adversarial + validation, exclusions, and exact candidate evidence. - [MCP contract](docs/MCP_CONTRACT.md) — exact tool and process boundary. - [Viewer manager](docs/VIEWER_MANAGER.md) — native service setup and lifecycle. - [Adapter decision](docs/APPLICATION_DECISION.md) — why custom adapters own canonical @@ -178,7 +196,7 @@ make gate Focused entry points are available as `make contract`, `make test`, `make type`, `make benchmark-smoke`, `make benchmark`, `make benchmark-m1-smoke`, and -`make benchmark-m1`. +`make benchmark-m1`. Milestone 2 adds `make benchmark-m2-smoke` and `make benchmark-m2`. The committed 1,000-node baseline and its measurement method are under `benchmarks/`. diff --git a/benchmarks/README.md b/benchmarks/README.md index 05f5c55..9d78eda 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -27,6 +27,13 @@ Run the maintained 1,000-node Milestone 1 benchmark: make benchmark-m1 ``` +Run the Milestone 2 agent-workflow smoke and full gates: + +```bash +make benchmark-m2-smoke +make benchmark-m2 +``` + The benchmark creates canonical sources, derived state, changesets, rendered output, and caches only in a disposable temporary directory. It does not read another project, self-host DocForge, or mutate repository content. @@ -42,6 +49,13 @@ harness enforces operation-specific p95 ceilings and fixed zero-work counter inv human-readable interpretation is in [`docs/MILESTONE_1_BASELINE.md`](../docs/MILESTONE_1_BASELINE.md). +`milestone2-2026-07-29.json` is the clean-tree agent-retrieval and client-integration baseline +captured from commit `fb0df5e4a1c591c2a84788fd4814d98550f11863`. It gates every warmup and +sample, reconstructs complete task-context and generation-diff collections across bounded pages, +records whether diagnostics were dropped for response budget, checks all hidden-work counters, +and measures isolated-process peak RSS. Its interpretation is in +[`docs/MILESTONE_2_BASELINE.md`](../docs/MILESTONE_2_BASELINE.md). + The generic fixture exposes whole-source scaling. It does not replace the incremental adapter equivalence tests and does not claim to measure a portable graph renderer, because Milestone 0 has no portable graph-planning or graph-rendering contract. diff --git a/benchmarks/milestone2-2026-07-29.json b/benchmarks/milestone2-2026-07-29.json new file mode 100644 index 0000000..f4196ac --- /dev/null +++ b/benchmarks/milestone2-2026-07-29.json @@ -0,0 +1,457 @@ +{ + "benchmark": "docforge2_milestone2", + "environment": { + "implementation": "CPython", + "machine": "x86_64", + "platform": "Linux-7.1.3-200.nobara.fc44.x86_64-x86_64-with-glibc2.43", + "python": "3.14.6" + }, + "fixture": { + "edge_count": 999, + "kind": "synthetic_generic_focus_fan_in", + "max_tool_output_chars": 200000, + "node_count": 1000, + "source_file_count": 1000 + }, + "isolated_process_peak_rss_kib": 86448, + "method": { + "clock": "time.perf_counter_ns", + "memory": "isolated child-process resource.getrusage(RUSAGE_SELF).ru_maxrss", + "memory_limit_kib": 262144, + "memory_probe_samples": 10, + "percentile": "nearest-rank", + "response_size": "UTF-8 bytes of compact sorted JSON", + "samples": 10, + "warmups": 1, + "zero_work_counters": [ + "project_loads", + "source_files_parsed", + "source_bytes_parsed", + "adapter_projection_loads", + "adapter_source_extractions", + "index_synchronizations", + "index_builds", + "render_prepare_calls", + "render_output_bytes_built", + "render_output_bytes_hashed", + "viewer_manager_requests" + ] + }, + "operations": { + "bootstrap_no_ast": { + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 1, "minimum": 1}, + "index_synchronizations": {"maximum": 1, "minimum": 1}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 1, "minimum": 1}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "max_ms": 9.379, + "maximum_response_bytes": 8796, + "median_ms": 9.153, + "min_ms": 9.026, + "p95_limit_ms": 100, + "p95_ms": 9.379, + "response_bytes": 8795, + "response_limit_bytes": 32768, + "samples": 10, + "validated_invocations": 11 + }, + "bootstrap_read": { + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 1, "minimum": 1}, + "index_synchronizations": {"maximum": 1, "minimum": 1}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 1, "minimum": 1}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "max_ms": 9.884, + "maximum_response_bytes": 7495, + "median_ms": 9.406, + "min_ms": 9.053, + "p95_limit_ms": 100, + "p95_ms": 9.884, + "response_bytes": 7491, + "response_limit_bytes": 32768, + "samples": 10, + "validated_invocations": 11 + }, + "configuration_preview": { + "claude": { + "artifact_format": "claude-json-fragment-v1", + "configuration_hash": "4dbb4ed0f38264fdba350de8904cc898493d620194b5105b7889c54bd5913c9c", + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 0, "minimum": 0}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 0, "minimum": 0}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "max_ms": 364.365, + "maximum_response_bytes": 2748, + "median_ms": 314.326, + "min_ms": 314.278, + "p95_limit_ms": 500, + "p95_ms": 364.365, + "response_bytes": 2748, + "response_limit_bytes": 32768, + "samples": 10, + "validated_invocations": 11 + }, + "codex": { + "artifact_format": "codex-toml-fragment-v1", + "configuration_hash": "3e1dd5191021da1778cd1c4f4658768537775e5e16252e42d4c80e328841145b", + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 0, "minimum": 0}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 0, "minimum": 0}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "max_ms": 364.383, + "maximum_response_bytes": 2627, + "median_ms": 314.365, + "min_ms": 314.248, + "p95_limit_ms": 500, + "p95_ms": 364.383, + "response_bytes": 2627, + "response_limit_bytes": 32768, + "samples": 10, + "validated_invocations": 11 + }, + "openclaw": { + "artifact_format": "openclaw-json-fragment-v1", + "configuration_hash": "6f269e90a55088c5d517f91761c53a3b90036d62fd80b5b9d094668267257b99", + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 0, "minimum": 0}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 0, "minimum": 0}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "max_ms": 364.532, + "maximum_response_bytes": 2869, + "median_ms": 314.401, + "min_ms": 314.251, + "p95_limit_ms": 500, + "p95_ms": 364.532, + "response_bytes": 2869, + "response_limit_bytes": 32768, + "samples": 10, + "validated_invocations": 11 + } + }, + "doctor": { + "claude": { + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 0, "minimum": 0}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 0, "minimum": 0}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "doctor_state": "degraded", + "max_ms": 0.446, + "maximum_response_bytes": 3669, + "median_ms": 0.364, + "min_ms": 0.352, + "p95_limit_ms": 100, + "p95_ms": 0.446, + "response_bytes": 3669, + "response_limit_bytes": 32768, + "samples": 10, + "summary": {"failed": 0, "passed": 11, "skipped": 1, "warning": 2}, + "validated_invocations": 11 + }, + "codex": { + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 0, "minimum": 0}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 0, "minimum": 0}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "doctor_state": "healthy", + "max_ms": 0.556, + "maximum_response_bytes": 3595, + "median_ms": 0.421, + "min_ms": 0.404, + "p95_limit_ms": 100, + "p95_ms": 0.556, + "response_bytes": 3595, + "response_limit_bytes": 32768, + "samples": 10, + "summary": {"failed": 0, "passed": 13, "skipped": 1, "warning": 0}, + "validated_invocations": 11 + }, + "openclaw": { + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 0, "minimum": 0}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 0, "minimum": 0}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "doctor_state": "healthy", + "max_ms": 0.484, + "maximum_response_bytes": 3602, + "median_ms": 0.384, + "min_ms": 0.353, + "p95_limit_ms": 100, + "p95_ms": 0.484, + "response_bytes": 3602, + "response_limit_bytes": 32768, + "samples": 10, + "summary": {"failed": 0, "passed": 13, "skipped": 1, "warning": 0}, + "validated_invocations": 11 + } + }, + "generation_diff_complete": { + "max_ms": 425.315, + "maximum_response_bytes": 984, + "median_ms": 418.607, + "min_ms": 410.974, + "p95_limit_ms": 500, + "p95_ms": 425.315, + "response_bytes": 983, + "response_limit_bytes": 32768, + "result_summary": { + "aggregate_page_bytes": 664715, + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 0, "minimum": 0}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 2, "minimum": 2}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "elapsed_ms": 419.04, + "item_count": 1000, + "maximum_cursor_bytes": 448, + "maximum_page_bytes": 66516, + "ordered_item_hash": "1ac48cc72532809ef5d3e949756e536eec819f348eaf06338c9b39b14e63b2c7", + "page_count": 10, + "receipt_hash": "6913c962972d8255f56966e5cfab5ac8293e41bdfb5f39ef91a4f33a3b092f88", + "status": "ok" + }, + "samples": 10, + "validated_invocations": 11 + }, + "generation_diff_diagnostic_page": { + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 0, "minimum": 0}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 2, "minimum": 2}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "max_ms": 41.65, + "maximum_response_bytes": 66516, + "median_ms": 40.961, + "min_ms": 40.239, + "p95_limit_ms": 100, + "p95_ms": 41.65, + "response_bytes": 66516, + "response_limit_bytes": 200000, + "samples": 10, + "validated_invocations": 11 + }, + "generation_diff_maximum_page": { + "diagnostics_dropped_for_budget": true, + "max_ms": 59.184, + "maximum_response_bytes": 199566, + "median_ms": 55.565, + "min_ms": 54.68, + "p95_limit_ms": 100, + "p95_ms": 59.184, + "response_bytes": 199566, + "response_limit_bytes": 200000, + "samples": 10, + "validated_invocations": 11 + }, + "task_context_complete": { + "max_ms": 721.847, + "maximum_response_bytes": 1325, + "median_ms": 703.561, + "min_ms": 688.975, + "p95_limit_ms": 2500, + "p95_ms": 721.847, + "response_bytes": 1324, + "response_limit_bytes": 32768, + "result_summary": { + "aggregate_page_bytes": 348845, + "capsule_hash": "20663ed685a255f7cb8a0e8d78262bf7bf26863d728f0159e0b29000f6a52b0a", + "collection_hash": "9dbc46eb5b1ac8c8340bb149205d3599fffaf67e3b363de2d035475da274857c", + "collection_hash_reconstructed": true, + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 1, "minimum": 1}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 2, "minimum": 2}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "elapsed_ms": 719.55, + "evidence_count": 108, + "item_count": 1000, + "maximum_cursor_bytes": 1066, + "maximum_page_bytes": 151172, + "omission_count": 892, + "ordered_candidate_hash": "bdeb3a8f4018000f72a5ff1891aa800b6edf98070b9814f443c6bac4e52c38f3", + "ordered_evidence_hash": "ec07bac7f528f5a3afbc083ea5ad60541335c8789d42980fe7bae0476e0a5331", + "page_count": 11, + "plan_hash": "84dbe60267e5f8359adcf30d4d395f1ac5d5bea957beaa3888d2938120860a9c", + "status": "ok" + }, + "samples": 10, + "validated_invocations": 11 + }, + "task_context_diagnostic_page": { + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 1, "minimum": 1}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 2, "minimum": 2}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "max_ms": 93.115, + "maximum_response_bytes": 7190, + "median_ms": 73.326, + "min_ms": 72.264, + "p95_limit_ms": 500, + "p95_ms": 93.115, + "response_bytes": 7190, + "response_limit_bytes": 200000, + "samples": 10, + "validated_invocations": 11 + }, + "task_context_maximum_page": { + "counter_ranges": { + "adapter_projection_loads": {"maximum": 0, "minimum": 0}, + "adapter_source_extractions": {"maximum": 0, "minimum": 0}, + "index_builds": {"maximum": 0, "minimum": 0}, + "index_checks": {"maximum": 1, "minimum": 1}, + "index_synchronizations": {"maximum": 0, "minimum": 0}, + "project_loads": {"maximum": 0, "minimum": 0}, + "render_output_bytes_built": {"maximum": 0, "minimum": 0}, + "render_output_bytes_hashed": {"maximum": 0, "minimum": 0}, + "render_prepare_calls": {"maximum": 0, "minimum": 0}, + "source_bytes_parsed": {"maximum": 0, "minimum": 0}, + "source_files_parsed": {"maximum": 0, "minimum": 0}, + "source_generation_checks": {"maximum": 2, "minimum": 2}, + "viewer_manager_requests": {"maximum": 0, "minimum": 0} + }, + "diagnostics_dropped_for_budget": false, + "max_ms": 87.372, + "maximum_response_bytes": 7192, + "median_ms": 84.933, + "min_ms": 83.652, + "p95_limit_ms": 500, + "p95_ms": 87.372, + "response_bytes": 7192, + "response_limit_bytes": 200000, + "samples": 10, + "validated_invocations": 11 + } + }, + "process_peak_rss_kib": 86072, + "schema_version": 1, + "source": { + "dirty": false, + "revision": "fb0df5e4a1c591c2a84788fd4814d98550f11863" + } +} diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 99053b1..12c6247 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -60,6 +60,17 @@ MCP results retain: The result schema describes the common envelope. Operation-specific fields are additive and remain bounded by the configured tool-output limit. +The following Milestone 2 CLI additions do not change existing command signatures: + +- `docforge configure codex|claude|openclaw --project ROOT` +- `docforge doctor --client codex|claude|openclaw` + +Configuration output is a new version-1 machine-local contract. It preserves the `docforge` +package and executable names and emits the existing `docforge.mcp_server` module entrypoint. +Existing hand-written client configurations remain valid and are never rewritten automatically. +Doctor is inspection-only and does not become a hidden bootstrap, synchronization, or migration +path. + ## Versioned data contracts Milestone 0 preserves: diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 832a5af..ec2b062 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -22,6 +22,8 @@ commit when Git is available; it cannot change repository state. - Task context capsule: `schemas/context-capsule.schema.json`, version 1. - Latest generation diff: `schemas/generation-diff.schema.json`, version 1. - Latest generation-diff page: `schemas/generation-diff-page.schema.json`, version 1. +- Generated client configuration: `schemas/client-configuration.schema.json`, version 1. +- Client doctor result: `schemas/doctor-result.schema.json`, version 1. - Index schema: version 3, disposable and reproducible. - Index attestation: schema version 1, disposable and reproducible. - Core, CLI, and MCP server: version 1.3.0.dev0. @@ -75,6 +77,25 @@ not history and contains no Logic details or source text. Public pages carry one `receipt_header`; its `stored_receipt_hash` identifies the complete persisted receipt rather than the header alone. One top-level pagination object carries the only continuation cursor. +## Machine-local client integration + +Generated Codex, Claude, and OpenClaw fragments are machine-local projections. They are not +canonical project content. Version 1 binds the selected project, exact isolated Python +interpreter, canonical argument layout, effective policy, no-AST projection, render policy, +timeouts, artifact bytes, and configuration hash. + +Preview is side-effect free. Explicit publication creates only one new private standalone +fragment in an existing real directory. It never merges or replaces different content. Descriptor, +parent, target, content, ownership, permission, and link identities are checked before and after +the directory durability boundary. A failure rolls back when that can be proven and otherwise +returns bounded unconfirmed publication evidence. + +Doctor is a bounded read-only inspector with one fixed check inventory. It uses stable no-follow +descriptor and configuration reads plus stat-only derived-index evidence. It never loads a +complete projection, opens SQLite, starts MCP, executes the configured command, synchronizes, +builds, renders, starts a viewer, or writes configuration. Unprovable client behavior is a warning, +not an invented success. + ## Isolated proposal model Create, update, move, and delete are ordered node operations inside an isolated changeset. Every diff --git a/docs/MILESTONE_2_BASELINE.md b/docs/MILESTONE_2_BASELINE.md new file mode 100644 index 0000000..44089f6 --- /dev/null +++ b/docs/MILESTONE_2_BASELINE.md @@ -0,0 +1,85 @@ +# Milestone 2 baseline + +## Scope and method + +This baseline records the agent-retrieval and client-integration behavior added in Milestone 2. +It was captured on 2026-07-29 from clean candidate commit +`fb0df5e4a1c591c2a84788fd4814d98550f11863`. + +The maintained command was: + +```bash +.venv/bin/python tools/milestone2_benchmark.py \ + --nodes 1000 \ + --samples 10 \ + --output /tmp/docforge-milestone2-final.json +``` + +The fixture contains 1,000 Markdown nodes and 999 edges in a direct fan-in around one focus node. +The configured MCP response limit is 200,000 characters. Durations use +`time.perf_counter_ns()` and nearest-rank p95. Peak memory uses an isolated child process and +`RUSAGE_SELF`. Every warmup and measured invocation is validated. + +Environment: + +- Linux 7.1.3-200.nobara.fc44.x86_64. +- CPython 3.14.6. +- x86_64. +- Ten warm samples after one warmup. +- Isolated memory ceiling: 262,144 KiB. + +The complete machine-readable result is +[`benchmarks/milestone2-2026-07-29.json`](../benchmarks/milestone2-2026-07-29.json). + +## Results + +| Operation | Median | p95 | Limit | Maximum response | +|---|---:|---:|---:|---:| +| Read bootstrap | 9.406 ms | 9.884 ms | 100 ms | 7,495 B | +| No-AST bootstrap | 9.153 ms | 9.379 ms | 100 ms | 8,796 B | +| Task diagnostic page | 73.326 ms | 93.115 ms | 500 ms | 7,190 B | +| Task complete traversal | 703.561 ms | 721.847 ms | 2,500 ms | 151,172 B/page | +| Generation diagnostic page | 40.961 ms | 41.650 ms | 100 ms | 66,516 B | +| Generation maximum page | 55.565 ms | 59.184 ms | 100 ms | 199,566 B | +| Generation complete traversal | 418.607 ms | 425.315 ms | 500 ms | 66,516 B/page | +| Codex configuration preview | 314.365 ms | 364.383 ms | 500 ms | 2,627 B | +| Claude configuration preview | 314.326 ms | 364.365 ms | 500 ms | 2,748 B | +| OpenClaw configuration preview | 314.401 ms | 364.532 ms | 500 ms | 2,869 B | +| Codex doctor | 0.421 ms | 0.556 ms | 100 ms | 3,595 B | +| Claude doctor | 0.364 ms | 0.446 ms | 100 ms | 3,669 B | +| OpenClaw doctor | 0.384 ms | 0.484 ms | 100 ms | 3,602 B | + +Isolated peak RSS was 86,448 KiB. + +Task traversal returned 108 evidence records and 892 explicit omissions across 11 pages. One +individually oversized focus record became a response-limit surrogate bound to the original record +hash. The remaining omissions were token-budget evidence. The benchmark verified every unique +subject, reconstructed the original collection hash, and matched the exact 1,000-node fixture. + +Generation traversal returned all 1,000 changed-node details across 10 pages. It reconstructed the +stored retained-collection hash. The maximum generation page approached the response limit and +proved that optional diagnostics were dropped before the primary result. + +## Structured-work gates + +Configuration preview and doctor performed zero project loads, source parses, adapter projection +loads, adapter extraction, index checks, synchronization, index builds, render preparation, +rendered-byte construction or hashing, and viewer-manager requests. + +Task-context pages performed exactly one index check and two cheap source-generation checks. They +performed none of the hidden work above. Generation-diff pages performed exactly two cheap +source-generation checks and no index check or hidden work. + +## Measured limits and future notes + +- Continuation is stateless and regenerates the task capsule for each page. The complete + 11-page traversal remains within its gate, but later work can avoid repeated planning without + weakening generation binding. +- Configuration preview deliberately spends about 314 ms proving that the exact isolated + interpreter can import the MCP module. Discovery-only checks were rejected as unsafe. +- Claude configuration syntax is supported, but its timeout representation remains unverified. + Doctor therefore reports degraded rather than healthy. +- Doctor is a configuration inspector, not an MCP connection or SQLite integrity test. +- Legacy adapters without cheap source-generation identity report unknown for generation-diff + freshness. +- The results do not justify a storage rewrite. SQLite remains fast after one generation is pinned. diff --git a/docs/MILESTONE_2_CLOSEOUT.md b/docs/MILESTONE_2_CLOSEOUT.md new file mode 100644 index 0000000..8ff2f8f --- /dev/null +++ b/docs/MILESTONE_2_CLOSEOUT.md @@ -0,0 +1,68 @@ +# Milestone 2 closeout + +## Outcome + +Milestone 2 is complete. One project-bound server can expose an explicit effective policy and +return compact, task-shaped, explainable context. Users can generate deterministic client +fragments and inspect their bindings without hidden runtime work. + +Implemented contracts: + +- Version-1 effective policy and capability-aware bootstrap. +- Version-1 retrieval plans and context capsules. +- Bounded task-context pagination with evidence gaps and explicit omissions. +- One disposable latest-generation transition receipt and paged read surface. +- Deterministic Codex, Claude, and OpenClaw standalone configuration fragments. +- Fixed-inventory read-only doctor results. +- Dedicated configuration and doctor JSON schemas. +- Repository-native Milestone 2 contract, smoke, scale, response-size, counter, and memory gates. + +## Candidate evidence + +The frozen implementation candidate is +`fb0df5e4a1c591c2a84788fd4814d98550f11863`. + +The complete repository gate passed: + +- Ruff formatting and lint. +- HTML, rendered-manual HTML, CSS, and JavaScript checks. +- Pyright with zero diagnostics. +- Warning-strict compilation and tests. +- 205 tests and 120 subtests. +- Lock and npm dependency-tree checks. +- Wheel and source-distribution builds. +- Milestone 0, 1, and 2 smoke benchmarks. + +Three independent read-only adversarial audits covered client publication and policy binding, +doctor race and malformed-input behavior, and benchmark/contract evidence. Reproduced descriptor, +parent, target, filesystem, policy, secret-redaction, ambiguity, parser, response-size, and hidden +work defects were fixed and regression-tested before the candidate was frozen. + +The clean ten-sample 1,000-node benchmark passed every threshold. Exact measurements and counter +ranges are recorded in +[`MILESTONE_2_BASELINE.md`](MILESTONE_2_BASELINE.md) and +[`benchmarks/milestone2-2026-07-29.json`](../benchmarks/milestone2-2026-07-29.json). + +## Preserved boundaries + +- The `docforge` package, imports, CLI executable, MCP executable, and existing tool names remain. +- Legacy one-method `load_projection()` adapters remain supported. +- The no-AST shorthand and legacy adapter-policy payload remain compatible. +- Project descriptor schema version 1 remains unchanged. +- No storage replacement was introduced. +- No legacy DocForge MCP or DocForge2 self-hosting was used. +- WorldForge and ScrapeStation were not touched. +- No production MCP integration was repointed. +- The legacy Forgejo repository and `legacy` remote were not changed. +- No tag, release, release announcement, or visibility change was created. + +## Known follow-up work + +The next active milestone may improve projection independence. It must not silently absorb these +separate future ideas: + +- Avoid recomputing a complete task capsule for every continuation page. +- Add authenticated cursors only if a stronger threat model requires them. +- Verify Claude's native timeout representation. +- Add versioned adapter-owned launcher metadata before generating custom-adapter configurations. +- Keep doctor read-only; a live connection test must be an explicit separate operation. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 48e26b5..a92ed1b 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -508,6 +508,67 @@ visualization-status visualization-stop ``` +### Client configuration and doctor + +Preview one deterministic standalone client fragment: + +```bash +docforge configure codex --project /absolute/path/MyProject +docforge configure claude --project /absolute/path/MyProject +docforge configure openclaw --project /absolute/path/MyProject +``` + +Preview is the default. Add `--output /absolute/path/fragment` to create a new private fragment in +an existing real directory. Publication is create-only. DocForge accepts an identical existing +private single-link file as unchanged, but it never merges, replaces, broadens permissions, or +follows a symlink. Descriptor, parent, and target identities are revalidated across the +publication commit. + +The generated command uses the exact current Python interpreter with isolated module startup. +Generation first proves that this interpreter can import `docforge.mcp_server`. The result binds +the project root, effective policy, arguments, artifact bytes, and all hashes. It copies no ambient +environment values. + +Select authority explicitly: + +```bash +docforge configure codex \ + --project /absolute/path/MyProject \ + --capability-mode proposal \ + --proposal-writer project-editor + +docforge configure codex \ + --project /absolute/path/MyProject \ + --capability-mode application \ + --proposal-writer project-editor \ + --canonical-applier project-editor +``` + +Read mode is the default. Proposal and application modes fail closed unless the descriptor +declares the named writer, and application requires the same writer/applier identity. Add +`--no-ast` to preserve the no-AST binding. Generic CLI generation refuses project-owned adapters +because it cannot safely reconstruct their composition. + +Inspect one configured client binding: + +```bash +docforge doctor --client codex --project /absolute/path/MyProject +docforge doctor --client codex \ + --project /absolute/path/MyProject \ + --config /absolute/path/config.toml \ + --server-name my-project-docforge +``` + +Doctor returns `healthy`, `degraded`, or `unhealthy` with exit codes 0, 1, or 2. Its fixed +version-1 inventory checks project and descriptor binding, the client driver and entry, executable +and arguments, project root, effective policy, no-AST state, timeouts, environment-key names, +tool-filter representation, and stat-only index presence. + +Doctor is intentionally not a connection test. It never loads canonical sources, opens SQLite, +starts MCP, executes the configured command, synchronizes, builds, renders, starts a viewer, or +writes configuration. Claude timeout representation and client filtering that cannot be proved +locally remain explicit warnings. + ## MCP usage Run one MCP server per project with absolute paths: