Preserve no-AST adapter bindings
This commit is contained in:
parent
bb13258861
commit
6c05607b14
7 changed files with 209 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue