1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Preserve no-AST adapter bindings

This commit is contained in:
Andraxion 2026-07-29 02:59:15 -04:00
parent bb13258861
commit 6c05607b14
7 changed files with 209 additions and 13 deletions

View file

@ -40,6 +40,12 @@ path without modification. Adapters gain incremental performance only when they
implement the source manifest and extraction methods. Incremental adapters must retain
`load_projection()` as their clean-rebuild fallback and equivalence oracle.
MCP bindings can additionally select `--no-ast` when an owner wants to preserve an existing
non-AST adapter. The binding advertises that policy to clients, forbids adapter rewrites that add
AST, Tree-sitter, compiler-AST, or function-Logic extraction, blocks the Logic tool, and rejects
nonempty Logic publication. Complete-projection adapters continue unchanged, and non-AST
incremental fingerprinting and caching remain allowed.
## Graph views
The browser presents the primary architecture graph through three complementary views and loads a

View file

@ -162,3 +162,32 @@ does not expose canonical application.
DocForge pins the official stable Python MCP SDK to the compatible `mcp>=1.28,<2` release line.
Migration to a later major release requires a separate contract and protocol compatibility review.
## Preserved no-AST bindings
An owner may start the generic MCP server with `--no-ast`:
```bash
docforge-mcp --project-root /absolute/project --no-ast
```
Project-owned integrations select the same immutable process policy with
`create_project_server(..., no_ast=True)` or `create_read_only_server(..., no_ast=True)`.
The policy preserves the current adapter extraction strategy. It does not require an adapter API
migration and does not disable complete-projection loading or non-AST incremental fingerprinting,
invalidation, and caching.
Both `docforge_bootstrap` and `docforge_get_contract` report the exact policy. MCP server
instructions tell clients not to add Python AST, Tree-sitter, compiler-AST, or function-Logic
extraction. Under this binding:
- `docforge_get_logic` returns `adapter_policy_forbids_logic`;
- a nonempty Logic projection is rejected before index publication;
- `adapter_ast_upgrade` and `function_logic_extraction` appear as excluded operations; and
- changing the policy requires changing the process configuration and starting a new MCP process.
The policy governs the DocForge binding and conforming MCP clients. DocForge still exposes no
filesystem sandbox and cannot prevent an unrelated process with direct repository write access
from editing adapter files. Repository permissions and project instructions remain responsible for
that broader boundary.

View file

@ -656,6 +656,25 @@ traversal.
See [Incremental Adapter Indexing](INCREMENTAL_INDEXING.md) for the complete contract, cache
invalidation rules, manual-application lifecycle, and lazy Logic boundary.
### Preserving an older non-AST adapter
Use `--no-ast` on the MCP binding when the project owner wants the existing adapter preserved
without AST, Tree-sitter, compiler-AST, or function-Logic upgrades:
```bash
docforge-mcp --project-root /absolute/project --no-ast
```
For a project-owned server, pass `no_ast=True` to `create_project_server()` or
`create_read_only_server()`. Bootstrap and contract responses then expose
`mode=preserve-no-ast`. The Logic tool is blocked, and DocForge refuses to publish nonempty Logic
projections.
This policy does not disable the Release 1 `load_projection()` path. It also permits incremental
fingerprinting and caching when those mechanisms do not add AST analysis. The adapter can
therefore benefit from current synchronization, proposals, application, rendering, and graph tools
without a source-analysis rewrite.
## Troubleshooting
### `stale_index` or `visualization_stale`

View file

@ -107,8 +107,9 @@ def _status(
class ProjectIndex:
"""A disposable index that always checks current canonical source before queries."""
def __init__(self, project: ProjectService) -> None:
def __init__(self, project: ProjectService, *, allow_logic: bool = True) -> None:
self.project = project
self.allow_logic = allow_logic
self._verified_index_signature: tuple[int, int, int, int, int] | None = None
@property
@ -391,7 +392,16 @@ class ProjectIndex:
def _logic_projections(self) -> tuple[LogicProjection, ...]:
if isinstance(self.project, LogicProject):
return self.project.logic_projections()
projections = self.project.logic_projections()
if projections and not self.allow_logic:
raise DocForgeError(
"adapter_policy_forbids_logic",
(
"This index preserves a no-AST adapter and refuses function-Logic "
"publication"
),
)
return projections
return ()
def check(self, *, verify_rows: bool = True) -> dict[str, object]:

View file

@ -112,9 +112,10 @@ class DocForgeService:
context_provider: ContextProvider = compile_context,
tool_surface: tuple[str, ...] | None = None,
binding_metadata: Mapping[str, object] | None = None,
no_ast: bool = False,
) -> None:
self.project = project
self.index = ProjectIndex(self.project)
self.index = ProjectIndex(self.project, allow_logic=not no_ast)
self.changesets = ChangesetStore(self.project, proposal_writer)
self.rendering = RenderService(self.project, self.changesets)
self.application = CanonicalApplicationService(
@ -125,11 +126,37 @@ class DocForgeService:
self.visualization = ViewerManagerClient(self.index)
self.context_provider = context_provider
self.binding_metadata = dict(binding_metadata or {})
self.no_ast = no_ast
self.tool_surface = tool_surface or (
*ALL_TOOLS,
*(APPLICATION_TOOLS if self.application.enabled else ()),
)
def adapter_policy(self) -> dict[str, object]:
"""Return the immutable adapter-evolution policy for this MCP binding."""
if not self.no_ast:
return {
"mode": "standard",
"ast_analysis": "allowed",
"logic_projection": "allowed",
"incremental_extraction": "allowed",
"adapter_rewrite": "not_requested",
}
return {
"mode": "preserve-no-ast",
"ast_analysis": "forbidden",
"logic_projection": "forbidden",
"incremental_extraction": "allowed",
"adapter_rewrite": "forbidden",
"blocked_tools": ["docforge_get_logic"],
"instruction": (
"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."
),
}
def invoke(
self,
operation: Callable[[], dict[str, object]],
@ -238,7 +265,25 @@ class DocForgeService:
"index_path": str(snapshot.descriptor.index_path),
"changeset_root": str(snapshot.descriptor.changeset_root),
**self.binding_metadata,
"adapter_policy": self.adapter_policy(),
}
recommended_workflow = [
"docforge_get_context or targeted read tools",
"make and verify one coherent implementation slice",
"docforge_sync",
"docforge_register_changes",
"docforge_get_changeset_diff",
"docforge_apply_changeset",
"docforge_bootstrap",
]
if self.no_ast:
recommended_workflow.insert(
1,
(
"preserve the current adapter; do not add AST, Tree-sitter, "
"compiler-AST, or function-Logic extraction"
),
)
return {
"status": "ok",
"project_id": snapshot.descriptor.project_id,
@ -249,18 +294,11 @@ class DocForgeService:
"source_hash": snapshot.source_hash,
"binding": binding,
"canonical_paths": [str(path) for path in snapshot.descriptor.content_roots],
"adapter_policy": self.adapter_policy(),
"proposal_access": self.changesets.access(),
"canonical_application_access": self.application.access(),
"synchronization": synchronized["synchronization"],
"recommended_workflow": [
"docforge_get_context or targeted read tools",
"make and verify one coherent implementation slice",
"docforge_sync",
"docforge_register_changes",
"docforge_get_changeset_diff",
"docforge_apply_changeset",
"docforge_bootstrap",
],
"recommended_workflow": recommended_workflow,
}
return self.invoke(operation, synchronize=False)
@ -310,6 +348,7 @@ class DocForgeService:
"authority_rule": (
"Canonical project files own facts; DocForge results are derived."
),
"adapter_policy": self.adapter_policy(),
"canonical_paths": [
*(relative(path) for path in snapshot.descriptor.content_roots),
*(relative(path) for path in snapshot.descriptor.authority_files),
@ -349,6 +388,7 @@ class DocForgeService:
else ()
)
+ (READ_ONLY_EXCLUDED_OPERATIONS if self.tool_surface == READ_TOOLS else ())
+ (("adapter_ast_upgrade", "function_logic_extraction") if self.no_ast else ())
),
"proposal_access": self.changesets.access(),
"canonical_application_access": self.application.access(),
@ -359,6 +399,23 @@ class DocForgeService:
return self.invoke(operation)
def get_logic(self, owner_node_id: str) -> dict[str, object]:
"""Return one Logic projection unless the binding preserves a no-AST adapter."""
if self.no_ast:
def forbidden() -> dict[str, object]:
raise DocForgeError(
"adapter_policy_forbids_logic",
(
"This MCP binding preserves a no-AST adapter and forbids function-Logic "
"extraction"
),
)
return self.invoke(forbidden, synchronize=False)
return self.invoke(lambda: self.index.get_logic(owner_node_id))
def validate_project(self) -> dict[str, object]:
def operation() -> dict[str, object]:
snapshot = self.project.load()
@ -437,6 +494,13 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
"docforge_register_changes creates a complete proposal atomically. "
"This server exposes no arbitrary renderer, shell, Git, deployment, publication, "
"or project switching."
+ (
" This binding preserves the existing adapter and forbids AST, Tree-sitter, "
"compiler-AST, and function-Logic extraction changes. Do not rewrite or upgrade "
"the adapter to add those capabilities."
if service.no_ast
else ""
)
),
json_response=True,
)
@ -475,7 +539,7 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
def get_logic(owner_node_id: str) -> dict[str, Any]:
"""Return the lazy control-flow projection owned by one function or method."""
return service.invoke(lambda: service.index.get_logic(owner_node_id))
return service.get_logic(owner_node_id)
@server.tool(name="docforge_search")
def search(query: str, limit: int | None = None) -> dict[str, Any]:
@ -822,6 +886,7 @@ def create_server(
proposal_writer: str | None = None,
*,
canonical_applier_id: str | None = None,
no_ast: bool = False,
) -> FastMCP:
project = Project.open(project_root)
return create_project_server(
@ -835,6 +900,7 @@ def create_server(
"server_module": "docforge.mcp_server",
"adapter_mode": "generic",
},
no_ast=no_ast,
)
@ -846,6 +912,7 @@ def create_project_server(
canonical_applier: CanonicalApplier | None = None,
context_provider: ContextProvider = compile_context,
binding_metadata: Mapping[str, object] | None = None,
no_ast: bool = False,
) -> FastMCP:
"""Create the full fixed MCP surface for one explicitly configured project service."""
@ -856,6 +923,7 @@ def create_project_server(
canonical_applier=canonical_applier,
context_provider=context_provider,
binding_metadata=binding_metadata,
no_ast=no_ast,
)
return _create_bound_server(service, read_only=False)
@ -865,6 +933,7 @@ def create_read_only_server(
*,
context_provider: ContextProvider = compile_context,
binding_metadata: Mapping[str, object] | None = None,
no_ast: bool = False,
) -> FastMCP:
"""Create an adapter-capable MCP server exposing only the fixed read tool surface."""
@ -873,6 +942,7 @@ def create_read_only_server(
context_provider=context_provider,
tool_surface=READ_TOOLS,
binding_metadata=binding_metadata,
no_ast=no_ast,
)
return _create_bound_server(service, read_only=True)
@ -882,11 +952,20 @@ def main() -> None:
parser.add_argument("--project-root", type=Path, required=True)
parser.add_argument("--proposal-writer")
parser.add_argument("--canonical-applier")
parser.add_argument(
"--no-ast",
action="store_true",
help=(
"Preserve the existing adapter and forbid AST, Tree-sitter, compiler-AST, "
"and function-Logic extraction changes"
),
)
arguments = parser.parse_args()
create_server(
arguments.project_root,
arguments.proposal_writer,
canonical_applier_id=arguments.canonical_applier,
no_ast=arguments.no_ast,
).run(transport="stdio")

View file

@ -366,6 +366,7 @@ class AdapterContractTests(unittest.TestCase):
self.assertTrue(visual_logic["available"])
self.assertEqual("entry", visual_logic["root"])
self.assertEqual("return", visual_logic["edges"][0]["relation"])
loader.extract_calls.clear()
cache_path = root / ".cache" / "incremental" / "extractions.json"
cache_modified = cache_path.stat().st_mtime_ns
@ -403,6 +404,20 @@ class AdapterContractTests(unittest.TestCase):
index.get_node("guide.workflow")["node"]["source_path"],
)
def test_no_ast_index_policy_rejects_logic_publication(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
project = AdapterProject(
IncrementalLoader(root),
cache_root=root / ".cache" / "no-ast",
)
with self.assertRaises(DocForgeError) as captured:
ProjectIndex(project, allow_logic=False).build()
self.assertEqual("adapter_policy_forbids_logic", captured.exception.code)
self.assertFalse(project.descriptor.index_path.exists())
def test_fast_incremental_reads_reverify_a_changed_index_file(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()

View file

@ -78,6 +78,44 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
)
)
async def test_no_ast_binding_preserves_adapter_and_blocks_logic(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
ProjectIndex(Project.open(root)).build()
async with create_connected_server_and_client_session(
create_server(root, no_ast=True), raise_exceptions=True
) as session:
bootstrap = await session.call_tool("docforge_bootstrap", {})
contract = await session.call_tool("docforge_get_contract", {})
logic = await session.call_tool(
"docforge_get_logic", {"owner_node_id": "guide.workflow"}
)
policy = bootstrap.structuredContent["adapter_policy"]
self.assertEqual("preserve-no-ast", policy["mode"])
self.assertEqual("forbidden", policy["ast_analysis"])
self.assertEqual("forbidden", policy["logic_projection"])
self.assertEqual("allowed", policy["incremental_extraction"])
self.assertEqual(["docforge_get_logic"], policy["blocked_tools"])
self.assertEqual(
policy,
bootstrap.structuredContent["binding"]["adapter_policy"],
)
self.assertIn(
"preserve the current adapter",
bootstrap.structuredContent["recommended_workflow"][1],
)
self.assertEqual(policy, contract.structuredContent["adapter_policy"])
self.assertIn(
"adapter_ast_upgrade",
contract.structuredContent["excluded_operations"],
)
self.assertEqual("error", logic.structuredContent["status"])
self.assertEqual(
"adapter_policy_forbids_logic",
logic.structuredContent["error"]["code"],
)
async def test_every_read_tool_returns_scoped_structured_results(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))