diff --git a/README.md b/README.md index 6704c7ae1..71edc86a1 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ TSA ships curated workflows under `.claude/skills/tsa-*/`: Each skill ships an `allowed-tools` subset + procedure recipe + decision-surface schema, so the agent doesn't have to triage 8 tools on every question. -### 323 CLI flags +### 324 CLI flags Superset of CodeGraph's CLI surface. Highlights: diff --git a/README_ja.md b/README_ja.md index 5259d2115..ba2ae0871 100644 --- a/README_ja.md +++ b/README_ja.md @@ -158,7 +158,7 @@ TSA は `.claude/skills/tsa-*/` 下にキュレーション済みワークフロ 各 skill は `allowed-tools` ツール サブセット + 手順レシピ + 決定面スキーマを同梱し、エージェントは 8 個のツールから毎回選別する必要がありません。 -### 323 の CLI フラグ +### 324 の CLI フラグ CodeGraph の CLI の厳密な上位互換。主なもの: diff --git a/README_zh.md b/README_zh.md index 96b30e417..0485372b6 100644 --- a/README_zh.md +++ b/README_zh.md @@ -158,7 +158,7 @@ TSA 在 `.claude/skills/tsa-*/` 下提供精选工作流: 每个 skill 都带 `allowed-tools` 工具子集 + 操作流程 + 决策面 schema,agent 不必在 8 个工具间反复挑选。 -### 323 个 CLI flag +### 324 个 CLI flag CodeGraph CLI 的严格超集。亮点: diff --git a/benchmarks/codegraph_compare/readme_claim_scanner.py b/benchmarks/codegraph_compare/readme_claim_scanner.py index cf0647fa8..c7b08e4e0 100644 --- a/benchmarks/codegraph_compare/readme_claim_scanner.py +++ b/benchmarks/codegraph_compare/readme_claim_scanner.py @@ -32,8 +32,8 @@ re.compile(r"13 言語は `?pipeline_registered|13 种语言为 `?pipeline_registered"), re.compile(r"5 言語 gap|5 语言 gap"), re.compile(r"1 ワークフロー|一个工作流"), - re.compile(r"\b323 CLI flags\b", re.IGNORECASE), - re.compile(r"323 の CLI フラグ|323 个 CLI flag", re.IGNORECASE), + re.compile(r"\b324 CLI flags\b", re.IGNORECASE), + re.compile(r"324 の CLI フラグ|324 个 CLI flag", re.IGNORECASE), re.compile(r"\b(?:FTS5|BM25)\b"), re.compile(r"\bE[0-4]\b"), re.compile(r"\bE2E\b", re.IGNORECASE), diff --git a/docs/CODEMAPS/cli.md b/docs/CODEMAPS/cli.md index ae2af4a7f..5719ef6df 100644 --- a/docs/CODEMAPS/cli.md +++ b/docs/CODEMAPS/cli.md @@ -61,6 +61,7 @@ Categories of CLI surface: - `--call-graph` — caller/callee graph ### Code Quality +- `--check-constraints [--constraints-read-only]` — evaluate architecture constraints; read-only mode forwards `persist=false` and never updates violation rows - `--code-patterns` — smell detection - `--refactor` — concrete refactor recipes - `--outline` — hierarchical outline (package → class → method, no bodies) diff --git a/docs/api/facade-actions.md b/docs/api/facade-actions.md index 2179459e1..5b83567d6 100644 --- a/docs/api/facade-actions.md +++ b/docs/api/facade-actions.md @@ -82,7 +82,7 @@ Reading the tables: | --- | --- | --- | --- | | `ast_diff` | `diff_snapshot_id`, `file_path`, `include_node_bodies`, `language`, `mode`, `new_file`, `new_ref`, `new_source`, `old_file`, `old_ref`, `old_source`, `output_format` | `success`*, `verdict`*, `agent_summary`, `error` + action payload | `--ast-diff` | | `classify` | `diff_snapshot_id`, `file_path`, `hunk_cap`, `include_ast_nodes`, `language`, `mode`, `new_ref`, `new_source`, `old_ref`, `old_source`, `output_format` | `success`*, `verdict`*, `agent_summary`, `error` + action payload | `--semantic-classify` | -| `constraints` | `output_format`, `path_filter`, `severity_min` | `success`*, `verdict`*, `agent_summary`, `error` + action payload | `--check-constraints` | +| `constraints` | `diff_snapshot_id`, `output_format`, `path_filter`, `persist`, `scope_paths`, `severity_min` | `success`*, `verdict`*, `agent_summary`, `error` + action payload | `--check-constraints` | | `guard` | `modification_type`*, `symbol`*, `file_path` | `success`*, `verdict`*, `agent_summary`, `error` + action payload | `--modification-guard` | | `impact` | `agent_summary_only`, `capture_diff_snapshot`, `compact_only`, `include_tests`, `mode`, `output_format`, `pr_url`, `resource_profile`, `scope_mode`, `scope_paths` — `capture_diff_snapshot` is an explicit boolean producer available only to same-process POSIX consumers | `success`*, `verdict`*, `agent_summary`, `error` + action payload | `--change-impact` | | `pr` | `include_call_graph`, `mode`, `output_format`, `pr_url` | `success`*, `verdict`*, `agent_summary`, `error` + action payload | `--pr-review` | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index dbb8913c7..29b782364 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -490,3 +490,9 @@ uv run tree-sitter-analyzer large_file.java --query-key methods --filter "public # 4. Extract only the lines you need uv run tree-sitter-analyzer large_file.java --partial-read --start-line 100 --end-line 150 ``` + +### Constraint evaluation + +`--check-constraints` evaluates the project architecture rules. Add +`--constraints-read-only` to forward `persist=false`, opening an existing cache +read-only and leaving its schema and violation rows unchanged. diff --git a/scripts/native_qualification_lib.py b/scripts/native_qualification_lib.py index bf2876bc1..af9c56a8c 100644 --- a/scripts/native_qualification_lib.py +++ b/scripts/native_qualification_lib.py @@ -314,7 +314,12 @@ def _cleanup_token_processes( tracked.update(_token_processes(token, final_deadline)) alive = _live_processes(tracked) if not alive: - return False + quiet_scans += 1 + if quiet_scans == 2: + return True + time.sleep(min(0.02, max(0.0, final_deadline - time.monotonic()))) + continue + quiet_scans = 0 _signal_group(proc, force=True) for process in alive: try: diff --git a/tests/contracts/test_language_support_inventory_contract.py b/tests/contracts/test_language_support_inventory_contract.py index e368db697..950b84f00 100644 --- a/tests/contracts/test_language_support_inventory_contract.py +++ b/tests/contracts/test_language_support_inventory_contract.py @@ -459,7 +459,7 @@ def test_translated_readmes_reject_unregistered_quantitative_marketing( ( "Python 3.10 以上", "8 MCP ツール", - "### 323 の CLI フラグ", + "### 324 の CLI フラグ", "22 言語プラグイン", "13 は `pipeline_registered`", "3 は `index_admitted`", @@ -471,7 +471,7 @@ def test_translated_readmes_reject_unregistered_quantitative_marketing( ( "需要 Python 3.10+", "8 个 MCP 工具", - "### 323 个 CLI flag", + "### 324 个 CLI flag", "22 个语言插件", "13 个为 `pipeline_registered`", "3 个为 `index_admitted`", diff --git a/tests/contracts/test_native_install_qualification.py b/tests/contracts/test_native_install_qualification.py index 68ba80715..69fad7cc5 100644 --- a/tests/contracts/test_native_install_qualification.py +++ b/tests/contracts/test_native_install_qualification.py @@ -426,6 +426,28 @@ def test_runner_late_detached_grandchild_cleanup_stress(tmp_path: Path) -> None: _assert_late_spawn_cleanup(tmp_path, 10) +def test_cleanup_fallback_certifies_two_quiet_scans( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import native_qualification_lib as owner + + killed: list[bool] = [] + process = type("Process", (), {"kill": lambda _self: killed.append(True)})() + scans = iter(([process], [], [])) + forced: list[bool] = [] + monkeypatch.setattr(owner, "_token_processes", lambda *_a, **_k: {}) + monkeypatch.setattr(owner, "_live_processes", lambda _tracked: next(scans)) + monkeypatch.setattr( + owner, "_signal_group", lambda _p, *, force: forced.append(force) + ) + monkeypatch.setattr(owner.psutil, "wait_procs", lambda *_a, **_k: ([], [])) + monkeypatch.setattr(owner.time, "sleep", lambda _seconds: None) + + result = owner._cleanup_token_processes(object(), {}, "token", grace=0, force=0) + + assert (result, forced, killed) == (True, [True], [True]) + + def test_runner_reports_cleanup_nonquiescence( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -463,31 +485,10 @@ def test_wheel_record_rejects_non_exact_or_injected_archive( @pytest.mark.parametrize( "mutation", - [ - "valid", - "stage_false", - "extra_field", - "mcp_oracle", - "venv_provenance", - "direct_hash", - "direct_base64", - "snapshot_direct", - "transcript_error", - "transcript_missing", - "path_dotdot_posix", - "path_dotdot_windows", - "aggregate_extra", - "axis_digest", - "installed_member_hash", - "installed_member_size", - "installed_record_digest", - "installed_inventory", - "side_artifact", - "path_containment", - "zip_extra", - "zip_symlink", - "filename_metadata", - ], + "valid stage_false extra_field mcp_oracle venv_provenance direct_hash direct_base64 " + "snapshot_direct transcript_error transcript_missing path_dotdot_posix path_dotdot_windows " + "aggregate_extra axis_digest installed_member_hash installed_member_size installed_record_digest " + "installed_inventory side_artifact path_containment zip_extra zip_symlink filename_metadata".split(), ) def test_trusted_inline_verifier_rejects_candidate_forgery( tmp_path: Path, mutation: str diff --git a/tests/integration/performance/test_mcp_performance.py b/tests/integration/performance/test_mcp_performance.py index a856a468c..d84f1ede6 100644 --- a/tests/integration/performance/test_mcp_performance.py +++ b/tests/integration/performance/test_mcp_performance.py @@ -525,42 +525,53 @@ class TestMemoryOptimization: async def test_memory_usage_optimization( self, large_code_file, performance_monitor ): - """メモリ使用量最適化の確認""" - tool = TableFormatTool() + """Measure suppressed-output memory after one-time engine warm-up.""" + import gc + + output_root = Path(large_code_file).parent.resolve() + warmup_file = output_root / "warmup.py" + warmup_file.write_text("value = 1\n") + tool = TableFormatTool(str(output_root)) + arguments = { + "file_path": large_code_file, + "format_type": "full", + "suppress_output": True, + "output_file": "test_output.json", + } + warm_arguments = {**arguments, "file_path": str(warmup_file)} + + # Plugin/parser initialization is a one-time process cost whose RSS is + # scheduling-dependent under xdist. Warm it on a different file so the + # measured call still parses and formats the full large fixture. + warm_result = await tool.execute(warm_arguments) + assert warm_result["success"] is True + output_file = Path(warm_result["output_file_path"]) + assert output_file.parent == output_root + output_file.unlink() + gc.collect() - # 初期メモリ使用量を記録 initial_memory = psutil.Process().memory_info().rss - performance_monitor.start_measurement() - - # suppress_output=True でメモリ最適化を有効化 - result = await tool.execute( - { - "file_path": large_code_file, - "format_type": "full", - "suppress_output": True, - "output_file": "test_output.json", - } - ) - - metrics = performance_monitor.end_measurement() - final_memory = psutil.Process().memory_info().rss - - assert result["success"] is True - - # メモリ使用量が適切に制御されていることを確認 - memory_increase = (final_memory - initial_memory) / 1024 / 1024 # MB - assert memory_increase < 50, ( - f"メモリ使用量増加が50MBを超過: {memory_increase:.2f}MB" - ) - - print(f"メモリ最適化実行時間: {metrics['execution_time']:.2f}秒") - print(f"メモリ使用量増加: {memory_increase:.2f}MB") - - # 出力ファイルが作成されていることを確認 - output_file = Path("test_output.json") - if output_file.exists(): - output_file.unlink() # クリーンアップ + try: + result = await tool.execute(arguments) + metrics = performance_monitor.end_measurement() + final_memory = psutil.Process().memory_info().rss + + assert result["success"] is True + output_file = Path(result["output_file_path"]) + assert output_file.parent == output_root + assert output_file.exists() is True + + memory_increase = (final_memory - initial_memory) / 1024 / 1024 + assert memory_increase < 50, ( + f"メモリ使用量増加が50MBを超過: {memory_increase:.2f}MB" + ) + + print(f"メモリ最適化実行時間: {metrics['execution_time']:.2f}秒") + print(f"メモリ使用量増加: {memory_increase:.2f}MB") + finally: + if output_file.exists(): + output_file.unlink() if __name__ == "__main__": diff --git a/tests/integration/test_diff_snapshot_capture.py b/tests/integration/test_diff_snapshot_capture.py index 42f72cae1..ac886725d 100644 --- a/tests/integration/test_diff_snapshot_capture.py +++ b/tests/integration/test_diff_snapshot_capture.py @@ -60,6 +60,7 @@ def test_staged_snapshot_freezes_add_delete_rename_binary_and_multiple_files( "gone.py", "image.bin", "impact.py", + "old.py", "renamed.py", ] @@ -245,19 +246,16 @@ def test_frozen_scope_inventory_does_not_admit_post_capture_mutation( @POSIX_SNAPSHOT_TEST -def test_staged_symlink_records_unsupported_source_kind(tmp_path: Path) -> None: - # PR #1252 review thread 3746878582. +def test_staged_symlink_cannot_claim_shared_authoritative_generation( + tmp_path: Path, +) -> None: root = _repo(tmp_path) (root / "module.py").symlink_to("old.py") _git(root, "add", "module.py") result = snapshots.DiffSnapshotRegistry().create(str(root), "staged", []) - assert result["success"] is True - record = next( - item for item in result["changed_records"] if item["path"] == "module.py" - ) - assert (record["new_kind"], record["new_mode"]) == ("symlink", "120000") + assert result == {"success": False, "error_code": "SOURCE_SCOPE_UNSAFE"} def test_entry_parts_rejects_malformed_git_header() -> None: diff --git a/tests/integration/test_diff_snapshot_constraints.py b/tests/integration/test_diff_snapshot_constraints.py new file mode 100644 index 000000000..cf4f2ffc2 --- /dev/null +++ b/tests/integration/test_diff_snapshot_constraints.py @@ -0,0 +1,137 @@ +"""Integration coverage for staged snapshot constraint/source planes.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import tree_sitter_analyzer.diff_snapshot_registry as snapshots +from tests.unit._diff_snapshot_support import POSIX_SNAPSHOT_TEST, make_repo + + +def _git(root: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=root, check=True, capture_output=True) + + +def _repo(tmp_path: Path) -> Path: + return make_repo(tmp_path) + + +@POSIX_SNAPSHOT_TEST +def test_staged_snapshot_constraint_config_comes_from_index_plane( + tmp_path: Path, +) -> None: + # PR #1254 review 3765536002: staged constraints are index-plane evidence. + root = _repo(tmp_path) + config = root / "architectural-constraints.yml" + config.write_bytes(b"version: 1\nconstraints: []\n") + _git(root, "add", config.name) + config.write_bytes(b"version: 1\nconstraints: [invalid-worktree]\n") + registry = snapshots.DiffSnapshotRegistry() + + created = registry.create(str(root), "staged", []) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(root)) + + assert error is None + assert consumer is not None + assert consumer.snapshot.constraint_config_path == config.name + assert consumer.snapshot.constraint_config_data == b"version: 1\nconstraints: []\n" + assert consumer.snapshot.staged_config_matches_worktree is False + consumer.release() + + +@POSIX_SNAPSHOT_TEST +def test_staged_snapshot_records_source_plane_divergence(tmp_path: Path) -> None: + # PR #1254 review 3765536016: live graphs cannot represent dirty staged sources. + root = _repo(tmp_path) + (root / "old.py").write_text("value = 2\n") + _git(root, "add", "old.py") + (root / "old.py").write_text("value = 3\n") + registry = snapshots.DiffSnapshotRegistry() + + created = registry.create(str(root), "staged", []) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(root)) + + assert error is None + assert consumer is not None + assert consumer.snapshot.staged_source_matches_worktree is False + consumer.release() + + +@POSIX_SNAPSHOT_TEST +def test_staged_snapshot_detects_ignored_supported_submodule_source( + tmp_path: Path, +) -> None: + # PR #1254 review 3769193852: tagged records remain unambiguous when a + # child source has the same name as an unrelated superproject directory. + child = _repo(tmp_path / "child") + (child / ".gitignore").write_text("masked.py\n") + _git(child, "add", ".gitignore") + _git(child, "commit", "-m", "ignore source") + root = _repo(tmp_path / "parent") + (root / "masked.py").mkdir() + (root / "masked.py" / "README.md").write_text("not a source\n") + _git(root, "add", "masked.py/README.md") + _git(root, "commit", "-m", "add colliding directory") + _git( + root, + "-c", + "protocol.file.allow=always", + "submodule", + "add", + str(child), + "modules/child", + ) + _git(root, "commit", "-am", "add submodule") + (root / "modules" / "child" / "masked.py").write_text("hidden = True\n") + + registry = snapshots.DiffSnapshotRegistry() + created = registry.create(str(root), "staged", []) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(root)) + + assert error is None + assert consumer is not None + assert consumer.snapshot.staged_source_matches_worktree is False + consumer.release() + + +@POSIX_SNAPSHOT_TEST +def test_staged_snapshot_marks_bare_gitlink_plane_uncertifiable( + tmp_path: Path, +) -> None: + child = _repo(tmp_path / "child") + (child / ".gitignore").write_text("ignored.py\n") + _git(child, "add", ".gitignore") + _git(child, "commit", "-m", "ignore source") + child_oid = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=child, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + root = _repo(tmp_path / "parent") + destination = root / "libs" / "component" + destination.parent.mkdir() + subprocess.run( + ["git", "clone", "--quiet", str(child), str(destination)], + check=True, + capture_output=True, + ) + _git( + root, + "update-index", + "--add", + "--cacheinfo", + f"160000,{child_oid},libs/component", + ) + (destination / "ignored.py").write_text("hidden = True\n") + + registry = snapshots.DiffSnapshotRegistry() + created = registry.create(str(root), "staged", []) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(root)) + + assert error is None + assert consumer is not None + assert consumer.snapshot.staged_source_matches_worktree is False + consumer.release() diff --git a/tests/integration/test_diff_snapshot_registry.py b/tests/integration/test_diff_snapshot_registry.py index 23a9cf05f..e00adf03c 100644 --- a/tests/integration/test_diff_snapshot_registry.py +++ b/tests/integration/test_diff_snapshot_registry.py @@ -226,7 +226,11 @@ def oracle(*args, deadline=None): monkeypatch.setattr(snapshots.time, "monotonic", lambda: 100.0) monkeypatch.setattr(snapshots, "oracle_generation", oracle) consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(tmp_path)) - assert (consumer, error, deadlines) == (None, "DIFF_SNAPSHOT_EXPIRED", [135.0]) + assert (consumer, error, deadlines) == ( + None, + "DIFF_SNAPSHOT_EXPIRED", + [35.0, 35.0], + ) def test_strict_scope_binds_only_scoped_change_and_valid_scope(monkeypatch): diff --git a/tests/unit/_diff_snapshot_support.py b/tests/unit/_diff_snapshot_support.py index b31732920..c897233eb 100644 --- a/tests/unit/_diff_snapshot_support.py +++ b/tests/unit/_diff_snapshot_support.py @@ -41,6 +41,56 @@ def fake_oracle(project_root, mode="diff", *, deadline=None, manifest=None): return "sg_test", identity monkeypatch.setattr(snapshots, "oracle_generation", fake_oracle) + monkeypatch.setattr( + snapshots, "shared_source_generation", lambda *_a, **_k: "sg_test" + ) + from types import SimpleNamespace + + import tree_sitter_analyzer.index_source_snapshot as source_snapshot + from tree_sitter_analyzer.source_oracle import SafePath + + monkeypatch.setattr( + source_snapshot, + "capture_current_source_snapshot", + lambda *_a, **_k: SimpleNamespace( + state="exact", generation="sg_test", reason=None + ), + ) + + original_safe_workspace_path = snapshots.safe_workspace_path + + def fake_safe_workspace_path(_root, relative, **kwargs): + try: + return original_safe_workspace_path(_root, relative, **kwargs) + except Exception as exc: + if "WORKSPACE_UNSUPPORTED" not in str(exc): + raise + target = root / relative + if not target.exists(): + return SafePath(data=None, metadata=(b"missing",), kind="missing") + if target.is_dir(): + return SafePath(data=None, metadata=(b"directory",), kind="directory") + if not target.is_file(): + return SafePath(data=None, metadata=(b"unsafe",), kind="unsafe") + data = target.read_bytes() + return SafePath( + data=data, + metadata=(b"test," + str(len(data)).encode("ascii"),), + kind="file", + ) + + monkeypatch.setattr(snapshots, "safe_workspace_path", fake_safe_workspace_path) + import tree_sitter_analyzer.source_oracle as source_oracle + + monkeypatch.setattr(source_oracle, "safe_workspace_path", fake_safe_workspace_path) + monkeypatch.setattr( + snapshots, + "frozen_index_constraint_config", + lambda *_a, **_k: (None, None, ()), + ) + monkeypatch.setattr( + snapshots, "staged_sources_match_worktree", lambda *_a, **_k: True + ) monkeypatch.setattr( snapshots, "capture_inventory", diff --git a/tests/unit/cli/test_argument_parser_builder_options.py b/tests/unit/cli/test_argument_parser_builder_options.py index 00f74f9e2..ac9b8aded 100644 --- a/tests/unit/cli/test_argument_parser_builder_options.py +++ b/tests/unit/cli/test_argument_parser_builder_options.py @@ -24,53 +24,28 @@ class TestCLIEpilog: - def test_epilog_is_string(self): - assert isinstance(CLI_EPILOG, str) - - def test_epilog_contains_examples(self): - assert "Examples:" in CLI_EPILOG - - def test_epilog_mentions_table(self): - assert "--table=full" in CLI_EPILOG - - def test_epilog_mentions_query_key(self): - assert "--query-key" in CLI_EPILOG - - def test_epilog_mentions_advanced(self): - assert "--advanced" in CLI_EPILOG - - def test_epilog_mentions_structure(self): - assert "--structure" in CLI_EPILOG - - def test_epilog_mentions_summary(self): - assert "--summary" in CLI_EPILOG - - def test_epilog_mentions_partial_read(self): - assert "--partial-read" in CLI_EPILOG - - def test_epilog_mentions_file_health(self): - assert "--file-health" in CLI_EPILOG - - def test_epilog_mentions_safe_to_edit(self): - assert "--safe-to-edit" in CLI_EPILOG - - def test_epilog_mentions_refactor(self): - assert "--refactor" in CLI_EPILOG - - def test_epilog_mentions_smart_context(self): - assert "--smart-context" in CLI_EPILOG - - def test_epilog_mentions_change_impact(self): - assert "--change-impact" in CLI_EPILOG - - def test_epilog_mentions_project_health(self): - assert "--project-health" in CLI_EPILOG - - def test_epilog_mentions_overview(self): - assert "--overview" in CLI_EPILOG - - def test_epilog_mentions_dependencies(self): - assert "--dependencies" in CLI_EPILOG + @pytest.mark.parametrize( + "fragment", + [ + "Examples:", + "--table=full", + "--query-key", + "--advanced", + "--structure", + "--summary", + "--partial-read", + "--file-health", + "--safe-to-edit", + "--refactor", + "--smart-context", + "--change-impact", + "--project-health", + "--overview", + "--dependencies", + ], + ) + def test_epilog_documents_expected_fragment(self, fragment: str) -> None: + assert fragment in CLI_EPILOG class TestCreateArgumentParser: @@ -491,42 +466,30 @@ def test_agent_workflow(self): assert args.agent_workflow is True -class TestFullIndexMCPEquivalentOptions: - """Regression: CLI --full-index-mode choices must match MCP tool valid modes. +def test_constraints_read_only_option_sets_exact_destination() -> None: + parser = argparse.ArgumentParser() + _add_mcp_equivalent_options(parser) + args = parser.parse_args(["--constraints-read-only"]) + assert args.constraints_read_only is True - Dogfood-found bug: argparse exposed {rebuild,stats,clear} but CodeGraphFullIndexTool - only accepts {full,incremental}. Using TSA on TSA to discover and verify the fix. - """ - def _make_parser(self): - p = argparse.ArgumentParser() - _add_mcp_equivalent_options(p) - return p - - def test_full_index_mode_default_is_incremental(self): - """Default mode must match MCP tool default ('incremental').""" - parser = self._make_parser() - args = parser.parse_args(["--full-index"]) - assert args.full_index_mode == "incremental" - - def test_full_index_mode_accepts_full(self): - parser = self._make_parser() - args = parser.parse_args(["--full-index", "--full-index-mode", "full"]) - assert args.full_index_mode == "full" - - def test_full_index_mode_accepts_incremental(self): - parser = self._make_parser() - args = parser.parse_args(["--full-index", "--full-index-mode", "incremental"]) - assert args.full_index_mode == "incremental" - - def test_full_index_mode_rejects_rebuild(self): - """'rebuild' was the old invalid choice — must now be rejected.""" - parser = self._make_parser() - with pytest.raises(SystemExit): - parser.parse_args(["--full-index", "--full-index-mode", "rebuild"]) - - def test_full_index_mode_rejects_stats(self): - """'stats' was an old invalid choice — must now be rejected.""" - parser = self._make_parser() - with pytest.raises(SystemExit): - parser.parse_args(["--full-index", "--full-index-mode", "stats"]) +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + (["--full-index"], "incremental"), + (["--full-index", "--full-index-mode", "full"], "full"), + (["--full-index", "--full-index-mode", "incremental"], "incremental"), + ], +) +def test_full_index_modes_match_mcp(arguments: list[str], expected: str) -> None: + parser = argparse.ArgumentParser() + _add_mcp_equivalent_options(parser) + assert parser.parse_args(arguments).full_index_mode == expected + + +@pytest.mark.parametrize("mode", ["rebuild", "stats"]) +def test_full_index_rejects_legacy_modes(mode: str) -> None: + parser = argparse.ArgumentParser() + _add_mcp_equivalent_options(parser) + with pytest.raises(SystemExit): + parser.parse_args(["--full-index", "--full-index-mode", mode]) diff --git a/tests/unit/cli/test_constraint_check_command.py b/tests/unit/cli/test_constraint_check_command.py index 29479ad84..16c7ddf4e 100644 --- a/tests/unit/cli/test_constraint_check_command.py +++ b/tests/unit/cli/test_constraint_check_command.py @@ -6,7 +6,7 @@ import sqlite3 from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest @@ -14,17 +14,15 @@ _compute_verdict, _evaluate_with_explicit_file, _exit_code_for, + _explicit_config_evidence, _failure_envelope, _filter_violations, _format_response, _load_explicit, _print_result, _resolve_output_format, - _run_and_persist, - _run_tool, _violations_ddl, get_default_project_root, - run_check_constraints, ) # Module-level patch targets @@ -58,11 +56,6 @@ ) -# --------------------------------------------------------------------------- -# Violation stub -# --------------------------------------------------------------------------- - - def _v( severity: str = "error", rule_id: str = "R1", @@ -85,11 +78,6 @@ def _v( ) -# --------------------------------------------------------------------------- -# _exit_code_for -# --------------------------------------------------------------------------- - - class TestExitCodeFor: def test_success_false_returns_1(self): assert _exit_code_for({"success": False}) == 1 @@ -111,11 +99,6 @@ def test_missing_verdict_defaults_to_safe_returns_0(self): assert _exit_code_for({"success": True}) == 0 -# --------------------------------------------------------------------------- -# _compute_verdict -# --------------------------------------------------------------------------- - - class TestComputeVerdict: def test_empty_rows_returns_safe(self): assert _compute_verdict([]) == "SAFE" @@ -138,11 +121,6 @@ def test_multiple_warns_no_error_returns_caution(self): assert _compute_verdict(rows) == "CAUTION" -# --------------------------------------------------------------------------- -# _filter_violations -# --------------------------------------------------------------------------- - - class TestFilterViolations: def test_no_path_filter_passes_all_at_or_above_severity(self): violations = [_v(severity="error"), _v(severity="warn")] @@ -203,11 +181,6 @@ def test_min_severity_rank_zero_passes_everything(self): assert len(rows) == 3 -# --------------------------------------------------------------------------- -# _violations_ddl -# --------------------------------------------------------------------------- - - class TestViolationsDDL: def test_returns_string(self): assert isinstance(_violations_ddl(), str) @@ -227,11 +200,6 @@ def test_is_idempotent_if_not_exists(self): conn.close() -# --------------------------------------------------------------------------- -# _format_response -# --------------------------------------------------------------------------- - - class TestFormatResponse: def test_returns_value_from_apply_toon(self): payload = {"success": True, "verdict": "SAFE"} @@ -254,11 +222,6 @@ def capture(p, fmt): assert captured[0][1] == "json" -# --------------------------------------------------------------------------- -# _failure_envelope -# --------------------------------------------------------------------------- - - class TestFailureEnvelope: def test_success_is_false(self): with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): @@ -286,11 +249,6 @@ def test_rule_count_is_zero(self): assert result["rule_count"] == 0 -# --------------------------------------------------------------------------- -# _resolve_output_format -# --------------------------------------------------------------------------- - - class TestResolveOutputFormat: def test_delegates_to_resolve_mcp_tool_format(self): args = SimpleNamespace(format="json") @@ -306,11 +264,6 @@ def test_returns_toon_when_resolver_says_toon(self): assert result == "toon" -# --------------------------------------------------------------------------- -# _print_result -# --------------------------------------------------------------------------- - - class TestPrintResult: def test_toon_prints_toon_content(self, capsys): _print_result({"toon_content": "## Verdict\nSAFE"}, "toon") @@ -333,11 +286,6 @@ def test_json_output_is_indented(self, capsys): assert "\n" in out # indent=2 produces newlines -# --------------------------------------------------------------------------- -# get_default_project_root -# --------------------------------------------------------------------------- - - class TestGetDefaultProjectRoot: def test_returns_project_root_attr(self): args = SimpleNamespace(project_root="/srv/proj") @@ -351,11 +299,6 @@ def test_falls_back_to_cwd_when_attr_missing(self): assert get_default_project_root(SimpleNamespace()) # truthy -# --------------------------------------------------------------------------- -# _load_explicit -# --------------------------------------------------------------------------- - - class TestLoadExplicit: def test_canonical_name_calls_load_constraints_on_parent(self, tmp_path): yaml_file = tmp_path / "architectural-constraints.yml" @@ -409,358 +352,147 @@ def capture(root: str) -> list: assert file_contents[0] == content -# --------------------------------------------------------------------------- -# _run_and_persist -# --------------------------------------------------------------------------- +def test_explicit_config_evidence_rejects_input_above_one_mib(tmp_path: Path) -> None: + # PR #1254 review 3769281328: explicit read-only input stays bounded. + config = tmp_path / "candidate.yml" + config.write_bytes(b"x" * (1024 * 1024 + 1)) + with pytest.raises(RuntimeError, match="^CONSTRAINT_CONFIG_CAPACITY$"): + _explicit_config_evidence(config, float("inf")) -class TestRunAndPersist: - def _empty_db(self, tmp_path: Path) -> Path: - db = tmp_path / "index.db" - sqlite3.connect(str(db)).close() - return db - def _db_with_edges(self, tmp_path: Path) -> Path: - from tree_sitter_analyzer.graph.edge_store import EDGE_STORE_SCHEMA +def test_explicit_config_evidence_honors_expired_deadline(tmp_path: Path) -> None: + # PR #1254 review 3769281328: reads share the evaluation deadline contract. + config = tmp_path / "candidate.yml" + config.write_bytes(b"version: 1\nconstraints: []\n") - db = tmp_path / "index.db" - conn = sqlite3.connect(str(db)) - # B1.3: the edge-count gate counts CALLS rows in the unified ``edges`` - # table (ast_call_edges was dropped). - conn.executescript(EDGE_STORE_SCHEMA) - conn.execute( - "INSERT INTO edges (source_node_id, target_node_id, kind) " - "VALUES ('a.py:f:1', 'b.py:g:1', 'calls')" - ) - conn.commit() - conn.close() - return db - - def test_no_call_edges_table_returns_empty(self, tmp_path): - db = self._empty_db(tmp_path) - violations, edge_count = _run_and_persist(db, []) - assert violations == [] - assert edge_count == 0 - - @pytest.mark.slow_ok # Windows xdist-load budget exemption; test logic is trivial, no perf claim (#976) - def test_empty_call_edges_table_returns_empty(self, tmp_path): - db = tmp_path / "index.db" - conn = sqlite3.connect(str(db)) - conn.execute("CREATE TABLE ast_call_edges (id INTEGER PRIMARY KEY)") - conn.commit() - conn.close() - violations, edge_count = _run_and_persist(db, []) - assert violations == [] - assert edge_count == 0 - - def test_evaluate_exception_degrades_gracefully(self, tmp_path): - db = self._db_with_edges(tmp_path) - with patch(_EVALUATE, side_effect=RuntimeError("boom")): - violations, edge_count = _run_and_persist(db, []) - assert violations == [] - assert edge_count == 1 - - def test_violations_persisted_to_db(self, tmp_path): - db = self._db_with_edges(tmp_path) - v = _v(detected_at=12345) - with patch(_EVALUATE, return_value=[v]): - violations, _ = _run_and_persist(db, ["c"]) - assert len(violations) == 1 - conn = sqlite3.connect(str(db)) - rows = conn.execute("SELECT rule_id FROM ast_constraint_violations").fetchall() - conn.close() - assert rows == [("R1",)] - - def test_returns_edge_count_from_db(self, tmp_path): - db = self._db_with_edges(tmp_path) - with patch(_EVALUATE, return_value=[]): - _, edge_count = _run_and_persist(db, []) - assert edge_count == 1 - - def test_violations_table_cleared_before_insert(self, tmp_path): - db = self._db_with_edges(tmp_path) - # Pre-populate violations table with a stale row - conn = sqlite3.connect(str(db)) - conn.execute(_violations_ddl()) - conn.execute( - """INSERT INTO ast_constraint_violations - VALUES ('OLD', 'f.py', 'fn', 1, 'bar', 'g.py', 'warn', 0)""" - ) - conn.commit() - conn.close() + with pytest.raises(RuntimeError, match="^CONSTRAINT_CONFIG_DEADLINE$"): + _explicit_config_evidence(config, 0.0) - new_v = _v(rule_id="NEW", detected_at=1) - with patch(_EVALUATE, return_value=[new_v]): - _run_and_persist(db, ["c"]) - conn = sqlite3.connect(str(db)) - rows = conn.execute("SELECT rule_id FROM ast_constraint_violations").fetchall() - conn.close() - # OLD row must be gone; only NEW should be present - rule_ids = [r[0] for r in rows] - assert "OLD" not in rule_ids - assert "NEW" in rule_ids - - def test_duplicate_pk_violations_do_not_crash_persist(self, tmp_path): - """Regression for #544: two violations with the same PK must not crash. - - If ``evaluate()`` returns two ``Violation`` objects that share the - same ``(rule_id, caller_file, caller_line, callee_name)`` PRIMARY - KEY (e.g., one call site resolved to two ``callee_file`` targets), - the old ``executemany`` would raise - ``UNIQUE constraint failed: ast_constraint_violations.rule_id, ...``. - - After the fix the persist path must succeed and write exactly 1 row - (the dedup is in ``evaluate()``, so ``_run_and_persist`` receives a - clean list — this test verifies the full stack from mock to DB). - """ - db = self._db_with_edges(tmp_path) - # Two violations with identical PK but different callee_file. - dup_v1 = _v( - rule_id="R1", - caller_file="a.py", - caller_line=10, - callee_name="bar", - callee_file="b.py", - detected_at=1, - ) - dup_v2 = _v( - rule_id="R1", - caller_file="a.py", - caller_line=10, - callee_name="bar", - callee_file="c.py", - detected_at=1, - ) - - # We intentionally bypass the real evaluate() and inject the two - # duplicates directly to test the persist layer in isolation. - with patch(_EVALUATE, return_value=[dup_v1, dup_v2]): - # Must NOT raise sqlite3.IntegrityError. - violations, edge_count = _run_and_persist(db, ["c"]) - - conn = sqlite3.connect(str(db)) - rows = conn.execute( - "SELECT rule_id, caller_file, caller_line, callee_name " - "FROM ast_constraint_violations" - ).fetchall() - conn.close() - # Exactly 1 row persisted (PK is unique); the constraint did not crash. - assert len(rows) == 1, ( - f"Expected exactly 1 persisted row after dedup, got {len(rows)}: {rows}" - ) - assert rows[0] == ("R1", "a.py", 10, "bar") +def test_explicit_zero_rules_revalidates_bytes_before_safe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3769193838: the zero-rule fast path retains rule authority. + config = tmp_path / "candidate.yml" + config.write_text("version: 1\nconstraints: []\n") + real_evidence = __import__( + "tree_sitter_analyzer.cli.commands.constraint_check_command", + fromlist=["_explicit_config_evidence"], + )._explicit_config_evidence + reads = 0 + def tighten(path: Path, deadline: float): + nonlocal reads + evidence = real_evidence(path, deadline) + reads += 1 + if reads == 1: + config.write_text("version: 1\nconstraints: [{id: changed}]\n") + return evidence -# --------------------------------------------------------------------------- -# _run_tool -# --------------------------------------------------------------------------- + monkeypatch.setattr( + "tree_sitter_analyzer.cli.commands.constraint_check_command._explicit_config_evidence", + tighten, + ) + result = _evaluate_with_explicit_file( + project_root=str(tmp_path), + constraint_file=str(config), + severity_min="warn", + path_filter="", + output_format="json", + persist=False, + ) + assert (result["success"], result["verdict"], result["error_code"], reads) == ( + False, + "ERROR", + "CONSTRAINT_CONFIG_CHANGED", + 2, + ) -class TestRunTool: - def test_builds_tool_and_returns_execute_coroutine(self, tmp_path): - import asyncio - mock_tool = MagicMock() - mock_tool.execute = AsyncMock(return_value={"success": True}) +def test_explicit_rules_revalidate_identity_after_evaluation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3769193838: same bytes under a replacement identity fail closed. + config = tmp_path / "candidate.yml" + config.write_text( + "version: 1\nconstraints:\n" + " - {id: r, severity: error, rule: forbid, from: 'a/**', " + "to: 'b/**', reason: boundary}\n" + ) - with patch(_CCT_CLS, return_value=mock_tool): - result = asyncio.run(_run_tool(str(tmp_path), "warn", "", "json")) + def replace_during_evaluation(*_args, **_kwargs): + replacement = tmp_path / "replacement.yml" + replacement.write_bytes(config.read_bytes()) + replacement.replace(config) + return [], 0 - assert result == {"success": True} - mock_tool.execute.assert_called_once_with( - {"path_filter": "", "severity_min": "warn", "output_format": "json"} - ) + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.ConstraintCheckTool._run_read_only", + replace_during_evaluation, + ) + result = _evaluate_with_explicit_file( + project_root=str(tmp_path), + constraint_file=str(config), + severity_min="warn", + path_filter="", + output_format="json", + persist=False, + ) - def test_passes_path_filter_and_severity(self, tmp_path): - import asyncio - - mock_tool = MagicMock() - mock_tool.execute = AsyncMock(return_value={"success": False}) - - with patch(_CCT_CLS, return_value=mock_tool): - asyncio.run(_run_tool(str(tmp_path), "error", "src/*", "toon")) - - called_payload = mock_tool.execute.call_args[0][0] - assert called_payload["severity_min"] == "error" - assert called_payload["path_filter"] == "src/*" - assert called_payload["output_format"] == "toon" - - -# --------------------------------------------------------------------------- -# _evaluate_with_explicit_file -# --------------------------------------------------------------------------- - - -class TestEvaluateWithExplicitFile: - def _call( - self, - tmp_path: Path, - constraint_file: str, - *, - severity_min: str = "warn", - path_filter: str = "", - output_format: str = "json", - ) -> dict: - return _evaluate_with_explicit_file( - project_root=str(tmp_path), - constraint_file=constraint_file, - severity_min=severity_min, - path_filter=path_filter, - output_format=output_format, - ) + assert (result["success"], result["verdict"], result["error_code"]) == ( + False, + "ERROR", + "CONSTRAINT_CONFIG_CHANGED", + ) - def test_file_not_found_returns_failure(self, tmp_path): - with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): - result = self._call(tmp_path, str(tmp_path / "missing.yml")) - assert result["success"] is False - assert "not found" in result["error"] - def test_parse_error_returns_failure(self, tmp_path): - from tree_sitter_analyzer.constraints.parser import ConstraintParseError +def test_explicit_config_recheck_treats_read_failure_as_changed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import tree_sitter_analyzer.cli.commands.constraint_check_command as owner - yaml_file = tmp_path / "constraints.yml" - yaml_file.write_text("") - with patch(_LOAD_EXPLICIT, side_effect=ConstraintParseError("bad")): - with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): - result = self._call(tmp_path, str(yaml_file)) - assert result["success"] is False - assert "parse error" in result["error"] - - def test_no_db_returns_safe_with_note(self, tmp_path): - yaml_file = tmp_path / "constraints.yml" - yaml_file.write_text("") - with patch(_LOAD_EXPLICIT, return_value=[]): - with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): - result = self._call(tmp_path, str(yaml_file)) - assert result["verdict"] == "SAFE" - assert "note" in result - assert result["evaluated_edge_count"] == 0 - - def test_with_db_no_violations_returns_safe(self, tmp_path): - yaml_file = tmp_path / "constraints.yml" - yaml_file.write_text("") - db_dir = tmp_path / ".ast-cache" - db_dir.mkdir() - sqlite3.connect(str(db_dir / "index.db")).close() - - with patch(_LOAD_EXPLICIT, return_value=[]): - with patch(_RUN_AND_PERSIST, return_value=([], 5)): - with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): - result = self._call(tmp_path, str(yaml_file)) - assert result["verdict"] == "SAFE" - assert result["success"] is True - assert result["evaluated_edge_count"] == 5 - - def test_constraint_file_path_included_in_result(self, tmp_path): - yaml_file = tmp_path / "constraints.yml" - yaml_file.write_text("") - db_dir = tmp_path / ".ast-cache" - db_dir.mkdir() - sqlite3.connect(str(db_dir / "index.db")).close() - - with patch(_LOAD_EXPLICIT, return_value=[]): - with patch(_RUN_AND_PERSIST, return_value=([], 0)): - with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): - result = self._call(tmp_path, str(yaml_file)) - assert "constraint_file" in result - - def test_with_db_and_error_violations_returns_unsafe(self, tmp_path): - yaml_file = tmp_path / "constraints.yml" - yaml_file.write_text("") - db_dir = tmp_path / ".ast-cache" - db_dir.mkdir() - sqlite3.connect(str(db_dir / "index.db")).close() - - error_v = _v(severity="error") - with patch(_LOAD_EXPLICIT, return_value=[]): - with patch(_RUN_AND_PERSIST, return_value=([error_v], 3)): - with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): - result = self._call(tmp_path, str(yaml_file)) - assert result["verdict"] == "UNSAFE" - - -# --------------------------------------------------------------------------- -# run_check_constraints — main dispatcher -# --------------------------------------------------------------------------- - - -def _ns(**kwargs: object) -> SimpleNamespace: - return SimpleNamespace( - severity_min=kwargs.get("severity_min", "warn"), - constraint_path_filter=kwargs.get("constraint_path_filter", ""), - constraint_file=kwargs.get("constraint_file", None), + config = tmp_path / "candidate.yml" + config.write_bytes(b"version: 1\nconstraints: []\n") + before = owner._explicit_config_evidence(config, float("inf")) + monkeypatch.setattr( + owner, + "_explicit_config_evidence", + lambda *_args: (_ for _ in ()).throw(OSError("unreadable")), ) - - -class TestRunCheckConstraints: - def test_with_constraint_file_routes_to_evaluate_explicit(self, tmp_path): - args = _ns(constraint_file="/some/path.yml") - safe_result = {"success": True, "verdict": "SAFE"} - with patch(_RESOLVE_OFMT, return_value="json"): - with patch(_EVAL_EXPLICIT, return_value=safe_result) as mock_eval: - with patch(_PRINT_RESULT): - code = run_check_constraints(args, str(tmp_path)) - mock_eval.assert_called_once() - assert code == 0 - - def test_without_constraint_file_calls_asyncio_run(self, tmp_path): - args = _ns() - safe_result = {"success": True, "verdict": "SAFE"} - with patch(_RESOLVE_OFMT, return_value="json"): - with patch(_ASYNCIO_RUN, return_value=safe_result) as mock_run: - with patch(_PRINT_RESULT): - code = run_check_constraints(args, str(tmp_path)) - mock_run.assert_called_once() - assert code == 0 - - def test_caution_verdict_returns_exit_2(self, tmp_path): - args = _ns() - caution_result = {"success": True, "verdict": "CAUTION"} - with patch(_RESOLVE_OFMT, return_value="json"): - with patch(_ASYNCIO_RUN, return_value=caution_result): - with patch(_PRINT_RESULT): - code = run_check_constraints(args, str(tmp_path)) - assert code == 2 - - def test_failure_result_returns_exit_1(self, tmp_path): - args = _ns() - fail_result = {"success": False, "verdict": "UNSAFE"} - with patch(_RESOLVE_OFMT, return_value="json"): - with patch(_ASYNCIO_RUN, return_value=fail_result): - with patch(_PRINT_RESULT): - code = run_check_constraints(args, str(tmp_path)) - assert code == 1 - - def test_severity_min_defaults_to_warn_when_none(self, tmp_path): - args = SimpleNamespace( - severity_min=None, - constraint_path_filter="", - constraint_file="/f.yml", - ) - safe_result = {"success": True, "verdict": "SAFE"} - with patch(_RESOLVE_OFMT, return_value="json"): - with patch(_EVAL_EXPLICIT, return_value=safe_result) as mock_eval: - with patch(_PRINT_RESULT): - run_check_constraints(args, str(tmp_path)) - called_kwargs = mock_eval.call_args.kwargs - assert called_kwargs["severity_min"] == "warn" - - def test_print_result_called_with_result_and_format(self, tmp_path): - args = _ns() - safe_result = {"success": True, "verdict": "SAFE"} - with patch(_RESOLVE_OFMT, return_value="toon"): - with patch(_ASYNCIO_RUN, return_value=safe_result): - with patch(_PRINT_RESULT) as mock_print: - run_check_constraints(args, str(tmp_path)) - mock_print.assert_called_once_with(safe_result, "toon") - - def test_path_filter_passed_to_evaluate_explicit(self, tmp_path): - args = _ns(constraint_file="/f.yml", constraint_path_filter="src/**") - safe_result = {"success": True, "verdict": "SAFE"} - with patch(_RESOLVE_OFMT, return_value="json"): - with patch(_EVAL_EXPLICIT, return_value=safe_result) as mock_eval: - with patch(_PRINT_RESULT): - run_check_constraints(args, str(tmp_path)) - called_kwargs = mock_eval.call_args.kwargs - assert called_kwargs["path_filter"] == "src/**" + assert owner._explicit_config_changed(config, before, float("inf")) is True + + +def test_explicit_nonempty_rules_publish_when_config_remains_exact( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3772454771: explicit config and evaluation share one budget. + config = tmp_path / "candidate.yml" + config.write_text( + "version: 1\nconstraints:\n" + " - {id: r, severity: warn, rule: forbid, from: 'a/**', " + "to: 'b/**', reason: boundary}\n" + ) + observed = [] + monkeypatch.setattr( + "tree_sitter_analyzer.cli.commands.constraint_check_command._explicit_config_evidence", + lambda path, deadline: ( + observed.append(deadline) or _explicit_config_evidence(path, deadline) + ), + ) + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.ConstraintCheckTool._run_read_only", + lambda *_args, deadline, **_kwargs: (observed.append(deadline) or [], 0), + ) + result = _evaluate_with_explicit_file( + project_root=str(tmp_path), + constraint_file=str(config), + severity_min="warn", + path_filter="", + output_format="json", + persist=False, + ) + actual = result["success"], result["verdict"], result["rule_count"] + assert actual == (True, "SAFE", 1) + assert observed == [observed[0]] * 3 diff --git a/tests/unit/cli/test_constraint_check_execution.py b/tests/unit/cli/test_constraint_check_execution.py new file mode 100644 index 000000000..260bb5e8b --- /dev/null +++ b/tests/unit/cli/test_constraint_check_execution.py @@ -0,0 +1,498 @@ +"""Tests for tree_sitter_analyzer.cli.commands.constraint_check_command.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from tree_sitter_analyzer.cli.commands.constraint_check_command import ( + _evaluate_with_explicit_file, + _exit_code_for, + run_check_constraints, +) + +_APPLY_TOON = ( + "tree_sitter_analyzer.mcp.utils.format_helper.apply_toon_format_to_response" +) +_COMMAND = "tree_sitter_analyzer.cli.commands.constraint_check_command" +_EVALUATE = _COMMAND + ".evaluate" +_LOAD_EXPLICIT = _COMMAND + "._load_explicit" +_RUN_AND_PERSIST = _COMMAND + "._run_and_persist" +_EVAL_EXPLICIT = _COMMAND + "._evaluate_with_explicit_file" +_ASYNCIO_RUN = _COMMAND + ".asyncio.run" +_PRINT_RESULT = _COMMAND + "._print_result" +_RESOLVE_OFMT = _COMMAND + "._resolve_output_format" + + +def _v( + severity: str = "error", + rule_id: str = "R1", + caller_file: str = "a.py", + caller_name: str = "foo", + caller_line: int = 10, + callee_name: str = "bar", + callee_file: str = "b.py", + detected_at: int | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + severity=severity, + rule_id=rule_id, + caller_file=caller_file, + caller_name=caller_name, + caller_line=caller_line, + callee_name=callee_name, + callee_file=callee_file, + detected_at=detected_at, + ) + + +class TestEvaluateWithExplicitFile: + def _call( + self, + tmp_path: Path, + constraint_file: str, + *, + severity_min: str = "warn", + path_filter: str = "", + output_format: str = "json", + persist: bool = True, + ) -> dict: + return _evaluate_with_explicit_file( + project_root=str(tmp_path), + constraint_file=constraint_file, + severity_min=severity_min, + path_filter=path_filter, + output_format=output_format, + persist=persist, + ) + + def test_file_not_found_returns_failure(self, tmp_path): + with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): + result = self._call(tmp_path, str(tmp_path / "missing.yml")) + assert result["success"] is False + assert "not found" in result["error"] + + def test_parse_error_returns_failure(self, tmp_path): + from tree_sitter_analyzer.constraints.parser import ConstraintParseError + + yaml_file = tmp_path / "constraints.yml" + yaml_file.write_text("") + with patch(_LOAD_EXPLICIT, side_effect=ConstraintParseError("bad")): + with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): + result = self._call(tmp_path, str(yaml_file)) + assert result["success"] is False + assert "parse error" in result["error"] + + def test_no_db_returns_safe_with_note(self, tmp_path): + yaml_file = tmp_path / "constraints.yml" + yaml_file.write_text("") + with patch(_LOAD_EXPLICIT, return_value=[object()]): + with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): + result = self._call(tmp_path, str(yaml_file)) + assert result["verdict"] == "SAFE" + assert "note" in result + assert result["evaluated_edge_count"] == 0 + + def test_with_db_no_violations_returns_safe(self, tmp_path): + yaml_file = tmp_path / "constraints.yml" + yaml_file.write_text("") + db_dir = tmp_path / ".ast-cache" + db_dir.mkdir() + sqlite3.connect(str(db_dir / "index.db")).close() + + with patch(_LOAD_EXPLICIT, return_value=[object()]): + with patch(_RUN_AND_PERSIST, return_value=([], 5)): + with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): + result = self._call(tmp_path, str(yaml_file)) + assert result["verdict"] == "SAFE" + assert result["success"] is True + assert result["evaluated_edge_count"] == 5 + + def test_read_only_missing_edges_fails_closed_with_nonzero_exit(self, tmp_path): + # PR #1254 review 3766246590: an absent edge capability is not SAFE. + yaml_file = tmp_path / "constraints.yml" + yaml_file.write_text( + """version: 1 +constraints: + - id: no-cli-to-mcp + severity: error + rule: forbid + from: cli/** + to: mcp/** + reason: boundary +""" + ) + db_dir = tmp_path / ".ast-cache" + db_dir.mkdir() + sqlite3.connect(str(db_dir / "index.db")).close() + + with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): + result = self._call(tmp_path, str(yaml_file), persist=False) + + expected_error = "CORRUPT_INDEX" + assert ( + result["success"], + result["verdict"], + result["error_code"], + result["error"], + result["violations"], + result["rule_count"], + _exit_code_for(result), + ) == ( + False, + "ERROR", + "CONSTRAINT_INDEX_UNKNOWN", + expected_error, + [], + 1, + 1, + ) + + def test_read_only_corrupt_edges_fails_closed_with_nonzero_exit(self, tmp_path): + # PR #1254 review 3766246590: evaluator database failures are not SAFE. + yaml_file = tmp_path / "constraints.yml" + yaml_file.write_text( + """version: 1 +constraints: + - id: no-cli-to-mcp + severity: error + rule: forbid + from: cli/** + to: mcp/** + reason: boundary +""" + ) + db_dir = tmp_path / ".ast-cache" + db_dir.mkdir() + conn = sqlite3.connect(str(db_dir / "index.db")) + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.execute("INSERT INTO edges VALUES ('calls')") + conn.commit() + conn.close() + + with patch(_EVALUATE, side_effect=sqlite3.DatabaseError("CORRUPT_INDEX")): + with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): + result = self._call(tmp_path, str(yaml_file), persist=False) + + expected_error = "CORRUPT_INDEX" + assert ( + result["success"], + result["verdict"], + result["error_code"], + result["error"], + result["violations"], + result["rule_count"], + _exit_code_for(result), + ) == ( + False, + "ERROR", + "CONSTRAINT_INDEX_UNKNOWN", + expected_error, + [], + 1, + 1, + ) + + def test_constraint_file_path_included_in_result(self, tmp_path): + yaml_file = tmp_path / "constraints.yml" + yaml_file.write_text("") + db_dir = tmp_path / ".ast-cache" + db_dir.mkdir() + sqlite3.connect(str(db_dir / "index.db")).close() + + with patch(_LOAD_EXPLICIT, return_value=[]): + with patch(_RUN_AND_PERSIST, return_value=([], 0)): + with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): + result = self._call(tmp_path, str(yaml_file)) + assert "constraint_file" in result + + def test_with_db_and_error_violations_returns_unsafe(self, tmp_path): + yaml_file = tmp_path / "constraints.yml" + yaml_file.write_text("") + db_dir = tmp_path / ".ast-cache" + db_dir.mkdir() + sqlite3.connect(str(db_dir / "index.db")).close() + + error_v = _v(severity="error") + with patch(_LOAD_EXPLICIT, return_value=[]): + with patch(_RUN_AND_PERSIST, return_value=([error_v], 3)): + with patch(_APPLY_TOON, side_effect=lambda p, fmt: p): + result = self._call(tmp_path, str(yaml_file)) + assert result["verdict"] == "UNSAFE" + + +def _ns(**kwargs: object) -> SimpleNamespace: + return SimpleNamespace( + severity_min=kwargs.get("severity_min", "warn"), + constraint_path_filter=kwargs.get("constraint_path_filter", ""), + constraint_file=kwargs.get("constraint_file", None), + constraints_read_only=kwargs.get("constraints_read_only", False), + ) + + +class TestRunCheckConstraints: + def test_with_constraint_file_routes_to_evaluate_explicit(self, tmp_path): + args = _ns(constraint_file="/some/path.yml") + safe_result = {"success": True, "verdict": "SAFE"} + with patch(_RESOLVE_OFMT, return_value="json"): + with patch(_EVAL_EXPLICIT, return_value=safe_result) as mock_eval: + with patch(_PRINT_RESULT): + code = run_check_constraints(args, str(tmp_path)) + mock_eval.assert_called_once() + assert code == 0 + + def test_without_constraint_file_calls_asyncio_run(self, tmp_path): + args = _ns() + safe_result = {"success": True, "verdict": "SAFE"} + with patch(_RESOLVE_OFMT, return_value="json"): + with patch(_ASYNCIO_RUN, return_value=safe_result) as mock_run: + with patch(_PRINT_RESULT): + code = run_check_constraints(args, str(tmp_path)) + mock_run.assert_called_once() + assert code == 0 + + def test_caution_verdict_returns_exit_2(self, tmp_path): + args = _ns() + caution_result = {"success": True, "verdict": "CAUTION"} + with patch(_RESOLVE_OFMT, return_value="json"): + with patch(_ASYNCIO_RUN, return_value=caution_result): + with patch(_PRINT_RESULT): + code = run_check_constraints(args, str(tmp_path)) + assert code == 2 + + def test_failure_result_returns_exit_1(self, tmp_path): + args = _ns() + fail_result = {"success": False, "verdict": "UNSAFE"} + with patch(_RESOLVE_OFMT, return_value="json"): + with patch(_ASYNCIO_RUN, return_value=fail_result): + with patch(_PRINT_RESULT): + code = run_check_constraints(args, str(tmp_path)) + assert code == 1 + + def test_severity_min_defaults_to_warn_when_none(self, tmp_path): + args = SimpleNamespace( + severity_min=None, + constraint_path_filter="", + constraint_file="/f.yml", + ) + safe_result = {"success": True, "verdict": "SAFE"} + with patch(_RESOLVE_OFMT, return_value="json"): + with patch(_EVAL_EXPLICIT, return_value=safe_result) as mock_eval: + with patch(_PRINT_RESULT): + run_check_constraints(args, str(tmp_path)) + called_kwargs = mock_eval.call_args.kwargs + assert called_kwargs["severity_min"] == "warn" + + def test_print_result_called_with_result_and_format(self, tmp_path): + args = _ns() + safe_result = {"success": True, "verdict": "SAFE"} + with patch(_RESOLVE_OFMT, return_value="toon"): + with patch(_ASYNCIO_RUN, return_value=safe_result): + with patch(_PRINT_RESULT) as mock_print: + run_check_constraints(args, str(tmp_path)) + mock_print.assert_called_once_with(safe_result, "toon") + + def test_path_filter_passed_to_evaluate_explicit(self, tmp_path): + args = _ns(constraint_file="/f.yml", constraint_path_filter="src/**") + safe_result = {"success": True, "verdict": "SAFE"} + with patch(_RESOLVE_OFMT, return_value="json"): + with patch(_EVAL_EXPLICIT, return_value=safe_result) as mock_eval: + with patch(_PRINT_RESULT): + run_check_constraints(args, str(tmp_path)) + called_kwargs = mock_eval.call_args.kwargs + assert called_kwargs["path_filter"] == "src/**" + + +def test_read_only_option_forwards_persist_false(tmp_path): + args = _ns(constraints_read_only=True) + safe_result = {"success": True, "verdict": "SAFE"} + with patch(_RESOLVE_OFMT, return_value="json"): + with patch(_ASYNCIO_RUN, return_value=safe_result): + with patch(_PRINT_RESULT): + with patch( + "tree_sitter_analyzer.cli.commands.constraint_check_command._run_tool" + ) as run_tool: + + async def result(): + return safe_result + + run_tool.return_value = result() + run_check_constraints(args, str(tmp_path)) + assert run_tool.call_args.kwargs["persist"] is False + + +def test_read_only_explicit_zero_rules_is_safe_without_index(tmp_path: Path) -> None: + # PR #1254 review 3767373489: empty policy needs no graph authority. + config = tmp_path / "empty.yml" + config.write_text("version: 1\nconstraints: []\n") + + result = _evaluate_with_explicit_file( + project_root=str(tmp_path), + constraint_file=str(config), + severity_min="warn", + path_filter="", + output_format="json", + persist=False, + ) + + assert result == { + "success": True, + "verdict": "SAFE", + "violations": [], + "rule_count": 0, + "evaluated_edge_count": 0, + "constraint_file": str(config), + } + assert not (tmp_path / ".ast-cache").exists() + + +def test_read_only_explicit_file_parses_bytes_without_temporary_staging( + tmp_path: Path, +) -> None: + # PR #1254 review 3768614254: read-only CLI must not honor project-local TMPDIR. + config = tmp_path / "candidate.yml" + config.write_text("version: 1\nconstraints: []\n") + + with patch( + "tree_sitter_analyzer.cli.commands.constraint_check_command._load_explicit", + side_effect=AssertionError("read-only route staged the config"), + ): + result = _evaluate_with_explicit_file( + project_root=str(tmp_path), + constraint_file=str(config), + severity_min="warn", + path_filter="", + output_format="json", + persist=False, + ) + + assert result == { + "success": True, + "verdict": "SAFE", + "violations": [], + "rule_count": 0, + "evaluated_edge_count": 0, + "constraint_file": str(config), + } + + +def test_read_only_explicit_file_maps_portable_oserror_to_index_error( + tmp_path: Path, +) -> None: + # PR #1254 review 3768452298: explicit-file portable errors stay structured. + config = tmp_path / "candidate.yml" + config.write_text( + """version: 1 +constraints: + - id: no-cli-to-mcp + severity: error + rule: forbid + from: cli/** + to: mcp/** + reason: boundary +""" + ) + + with patch.object( + __import__( + "tree_sitter_analyzer.cli.commands.constraint_check_command", + fromlist=["ConstraintCheckTool"], + ).ConstraintCheckTool, + "_run_read_only", + side_effect=OSError("portable index disappeared"), + ): + result = _evaluate_with_explicit_file( + project_root=str(tmp_path), + constraint_file=str(config), + severity_min="warn", + path_filter="", + output_format="json", + persist=False, + ) + + assert result == { + "success": False, + "verdict": "ERROR", + "error_code": "CONSTRAINT_INDEX_UNKNOWN", + "error": "portable index disappeared", + "violations": [], + "rule_count": 1, + } + + +def test_explicit_config_evidence_ignores_read_induced_atime(tmp_path, monkeypatch): + # PR #1254 review 3771670600: reads cannot invalidate their own evidence. + import tree_sitter_analyzer.cli.commands.constraint_check_execution as owner + + config = tmp_path / "rules.yml" + payload = b"version: 1\n" + config.write_bytes(payload) + stable = config.stat() + accessed = SimpleNamespace( + st_dev=stable.st_dev, + st_ino=stable.st_ino, + st_mode=stable.st_mode, + st_size=stable.st_size, + st_mtime_ns=stable.st_mtime_ns, + st_ctime_ns=stable.st_ctime_ns, + st_atime_ns=stable.st_atime_ns + 1, + st_file_attributes=getattr(stable, "st_file_attributes", 0), + ) + real_path_stat = Path.stat + path_stats = iter((stable, accessed)) + + def config_stat(path, **kwargs): + if path == config: + return next(path_stats) + return real_path_stat(path, **kwargs) + + monkeypatch.setattr(Path, "stat", config_stat) + monkeypatch.setattr(owner, "os", SimpleNamespace(fstat=lambda _fd: accessed)) + + result = owner.explicit_config_evidence(config, float("inf")) + + assert result == (payload, owner._identity(accessed)) + + +@pytest.mark.parametrize("phase", ["open", "final_fd", "final_path"]) +def test_explicit_identity_changes(tmp_path, monkeypatch, phase): + import tree_sitter_analyzer.cli.commands.constraint_check_execution as owner + + config = tmp_path / "rules.yml" + config.write_bytes(b"version: 1\n") + stable = config.stat() + changed = SimpleNamespace( + st_dev=stable.st_dev, + st_ino=stable.st_ino, + st_mode=stable.st_mode, + st_size=stable.st_size, + st_mtime_ns=stable.st_mtime_ns + 1, + st_ctime_ns=stable.st_ctime_ns, + st_file_attributes=getattr(stable, "st_file_attributes", 0), + ) + fstats = iter((changed,)) if phase == "open" else iter((stable, changed)) + monkeypatch.setattr(owner, "os", SimpleNamespace(fstat=lambda _fd: next(fstats))) + if phase == "final_path": + real_path_stat = Path.stat + path_stats = iter((stable, changed)) + + def config_stat(path, **kwargs): + if path == config: + return next(path_stats) + return real_path_stat(path, **kwargs) + + monkeypatch.setattr(owner, "os", SimpleNamespace(fstat=lambda _fd: stable)) + monkeypatch.setattr(Path, "stat", config_stat) + with pytest.raises(OSError, match="^constraint file changed during read$"): + owner.explicit_config_evidence(config, float("inf")) + + +def test_explicit_config_evidence_rejects_directory(tmp_path): + import tree_sitter_analyzer.cli.commands.constraint_check_execution as owner + + with pytest.raises(OSError, match="^constraint file is not a regular file$"): + owner.explicit_config_evidence(tmp_path, float("inf")) diff --git a/tests/unit/cli/test_constraint_check_persistence.py b/tests/unit/cli/test_constraint_check_persistence.py new file mode 100644 index 000000000..7fe8fc1c0 --- /dev/null +++ b/tests/unit/cli/test_constraint_check_persistence.py @@ -0,0 +1,452 @@ +"""Tests for tree_sitter_analyzer.cli.commands.constraint_check_command.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from tree_sitter_analyzer.cli.commands.constraint_check_command import ( + _evaluate_with_explicit_file, + _load_explicit, + _run_and_persist, + _run_tool, + _violations_ddl, + get_default_project_root, +) + +# Module-level patch targets +_APPLY_TOON = ( + "tree_sitter_analyzer.mcp.utils.format_helper.apply_toon_format_to_response" +) +_RESOLVE_FMT = "tree_sitter_analyzer.cli.output_format.resolve_mcp_tool_format" +_LOAD_CONSTRAINTS = ( + "tree_sitter_analyzer.cli.commands.constraint_check_command.load_constraints" +) +_EVALUATE = "tree_sitter_analyzer.cli.commands.constraint_check_command.evaluate" +_LOAD_EXPLICIT = ( + "tree_sitter_analyzer.cli.commands.constraint_check_command._load_explicit" +) +_RUN_AND_PERSIST = ( + "tree_sitter_analyzer.cli.commands.constraint_check_command._run_and_persist" +) +_EVAL_EXPLICIT = ( + "tree_sitter_analyzer.cli.commands.constraint_check_command" + "._evaluate_with_explicit_file" +) +_ASYNCIO_RUN = "tree_sitter_analyzer.cli.commands.constraint_check_command.asyncio.run" +_PRINT_RESULT = ( + "tree_sitter_analyzer.cli.commands.constraint_check_command._print_result" +) +_RESOLVE_OFMT = ( + "tree_sitter_analyzer.cli.commands.constraint_check_command._resolve_output_format" +) +_CCT_CLS = ( + "tree_sitter_analyzer.cli.commands.constraint_check_command.ConstraintCheckTool" +) + + +def _v( + severity: str = "error", + rule_id: str = "R1", + caller_file: str = "a.py", + caller_name: str = "foo", + caller_line: int = 10, + callee_name: str = "bar", + callee_file: str = "b.py", + detected_at: int | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + severity=severity, + rule_id=rule_id, + caller_file=caller_file, + caller_name=caller_name, + caller_line=caller_line, + callee_name=callee_name, + callee_file=callee_file, + detected_at=detected_at, + ) + + +# --------------------------------------------------------------------------- +# Violation stub +# --------------------------------------------------------------------------- + + +class TestGetDefaultProjectRoot: + def test_returns_project_root_attr(self): + args = SimpleNamespace(project_root="/srv/proj") + assert get_default_project_root(args) == "/srv/proj" + + def test_falls_back_to_cwd_when_none(self): + args = SimpleNamespace(project_root=None) + assert get_default_project_root(args) # truthy + + def test_falls_back_to_cwd_when_attr_missing(self): + assert get_default_project_root(SimpleNamespace()) # truthy + + +# --------------------------------------------------------------------------- +# _load_explicit +# --------------------------------------------------------------------------- + + +class TestLoadExplicit: + def test_canonical_name_calls_load_constraints_on_parent(self, tmp_path): + yaml_file = tmp_path / "architectural-constraints.yml" + yaml_file.write_text("rules: []") + with patch(_LOAD_CONSTRAINTS, return_value=[]) as mock_load: + result = _load_explicit(yaml_file) + mock_load.assert_called_once_with(str(tmp_path)) + assert result == [] + + def test_non_canonical_name_stages_into_tempdir(self, tmp_path): + yaml_file = tmp_path / "my-constraints.yml" + yaml_file.write_text("rules: []") + staged_roots: list[str] = [] + + def capture(root: str) -> list: + staged_roots.append(root) + return ["rule1"] + + with patch(_LOAD_CONSTRAINTS, side_effect=capture): + result = _load_explicit(yaml_file) + + assert staged_roots[0] != str(tmp_path) # was staged, not the original dir + assert result == ["rule1"] + + def test_non_canonical_creates_canonical_filename_in_tempdir(self, tmp_path): + yaml_file = tmp_path / "custom.yml" + yaml_file.write_text("rules: []") + + def capture_and_check(root: str) -> list: + staged = Path(root) / "architectural-constraints.yml" + assert staged.exists(), "canonical filename not staged" + return [] + + with patch(_LOAD_CONSTRAINTS, side_effect=capture_and_check): + _load_explicit(yaml_file) + + def test_non_canonical_content_is_copied(self, tmp_path): + yaml_file = tmp_path / "other.yml" + content = "rules:\n - id: R99\n" + yaml_file.write_text(content) + file_contents: list[str] = [] + + def capture(root: str) -> list: + staged = Path(root) / "architectural-constraints.yml" + file_contents.append(staged.read_text()) + return [] + + with patch(_LOAD_CONSTRAINTS, side_effect=capture): + _load_explicit(yaml_file) + + assert file_contents[0] == content + + +# --------------------------------------------------------------------------- +# _run_and_persist +# --------------------------------------------------------------------------- + + +class TestRunAndPersist: + def _empty_db(self, tmp_path: Path) -> Path: + db = tmp_path / "index.db" + sqlite3.connect(str(db)).close() + return db + + def _db_with_edges(self, tmp_path: Path) -> Path: + from tree_sitter_analyzer.graph.edge_store import EDGE_STORE_SCHEMA + + db = tmp_path / "index.db" + conn = sqlite3.connect(str(db)) + # B1.3: the edge-count gate counts CALLS rows in the unified ``edges`` + # table (ast_call_edges was dropped). + conn.executescript(EDGE_STORE_SCHEMA) + conn.execute( + "INSERT INTO edges (source_node_id, target_node_id, kind) " + "VALUES ('a.py:f:1', 'b.py:g:1', 'calls')" + ) + conn.commit() + conn.close() + return db + + def test_no_call_edges_table_returns_empty(self, tmp_path): + db = self._empty_db(tmp_path) + violations, edge_count = _run_and_persist(db, []) + assert violations == [] + assert edge_count == 0 + + @pytest.mark.slow_ok # Windows xdist-load budget exemption; test logic is trivial, no perf claim (#976) + def test_empty_call_edges_table_returns_empty(self, tmp_path): + db = tmp_path / "index.db" + conn = sqlite3.connect(str(db)) + conn.execute("CREATE TABLE ast_call_edges (id INTEGER PRIMARY KEY)") + conn.commit() + conn.close() + violations, edge_count = _run_and_persist(db, []) + assert violations == [] + assert edge_count == 0 + + def test_evaluate_exception_degrades_gracefully(self, tmp_path): + db = self._db_with_edges(tmp_path) + with patch(_EVALUATE, side_effect=RuntimeError("boom")): + violations, edge_count = _run_and_persist(db, []) + assert violations == [] + assert edge_count == 1 + + def test_violations_persisted_to_db(self, tmp_path): + db = self._db_with_edges(tmp_path) + v = _v(detected_at=12345) + with patch(_EVALUATE, return_value=[v]): + violations, _ = _run_and_persist(db, ["c"]) + assert len(violations) == 1 + conn = sqlite3.connect(str(db)) + rows = conn.execute("SELECT rule_id FROM ast_constraint_violations").fetchall() + conn.close() + assert rows == [("R1",)] + + def test_returns_edge_count_from_db(self, tmp_path): + db = self._db_with_edges(tmp_path) + with patch(_EVALUATE, return_value=[]): + _, edge_count = _run_and_persist(db, []) + assert edge_count == 1 + + def test_read_only_evaluation_returns_rows_without_creating_cache_table( + self, tmp_path + ): + db = self._db_with_edges(tmp_path) + violation = _v() + + with patch(_EVALUATE, return_value=[violation]): + result = _run_and_persist(db, ["c"], persist=False) + + conn = sqlite3.connect(str(db)) + tables = conn.execute( + "SELECT name FROM sqlite_master " + "WHERE type = 'table' AND name = 'ast_constraint_violations'" + ).fetchall() + conn.close() + assert result == ([violation], 1) + assert tables == [] + + def test_read_only_missing_edges_table_reraises_operational_error(self, tmp_path): + db = self._empty_db(tmp_path) + + with pytest.raises(sqlite3.OperationalError, match="no such table: edges"): + _run_and_persist(db, [], persist=False) + + def test_read_only_evaluator_exception_is_not_degraded(self, tmp_path): + db = self._db_with_edges(tmp_path) + + with patch(_EVALUATE, side_effect=RuntimeError("evaluation failed")): + with pytest.raises(RuntimeError, match="^evaluation failed$"): + _run_and_persist(db, [], persist=False) + + def test_violations_table_cleared_before_insert(self, tmp_path): + db = self._db_with_edges(tmp_path) + # Pre-populate violations table with a stale row + conn = sqlite3.connect(str(db)) + conn.execute(_violations_ddl()) + conn.execute( + """INSERT INTO ast_constraint_violations + VALUES ('OLD', 'f.py', 'fn', 1, 'bar', 'g.py', 'warn', 0)""" + ) + conn.commit() + conn.close() + + new_v = _v(rule_id="NEW", detected_at=1) + with patch(_EVALUATE, return_value=[new_v]): + _run_and_persist(db, ["c"]) + + conn = sqlite3.connect(str(db)) + rows = conn.execute("SELECT rule_id FROM ast_constraint_violations").fetchall() + conn.close() + # OLD row must be gone; only NEW should be present + rule_ids = [r[0] for r in rows] + assert "OLD" not in rule_ids + assert "NEW" in rule_ids + + def test_duplicate_pk_violations_do_not_crash_persist(self, tmp_path): + """Regression for #544: two violations with the same PK must not crash. + + If ``evaluate()`` returns two ``Violation`` objects that share the + same ``(rule_id, caller_file, caller_line, callee_name)`` PRIMARY + KEY (e.g., one call site resolved to two ``callee_file`` targets), + the old ``executemany`` would raise + ``UNIQUE constraint failed: ast_constraint_violations.rule_id, ...``. + + After the fix the persist path must succeed and write exactly 1 row + (the dedup is in ``evaluate()``, so ``_run_and_persist`` receives a + clean list — this test verifies the full stack from mock to DB). + """ + db = self._db_with_edges(tmp_path) + # Two violations with identical PK but different callee_file. + dup_v1 = _v( + rule_id="R1", + caller_file="a.py", + caller_line=10, + callee_name="bar", + callee_file="b.py", + detected_at=1, + ) + dup_v2 = _v( + rule_id="R1", + caller_file="a.py", + caller_line=10, + callee_name="bar", + callee_file="c.py", + detected_at=1, + ) + + # We intentionally bypass the real evaluate() and inject the two + # duplicates directly to test the persist layer in isolation. + with patch(_EVALUATE, return_value=[dup_v1, dup_v2]): + # Must NOT raise sqlite3.IntegrityError. + violations, edge_count = _run_and_persist(db, ["c"]) + + conn = sqlite3.connect(str(db)) + rows = conn.execute( + "SELECT rule_id, caller_file, caller_line, callee_name " + "FROM ast_constraint_violations" + ).fetchall() + conn.close() + # Exactly 1 row persisted (PK is unique); the constraint did not crash. + assert len(rows) == 1, ( + f"Expected exactly 1 persisted row after dedup, got {len(rows)}: {rows}" + ) + assert rows[0] == ("R1", "a.py", 10, "bar") + + +# --------------------------------------------------------------------------- +# _run_tool +# --------------------------------------------------------------------------- + + +class TestRunTool: + def test_builds_tool_and_returns_execute_coroutine(self, tmp_path): + import asyncio + + mock_tool = MagicMock() + mock_tool.execute = AsyncMock(return_value={"success": True}) + + with patch(_CCT_CLS, return_value=mock_tool): + result = asyncio.run(_run_tool(str(tmp_path), "warn", "", "json")) + + assert result == {"success": True} + mock_tool.execute.assert_called_once_with( + {"path_filter": "", "severity_min": "warn", "output_format": "json"} + ) + + def test_passes_path_filter_and_severity(self, tmp_path): + import asyncio + + mock_tool = MagicMock() + mock_tool.execute = AsyncMock(return_value={"success": False}) + + with patch(_CCT_CLS, return_value=mock_tool): + asyncio.run(_run_tool(str(tmp_path), "error", "src/*", "toon")) + + called_payload = mock_tool.execute.call_args[0][0] + assert called_payload["severity_min"] == "error" + assert called_payload["path_filter"] == "src/*" + assert called_payload["output_format"] == "toon" + + def test_read_only_omits_persistence_from_tool_execution(self, tmp_path): + import asyncio + + mock_tool = MagicMock() + mock_tool.execute = AsyncMock(return_value={"success": True}) + + with patch(_CCT_CLS, return_value=mock_tool): + asyncio.run(_run_tool(str(tmp_path), "warn", "", "json", persist=False)) + + mock_tool.execute.assert_awaited_once_with( + { + "path_filter": "", + "severity_min": "warn", + "output_format": "json", + "persist": False, + } + ) + + +def _unexpected_evaluator(*_args): + raise ValueError("bad evaluator") + + +def test_persistence_swallows_unexpected_evaluator_errors(tmp_path: Path) -> None: + from tree_sitter_analyzer.cli.commands.constraint_check_persistence import ( + run_and_persist, + ) + + db = TestRunAndPersist()._db_with_edges(tmp_path) + assert run_and_persist( + db, + [], + persist=True, + evaluator=_unexpected_evaluator, + violations_ddl=_violations_ddl, + ) == ([], 1) + + +def test_read_only_propagates_unexpected_evaluator_errors(tmp_path: Path) -> None: + from tree_sitter_analyzer.cli.commands.constraint_check_persistence import ( + run_and_persist, + ) + + db = TestRunAndPersist()._db_with_edges(tmp_path) + with pytest.raises(ValueError, match="^bad evaluator$"): + run_and_persist( + db, + [], + persist=False, + evaluator=_unexpected_evaluator, + violations_ddl=_violations_ddl, + ) + + +def test_explicit_persist_capacity_failure_is_structured( + tmp_path: Path, +) -> None: + # PR #1254 review 3768096795: explicit-file persistence must fail closed. + config = tmp_path / "candidate.yml" + config.write_text( + """version: 1 +constraints: + - id: no-cli-to-mcp + severity: error + rule: forbid + from: cli/** + to: mcp/** + reason: boundary +""" + ) + db_path = tmp_path / ".ast-cache" / "index.db" + db_path.parent.mkdir() + db_path.touch() + + with patch( + "tree_sitter_analyzer.cli.commands.constraint_check_command._run_and_persist", + side_effect=RuntimeError("CONSTRAINT_EVALUATION_CAPACITY"), + ): + result = _evaluate_with_explicit_file( + project_root=str(tmp_path), + constraint_file=str(config), + severity_min="warn", + path_filter="", + output_format="json", + persist=True, + ) + + assert result == { + "success": False, + "verdict": "ERROR", + "error_code": "CONSTRAINT_EVALUATION_CAPACITY", + "error": "CONSTRAINT_EVALUATION_CAPACITY", + "violations": [], + "rule_count": 1, + } diff --git a/tests/unit/mcp/test_index_sync_tools.py b/tests/unit/mcp/test_index_sync_tools.py index 7f6fe3dc1..aa98961ac 100644 --- a/tests/unit/mcp/test_index_sync_tools.py +++ b/tests/unit/mcp/test_index_sync_tools.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """Tests for codegraph_full_index, codegraph_autoindex, and codegraph_incremental_sync MCP tools.""" -import os - import pytest @@ -82,8 +80,9 @@ async def test_execute_incremental(self, project_root): {"mode": "incremental", "max_files": 10, "output_format": "json"} ) assert result["success"] is True - expected_verdict = "WARN" if os.name == "nt" else "INFO" - assert result["verdict"] == expected_verdict + # PR #1254: portable source certification makes ordinary indexing + # authoritative on Windows as well as POSIX. + assert result["verdict"] == "INFO" assert "phases" in result @pytest.mark.asyncio @@ -96,7 +95,9 @@ async def test_execute_full(self, project_root): result = await tool.execute( {"mode": "full", "max_files": 10, "output_format": "json"} ) - assert result["success"] is (os.name != "nt") + # PR #1254: the normal full-index producer now stamps a portable + # manifest on hosts without /dev/fd. + assert result["success"] is True assert "phases" in result assert "elapsed_seconds" in result diff --git a/tests/unit/mcp/tools/_constraint_check_support.py b/tests/unit/mcp/tools/_constraint_check_support.py new file mode 100644 index 000000000..bea066c78 --- /dev/null +++ b/tests/unit/mcp/tools/_constraint_check_support.py @@ -0,0 +1,153 @@ +"""Shared fixtures for constraint-check MCP tool tests.""" + +from __future__ import annotations + +import asyncio +import sqlite3 +import time +from pathlib import Path + +import tree_sitter_analyzer.diff_snapshot_registry as snapshots +from tests.unit._diff_snapshot_support import install_fake_snapshot_materializer + + +def run(coro): + """Drive a coroutine to completion under pytest's per-test event loop.""" + return asyncio.run(coro) + + +def init_violations_db(db_path: Path) -> None: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS ast_constraint_violations ( + rule_id TEXT NOT NULL, caller_file TEXT NOT NULL, + caller_name TEXT NOT NULL, caller_line INTEGER NOT NULL, + callee_name TEXT NOT NULL, callee_file TEXT NOT NULL DEFAULT '', + severity TEXT NOT NULL, detected_at INTEGER NOT NULL, + PRIMARY KEY (rule_id, caller_file, caller_line, callee_name) + ) + """) + conn.commit() + finally: + conn.close() + + +def seed_violation( + db_path: Path, + *, + rule_id: str, + caller_file: str, + callee_file: str, + severity: str, + caller_line: int = 1, + callee_name: str = "callee_fn", + caller_name: str = "caller_fn", +) -> None: + conn = sqlite3.connect(str(db_path)) + try: + conn.execute( + """ + INSERT OR REPLACE INTO ast_constraint_violations + (rule_id, caller_file, caller_name, caller_line, + callee_name, callee_file, severity, detected_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + rule_id, + caller_file, + caller_name, + caller_line, + callee_name, + callee_file, + severity, + int(time.time()), + ), + ) + conn.commit() + finally: + conn.close() + + +def stage_minimal_constraints(project: Path) -> None: + (project / "architectural-constraints.yml").write_text( + """ +version: 1 +constraints: + - id: test-rule + severity: error + rule: forbid + from: "src/a/**" + to: "src/b/**" + reason: "Test fixture rule." +""".lstrip() + ) + + +def make_tool(project_root: Path): + from tree_sitter_analyzer.mcp.tools.constraint_check_tool import ConstraintCheckTool + + tool = ConstraintCheckTool(str(project_root)) + tool.set_project_path(str(project_root)) + return tool + + +def create_frozen_scope( + monkeypatch, project: Path, paths: list[str], *, source_scope=None +): + from contextlib import contextmanager + from types import SimpleNamespace + + import tree_sitter_analyzer.index_snapshot as index_snapshots + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + + source_scope = source_scope or make_source_scope_descriptor() + index_snapshots.REGISTRY.close_all() + install_fake_snapshot_materializer(monkeypatch, project) + registry = snapshots.DiffSnapshotRegistry() + monkeypatch.setattr(snapshots, "REGISTRY", registry) + created = registry.create(str(project), "diff", paths) + assert created["success"] is True + + @contextmanager + def lease(_root): + yield SimpleNamespace( + snapshot_id="is_test", + completeness="complete", + source_generation=created["source_generation"], + reason=None, + canonical_root=str(project.resolve()), + index_fingerprint="sha256:" + "1" * 64, + source_scope=source_scope, + ) + + @contextmanager + def acquire(_snapshot_id, _root, _generation): + conn = sqlite3.connect(project / ".ast-cache" / "index.db") + try: + yield SimpleNamespace(), conn + finally: + conn.close() + + monkeypatch.setattr(index_snapshots, "lease_existing_snapshot", lease) + monkeypatch.setattr(index_snapshots, "acquire_index_snapshot", acquire) + return registry, created + + +def frozen_arguments(created: dict[str, object]) -> dict[str, object]: + return { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + + +def edges_db(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.execute("INSERT INTO edges VALUES ('calls')") + conn.commit() + conn.close() diff --git a/tests/unit/mcp/tools/test_constraint_check_evaluation.py b/tests/unit/mcp/tools/test_constraint_check_evaluation.py new file mode 100644 index 000000000..2ba2aefdd --- /dev/null +++ b/tests/unit/mcp/tools/test_constraint_check_evaluation.py @@ -0,0 +1,450 @@ +"""Focused frozen/read-only exactness tests for constraint checking.""" + +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + +import pytest + +from tests.unit.mcp.tools._constraint_check_support import ( + create_frozen_scope as _create_frozen_scope, +) +from tests.unit.mcp.tools._constraint_check_support import ( + frozen_arguments as _frozen_arguments, +) +from tests.unit.mcp.tools._constraint_check_support import ( + make_tool as _make_tool, +) +from tests.unit.mcp.tools._constraint_check_support import ( + run as _run, +) +from tests.unit.mcp.tools._constraint_check_support import ( + stage_minimal_constraints as _stage_minimal_constraints, +) + +pytest.importorskip("yaml") + + +def test_evaluate_connection_rejects_deadline_before_query( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(time, "monotonic", lambda: 2.0) + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.commit() + try: + with pytest.raises(RuntimeError, match="^INDEX_SNAPSHOT_DEADLINE$"): + _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + min_severity_rank=1, + evaluator=lambda _constraints, _conn: [], + deadline=1.0, + ) + finally: + conn.close() + + +def test_evaluate_connection_rejects_deadline_after_query( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ticks = iter((0.0, 2.0)) + monkeypatch.setattr(time, "monotonic", lambda: next(ticks, 2.0)) + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.commit() + try: + with pytest.raises(RuntimeError, match="^INDEX_SNAPSHOT_DEADLINE$"): + _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + min_severity_rank=1, + evaluator=lambda _constraints, _conn: [], + deadline=1.0, + ) + finally: + conn.close() + + +def test_progress_handler_timeout_rolls_back_and_removes_handler( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ticks = iter((0.0, 0.0, 2.0)) + monkeypatch.setattr(time, "monotonic", lambda: next(ticks, 2.0)) + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.commit() + + def exercise_handler(_constraints, connection): + with pytest.raises(sqlite3.OperationalError, match="interrupted"): + connection.execute( + "WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n) " + "SELECT sum(x) FROM n" + ).fetchone() + raise RuntimeError("evaluation timed out") + + with pytest.raises(RuntimeError, match="^evaluation timed out$"): + _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + min_severity_rank=1, + evaluator=exercise_handler, + deadline=1.0, + ) + + assert conn.in_transaction is False + assert conn.execute("SELECT COUNT(*) FROM edges").fetchone() == (0,) + conn.close() + + +def test_read_only_deadline_interrupts_response_materialization(tmp_path, monkeypatch): + # Final zero gate: Python row assembly belongs to the same absolute deadline. + from tree_sitter_analyzer.constraints.schema import Violation + + calls = {"count": 0} + + def clock(): + calls["count"] += 1 + return 1.0 if calls["count"] < 3 else 3.0 + + monkeypatch.setattr(time, "monotonic", clock) + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges (kind TEXT)") + conn.execute("INSERT INTO edges VALUES ('calls')") + row = Violation("r", "a.py", "a", 1, "b", "b.py", "warn", 0) + with pytest.raises(RuntimeError, match="INDEX_SNAPSHOT_DEADLINE"): + _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + path_filter="", + min_severity_rank=0, + evaluator=lambda *_args, **_kwargs: [row], + deadline=2.0, + ) + conn.close() + + +def test_evaluate_connection_rejects_deadline_after_response_sort( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tree_sitter_analyzer.constraints.schema import Violation + + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges (kind TEXT)") + conn.execute("INSERT INTO edges VALUES ('calls')") + conn.commit() + calls = 0 + + def clock() -> float: + nonlocal calls + calls += 1 + return 0.0 if calls < 4 else 2.0 + + monkeypatch.setattr(time, "monotonic", clock) + violation = Violation("r", "a.py", "a", 1, "b", "b.py", "warn", 0) + try: + with pytest.raises(RuntimeError, match="^INDEX_SNAPSHOT_DEADLINE$"): + _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + min_severity_rank=0, + evaluator=lambda *_args, **_kwargs: [violation], + deadline=1.0, + ) + finally: + conn.close() + + assert calls == 4 + + +def test_staged_zero_rules_final_guard_does_not_probe_worktree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3767273230: staged zero-rule evaluation owns index bytes only. + from dataclasses import replace + + from tree_sitter_analyzer.mcp.tools import constraint_check_frozen + + (tmp_path / "architectural-constraints.yml").write_text( + "version: 1\nconstraints: []\n" + ) + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + state = registry._states[str(created["diff_snapshot_id"])] + state.snapshot = replace(state.snapshot, mode="staged") + monkeypatch.setattr( + constraint_check_frozen.source_oracle, + "safe_workspace_path", + lambda *a, **k: pytest.fail("staged zero-rule guard probed worktree"), + ) + + result = _run(_make_tool(tmp_path).execute(_frozen_arguments(created))) + + assert (result["success"], result["rule_count"], result["verdict"]) == ( + True, + 0, + "SAFE", + ) + + +def test_frozen_directory_scope_matches_descendants_not_sibling_prefix( + tmp_path: Path, +) -> None: + # PR #1254 review 3767373475: frozen directory scope uses path-component prefix. + from tree_sitter_analyzer.constraints import Violation + + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + candidates = [ + Violation("child", "src/pkg/a.py", "a", 1, "b", "out.py", "warn", 1), + Violation("sibling", "src/package/a.py", "a", 2, "b", "out.py", "warn", 1), + ] + try: + rows, _ = _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + min_severity_rank=1, + scope_paths=frozenset({"src/pkg"}), + evaluator=lambda _rules, _conn, **_kwargs: candidates, + ) + finally: + conn.close() + + assert [row["rule_id"] for row in rows] == ["child"] + + +def test_ordinary_read_only_rejects_custom_excluded_source_scope() -> None: + # PR #1254 review 3767507293: ordinary checks require whole-project authority. + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + from tree_sitter_analyzer.mcp.tools.constraint_index_snapshot import ( + ordinary_source_scope_is_full, + ) + + assert ( + ordinary_source_scope_is_full(make_source_scope_descriptor()), + ordinary_source_scope_is_full( + make_source_scope_descriptor(exclude_patterns=("legacy/**",)) + ), + ordinary_source_scope_is_full(make_source_scope_descriptor(roots=("src",))), + ) == (True, False, False) + + +def test_malformed_path_scalar_is_structured_index_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3767568278: path scalar failures cannot escape MCP. + _stage_minimal_constraints(tmp_path) + monkeypatch.setattr( + _make_tool(tmp_path), + "_run_read_only", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AttributeError("caller_file must be text") + ), + ) + tool = _make_tool(tmp_path) + monkeypatch.setattr( + tool, + "_run_read_only", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AttributeError("caller_file must be text") + ), + ) + + result = _run(tool.execute({"persist": False, "output_format": "json"})) + + assert (result["success"], result["verdict"], result["error_code"]) == ( + False, + "ERROR", + "CONSTRAINT_INDEX_UNKNOWN", + ) + assert result["error"] == "caller_file must be text" + + +def test_tool_schema_reexport_preserves_public_api(tmp_path: Path) -> None: + from tree_sitter_analyzer.mcp.tools.constraint_check_schema import ( + TOOL_SCHEMA as extracted_schema, + ) + from tree_sitter_analyzer.mcp.tools.constraint_check_tool import TOOL_SCHEMA + + assert ( + _make_tool(tmp_path).get_tool_schema() is TOOL_SCHEMA, + TOOL_SCHEMA is extracted_schema, + ) == (True, True) + + +def test_constraint_arguments_reject_snapshot_with_default_persistence( + tmp_path: Path, +) -> None: + with pytest.raises(ValueError, match="^diff_snapshot_id requires persist=false$"): + _make_tool(tmp_path).validate_arguments( + {"diff_snapshot_id": "ds_snapshot", "scope_paths": []} + ) + + +def test_evaluate_connection_passes_supported_evaluator_controls( + tmp_path: Path, +) -> None: + from tree_sitter_analyzer.mcp.tools.constraint_check_tool import ( + _MAX_MATERIALIZED_VIOLATIONS, + ) + + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.commit() + received: dict[str, object] = {} + + def controlled_evaluator( + _constraints, + _conn, + *, + check_callback, + capacity, + ): + received.update(check_callback=check_callback, capacity=capacity) + return [] + + try: + rows, edge_count = _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + min_severity_rank=0, + evaluator=controlled_evaluator, + ) + finally: + conn.close() + + assert rows == [] + assert edge_count == 0 + assert callable(received["check_callback"]) + assert received["capacity"] == _MAX_MATERIALIZED_VIOLATIONS + + +def test_evaluate_connection_supports_legacy_evaluator_signature( + tmp_path: Path, +) -> None: + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.commit() + calls: list[tuple[object, sqlite3.Connection]] = [] + + def legacy_evaluator(constraints, connection): + calls.append((constraints, connection)) + return [] + + rules = [object()] + try: + rows, edge_count = _make_tool(tmp_path)._evaluate_connection( + conn, + rules, + min_severity_rank=0, + evaluator=legacy_evaluator, + ) + assert calls == [(rules, conn)] + finally: + conn.close() + + assert rows == [] + assert edge_count == 0 + + +def test_evaluate_connection_bounds_custom_evaluator_response( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tree_sitter_analyzer.constraints.schema import Violation + from tree_sitter_analyzer.mcp.tools import constraint_check_tool + + monkeypatch.setattr(constraint_check_tool, "_MAX_MATERIALIZED_VIOLATIONS", 1) + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.commit() + violations = [ + Violation("r1", "a.py", "a", 1, "b", "b.py", "warn", 0), + Violation("r2", "c.py", "c", 2, "d", "d.py", "warn", 0), + ] + + try: + with pytest.raises(RuntimeError, match="^CONSTRAINT_EVALUATION_CAPACITY$"): + _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + min_severity_rank=0, + evaluator=lambda _constraints, _conn: violations, + ) + finally: + conn.close() + + +def test_root_directory_scope_contains_all_relative_paths(): + from tree_sitter_analyzer.mcp.tools.constraint_check_tool import _path_is_in_scope + + assert _path_is_in_scope("src/a.py", frozenset({"."})) is True + assert _path_is_in_scope("src/a.py", frozenset({""})) is True + + +def test_defensive_scope_filter_uses_raw_reserved_prefix_path(tmp_path: Path) -> None: + # PR #1254 review 3768614243: wire normalization must occur exactly once. + from tree_sitter_analyzer.constraints.schema import Violation + from tree_sitter_analyzer.git_path_codec import path_to_wire + + raw_path = "git-path-b64:literal.py" + violation = Violation( + "reserved", raw_path, "caller", 1, "callee", "outside.py", "warn", 0 + ) + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + try: + rows, edge_count = _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + min_severity_rank=0, + scope_paths=frozenset({path_to_wire(raw_path)}), + evaluator=lambda _constraints, _conn, **_kwargs: [violation], + ) + finally: + conn.close() + + assert edge_count == 0 + assert [row["rule_id"] for row in rows] == ["reserved"] + assert rows[0]["caller_file"] == path_to_wire(raw_path) + + +def test_read_only_config_filesystem_failure_is_structured(tmp_path, monkeypatch): + import tree_sitter_analyzer.mcp.tools.constraint_check_tool as owner + + monkeypatch.setattr( + owner, + "load_live_constraints", + lambda *_args: (_ for _ in ()).throw(OSError("config unreadable")), + ) + result = _run(_make_tool(tmp_path).execute({"persist": False})) + assert ( + result["success"], + result["verdict"], + result["error_code"], + result["error"], + ) == ( + False, + "ERROR", + "CONSTRAINT_CONFIG_UNKNOWN", + "config unreadable", + ) + + +def test_persistent_generic_evaluator_failure_preserves_legacy_degradation( + tmp_path, monkeypatch +): + import tree_sitter_analyzer.mcp.tools.constraint_check_tool as owner + + db_path = tmp_path / "index.db" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.execute("INSERT INTO edges VALUES ('calls')") + conn.commit() + conn.close() + monkeypatch.setattr( + owner, + "evaluate", + lambda *_args: (_ for _ in ()).throw(ValueError("bad evaluator")), + ) + assert _make_tool(tmp_path)._run_and_persist(db_path, [object()]) == ([], 1) diff --git a/tests/unit/mcp/tools/test_constraint_check_frozen.py b/tests/unit/mcp/tools/test_constraint_check_frozen.py new file mode 100644 index 000000000..f0feaf721 --- /dev/null +++ b/tests/unit/mcp/tools/test_constraint_check_frozen.py @@ -0,0 +1,443 @@ +"""Focused frozen/read-only exactness tests for constraint checking.""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import replace +from pathlib import Path + +import pytest + +import tree_sitter_analyzer.diff_snapshot_registry as snapshots +from tests.unit.mcp.tools._constraint_check_support import ( + create_frozen_scope as _create_frozen_scope, +) +from tests.unit.mcp.tools._constraint_check_support import ( + init_violations_db as _init_violations_db, +) +from tests.unit.mcp.tools._constraint_check_support import ( + make_tool as _make_tool, +) +from tests.unit.mcp.tools._constraint_check_support import ( + run as _run, +) +from tests.unit.mcp.tools._constraint_check_support import ( + stage_minimal_constraints as _stage_minimal_constraints, +) +from tree_sitter_analyzer.constraints import Violation + +pytest.importorskip("yaml") + +# --------------------------------------------------------------------------- +# RFC-0022 P0.3 read-only frozen-snapshot contract +# --------------------------------------------------------------------------- + + +def test_constraint_annotation_discloses_legacy_write_side_effect() -> None: + definition = _make_tool(Path(".")).get_tool_definition() + assert definition["annotations"] == { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + } + + +def test_persist_false_performs_zero_project_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_minimal_constraints(tmp_path) + db_path = tmp_path / ".ast-cache" / "index.db" + _init_violations_db(db_path) + before = { + path.relative_to(tmp_path): path.read_bytes() + for path in tmp_path.rglob("*") + if path.is_file() + } + tool = _make_tool(tmp_path) + monkeypatch.setattr(tool, "_run_read_only", lambda *a, **k: ([], 0)) + monkeypatch.setattr( + tool, + "_run_and_persist", + lambda *a, **k: pytest.fail("persist=false entered the write-through path"), + ) + + result = _run(tool.execute({"persist": False, "output_format": "json"})) + + after = { + path.relative_to(tmp_path): path.read_bytes() + for path in tmp_path.rglob("*") + if path.is_file() + } + assert result["verdict"] == "SAFE" + assert after == before + + +def test_frozen_scope_intersection_excludes_outside_project_debt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_minimal_constraints(tmp_path) + db_path = tmp_path / ".ast-cache" / "index.db" + _init_violations_db(db_path) + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.commit() + conn.close() + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/scope.py"]) + + def violation(rule_id: str, caller: str, callee: str, severity: str, line: int): + return Violation(rule_id, caller, "caller", line, "callee", callee, severity, 1) + + violations = [ + violation("caller-in-scope", "src/scope.py", "vendor/a.py", "warn", 10), + violation("callee-in-scope", "vendor/b.py", "src/scope.py", "warn", 20), + violation("outside-debt", "legacy/a.py", "legacy/b.py", "error", 30), + ] + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.evaluate", + lambda constraints, conn, **_kwargs: violations, + ) + + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + + assert result["verdict"] == "CAUTION" + assert [row["rule_id"] for row in result["violations"]] == [ + "caller-in-scope", + "callee-in-scope", + ] + assert result["diff_snapshot_id"] == created["diff_snapshot_id"] + assert result["source_generation"] == created["source_generation"] + assert result["assessed_scope_paths"] == ["src/scope.py"] + assert ( + registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + is True + ) + + +def test_frozen_constraints_reject_scope_not_exactly_owned_by_snapshot( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": ["src/a.py", "outside.py"], + "output_format": "json", + } + ) + ) + + assert result["error_code"] == "DIFF_SNAPSHOT_SCOPE_MISMATCH" + assert ( + registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + is True + ) + + +def test_frozen_constraints_reject_closed_lease( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + assert ( + registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + is True + ) + + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + + assert result["error_code"] == "DIFF_SNAPSHOT_EXPIRED" + + +def test_frozen_constraints_without_config_is_not_applicable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + + assert result["state"] == "not_applicable" + assert result["reason"] == "NO_CONFIG" + assert result["violations"] == [] + assert result["diff_snapshot_id"] == created["diff_snapshot_id"] + assert result["source_generation"] == created["source_generation"] + assert ( + registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + is True + ) + + +def test_frozen_constraints_reject_generation_changed_after_capture( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + monkeypatch.setattr( + snapshots, + "oracle_generation", + lambda *args, **kwargs: ( + "sg_changed", + snapshots.RootIdentity(str(tmp_path.resolve()), 1, 2), + ), + ) + + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + + assert result["error_code"] == "DIFF_SNAPSHOT_SOURCE_CHANGED" + assert ( + registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + is True + ) + + +def test_frozen_constraints_missing_edges_schema_is_unknown( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_minimal_constraints(tmp_path) + db_path = tmp_path / ".ast-cache" / "index.db" + _init_violations_db(db_path) + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + assert (result["success"], result["error_code"]) == ( + False, + "CONSTRAINT_INDEX_UNKNOWN", + ) + assert ( + registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + is True + ) + + +def test_frozen_constraints_rejects_config_changed_during_index_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_minimal_constraints(tmp_path) + db_path = tmp_path / ".ast-cache" / "index.db" + _init_violations_db(db_path) + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.commit() + conn.close() + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + import tree_sitter_analyzer.source_oracle as oracle + + real_safe = oracle.safe_workspace_path + config_reads = 0 + + def changed_config(*args, **kwargs): + nonlocal config_reads + result = real_safe(*args, **kwargs) + if args[1] == "architectural-constraints.yml": + config_reads += 1 + if config_reads == 1: + return replace(result, data=(result.data or b"") + b"\n# changed") + return result + + monkeypatch.setattr(oracle, "safe_workspace_path", changed_config) + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.evaluate", + lambda constraints, conn, **_kwargs: [], + ) + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + } + ) + ) + assert result["error_code"] == "CONSTRAINT_CONFIG_CHANGED" + assert ( + registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + is True + ) + + +def test_config_only_frozen_change_evaluates_full_source_scope( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3768452296: tightening rules governs existing source too. + _stage_minimal_constraints(tmp_path) + db_path = tmp_path / ".ast-cache" / "index.db" + _init_violations_db(db_path) + with sqlite3.connect(db_path) as conn: + conn.execute("CREATE TABLE edges(kind TEXT)") + registry, created = _create_frozen_scope( + monkeypatch, tmp_path, ["architectural-constraints.yml"] + ) + observed: list[object] = [] + + def evaluator(_constraints, _conn, **kwargs): + observed.append(kwargs.get("scope_predicate", "absent")) + return [] + + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.evaluate", evaluator + ) + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + + assert result["verdict"] == "SAFE" + assert created["assessed_scope_paths"] == ["architectural-constraints.yml"] + assert observed == ["absent"] + assert ( + registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + is True + ) + + +def test_frozen_constraint_consumer_fails_closed_on_unsafe_config_evidence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3769193867: config failure belongs to this consumer. + from tests.unit._diff_snapshot_support import install_fake_snapshot_materializer + from tree_sitter_analyzer.source_oracle import SafePath + + install_fake_snapshot_materializer(monkeypatch, tmp_path) + monkeypatch.setattr( + snapshots, + "safe_workspace_path", + lambda *_args, **_kwargs: SafePath(None, (), "symlink"), + ) + registry = snapshots.DiffSnapshotRegistry() + monkeypatch.setattr(snapshots, "REGISTRY", registry) + created = registry.create(str(tmp_path), "diff", []) + + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + + assert created["success"] is True + assert result["error_code"] == "CONSTRAINT_CONFIG_UNSAFE" + + +def test_renamed_primary_config_activates_fallback_over_full_graph( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3772454791: renamed-away config changes precedence. + _stage_minimal_constraints(tmp_path) + fallback = tmp_path / ".tree-sitter-analyzer/constraints.yml" + fallback.parent.mkdir() + (tmp_path / "architectural-constraints.yml").replace(fallback) + db_path = tmp_path / ".ast-cache/index.db" + _init_violations_db(db_path) + with sqlite3.connect(db_path) as conn: + conn.execute("CREATE TABLE edges(kind TEXT)") + registry, created = _create_frozen_scope( + monkeypatch, tmp_path, ["architectural-constraints.yml", "renamed.yml"] + ) + state = registry._states[str(created["diff_snapshot_id"])] + state.snapshot = replace(state.snapshot, mode="staged") + observed = [] + + def evaluator(_constraints, _conn, **kwargs): + observed.append(kwargs.get("scope_predicate", "absent")) + return [ + Violation( + "fallback-block", + "src/a/x.py", + "caller", + 7, + "callee", + "src/b/y.py", + "error", + 1, + ) + ] + + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.evaluate", evaluator + ) + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + + assert ( + state.snapshot.constraint_config_path == ".tree-sitter-analyzer/constraints.yml" + ) + actual = ( + result["verdict"], + [row["rule_id"] for row in result["violations"]], + observed, + result["assessed_scope_paths"], + ) + assert actual == ( + "UNSAFE", + ["fallback-block"], + ["absent"], + ["architectural-constraints.yml", "renamed.yml"], + ) + assert ( + registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + is True + ) diff --git a/tests/unit/mcp/tools/test_constraint_check_live.py b/tests/unit/mcp/tools/test_constraint_check_live.py new file mode 100644 index 000000000..b3ea8e2bf --- /dev/null +++ b/tests/unit/mcp/tools/test_constraint_check_live.py @@ -0,0 +1,387 @@ +"""Exact behavior tests for live constraint configuration reads.""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +import tree_sitter_analyzer.mcp.tools.constraint_check_live as live +from tree_sitter_analyzer.source_oracle import SafePath, SourceOracleError + + +class _ModuleProxy: + def __init__(self, module: ModuleType, **overrides: Any) -> None: + self._module = module + self._overrides = overrides + + def __getattr__(self, name: str) -> Any: + if name in self._overrides: + return self._overrides[name] + return getattr(self._module, name) + + +def _resized(info: os.stat_result) -> os.stat_result: + values = list(info) + values[stat.ST_SIZE] = int(info.st_size) + 1 + return os.stat_result(values) + + +def test_portable_probe_reads_nested_regular_config(tmp_path: Path) -> None: + parent = tmp_path / ".tree-sitter-analyzer" + parent.mkdir() + config = parent / "constraints.yml" + payload = b"version: 1\nconstraints: []\n" + config.write_bytes(payload) + + result = live._portable_probe( + str(tmp_path), ".tree-sitter-analyzer/constraints.yml", float("inf") + ) + + assert result == ( + payload, + ( + live._identity(os.lstat(tmp_path)), + live._identity(os.lstat(parent)), + live._identity(os.lstat(config)), + ), + "file", + ) + + +def test_portable_probe_reports_missing_project_root(tmp_path: Path) -> None: + result = live._portable_probe( + str(tmp_path / "absent"), + ".tree-sitter-analyzer/constraints.yml", + float("inf"), + ) + + assert result == (None, (b"missing",), "missing") + + +def test_portable_probe_rejects_non_directory_project_root(tmp_path: Path) -> None: + project = tmp_path / "project" + project.write_text("not a directory") + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_UNSAFE$"): + live._portable_probe( + str(project), ".tree-sitter-analyzer/constraints.yml", float("inf") + ) + + +def test_portable_probe_reports_missing_nested_parent(tmp_path: Path) -> None: + result = live._portable_probe( + str(tmp_path), ".tree-sitter-analyzer/constraints.yml", float("inf") + ) + + assert result == ( + None, + (live._identity(os.lstat(tmp_path)), b"missing"), + "missing", + ) + + +def test_portable_probe_rejects_non_directory_parent(tmp_path: Path) -> None: + (tmp_path / ".tree-sitter-analyzer").write_text("not a directory") + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_UNSAFE$"): + live._portable_probe( + str(tmp_path), ".tree-sitter-analyzer/constraints.yml", float("inf") + ) + + +def test_portable_probe_reports_missing_leaf(tmp_path: Path) -> None: + result = live._portable_probe( + str(tmp_path), "architectural-constraints.yml", float("inf") + ) + + assert result == ( + None, + (live._identity(os.lstat(tmp_path)), b"missing"), + "missing", + ) + + +def test_portable_probe_rejects_reparse_leaf( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config = tmp_path / "architectural-constraints.yml" + config.write_text("version: 1\nconstraints: []\n") + leaf_identity = (config.stat().st_dev, config.stat().st_ino) + monkeypatch.setattr( + live, + "_is_reparse", + lambda info: (info.st_dev, info.st_ino) == leaf_identity, + ) + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_UNSAFE$"): + live._portable_probe( + str(tmp_path), "architectural-constraints.yml", float("inf") + ) + + +def test_portable_probe_classifies_directory_leaf(tmp_path: Path) -> None: + config = tmp_path / "architectural-constraints.yml" + config.mkdir() + + result = live._portable_probe( + str(tmp_path), "architectural-constraints.yml", float("inf") + ) + + assert result == ( + None, + ( + live._identity(os.lstat(tmp_path)), + live._identity(os.lstat(config)), + ), + "directory", + ) + + +def test_portable_probe_rejects_nonregular_leaf( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "architectural-constraints.yml").write_text("content") + monkeypatch.setattr( + live, + "stat", + _ModuleProxy(stat, S_ISREG=lambda _mode: False), + ) + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_UNSAFE$"): + live._portable_probe( + str(tmp_path), "architectural-constraints.yml", float("inf") + ) + + +def test_portable_probe_rejects_opened_identity_mismatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "architectural-constraints.yml").write_text("content") + + def changed_fstat(fd: int) -> os.stat_result: + return _resized(os.fstat(fd)) + + monkeypatch.setattr(live, "os", _ModuleProxy(os, fstat=changed_fstat)) + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_CHANGED$"): + live._portable_probe( + str(tmp_path), "architectural-constraints.yml", float("inf") + ) + + +def test_portable_probe_enforces_read_deadline(tmp_path: Path) -> None: + (tmp_path / "architectural-constraints.yml").write_text("content") + + with pytest.raises(RuntimeError, match="^CONSTRAINT_CONFIG_DEADLINE$"): + live._portable_probe(str(tmp_path), "architectural-constraints.yml", 0.0) + + +def test_portable_probe_enforces_config_byte_capacity(tmp_path: Path) -> None: + (tmp_path / "architectural-constraints.yml").write_bytes(b"x" * (1024 * 1024 + 1)) + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_CAPACITY$"): + live._portable_probe( + str(tmp_path), "architectural-constraints.yml", float("inf") + ) + + +def test_portable_probe_rejects_descriptor_change_after_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "architectural-constraints.yml").write_text("content") + calls = 0 + + def changing_fstat(fd: int) -> os.stat_result: + nonlocal calls + calls += 1 + info = os.fstat(fd) + return _resized(info) if calls == 2 else info + + monkeypatch.setattr(live, "os", _ModuleProxy(os, fstat=changing_fstat)) + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_CHANGED$"): + live._portable_probe( + str(tmp_path), "architectural-constraints.yml", float("inf") + ) + + assert calls == 2 + + +def test_portable_probe_rejects_leaf_change_after_close( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config = tmp_path / "architectural-constraints.yml" + config.write_text("content") + leaf_reads = 0 + + def changing_lstat(path: os.PathLike[str] | str) -> os.stat_result: + nonlocal leaf_reads + info = os.lstat(path) + if Path(path) == config: + leaf_reads += 1 + return _resized(info) if leaf_reads == 2 else info + return info + + monkeypatch.setattr(live, "os", _ModuleProxy(os, lstat=changing_lstat)) + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_CHANGED$"): + live._portable_probe(str(tmp_path), config.name, float("inf")) + + assert leaf_reads == 2 + + +def test_portable_probe_rejects_ancestor_change_after_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + parent = tmp_path / ".tree-sitter-analyzer" + parent.mkdir() + (parent / "constraints.yml").write_text("content") + root_reads = 0 + + def changing_lstat(path: os.PathLike[str] | str) -> os.stat_result: + nonlocal root_reads + info = os.lstat(path) + if Path(path) == tmp_path: + root_reads += 1 + return _resized(info) if root_reads == 2 else info + return info + + monkeypatch.setattr(live, "os", _ModuleProxy(os, lstat=changing_lstat)) + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_CHANGED$"): + live._portable_probe( + str(tmp_path), ".tree-sitter-analyzer/constraints.yml", float("inf") + ) + + assert root_reads == 2 + + +def test_portable_probe_normalizes_operating_system_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def denied_lstat(_path: os.PathLike[str] | str) -> os.stat_result: + raise PermissionError("denied") + + monkeypatch.setattr(live, "os", _ModuleProxy(os, lstat=denied_lstat)) + + with pytest.raises(SourceOracleError) as caught: + live._portable_probe( + str(tmp_path), "architectural-constraints.yml", float("inf") + ) + + assert ( + str(caught.value), + type(caught.value.__cause__), + str(caught.value.__cause__), + ) == ("CONSTRAINT_CONFIG_UNSAFE", PermissionError, "denied") + + +def test_live_config_snapshot_portable_falls_back_to_nested_candidate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + parent = tmp_path / ".tree-sitter-analyzer" + parent.mkdir() + config = parent / "constraints.yml" + payload = b"version: 1\nconstraints: []\n" + config.write_bytes(payload) + monkeypatch.setattr(live, "_portable_config_required", lambda: True) + + result = live.live_config_snapshot(str(tmp_path), float("inf")) + + assert result == ( + ".tree-sitter-analyzer/constraints.yml", + payload, + ( + live._identity(os.lstat(tmp_path)), + live._identity(os.lstat(parent)), + live._identity(os.lstat(config)), + ), + ) + + +def test_live_config_snapshot_portable_reports_absent_configuration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(live, "_portable_config_required", lambda: True) + + result = live.live_config_snapshot(str(tmp_path), float("inf")) + + assert result == (None, None, ()) + + +def test_live_config_snapshot_posix_reports_absent_configuration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[tuple[str, str, float, int, bool]] = [] + + def missing( + root: str, + candidate: str, + *, + deadline: float, + limit: int, + allow_directory: bool, + ) -> SafePath: + calls.append((root, candidate, deadline, limit, allow_directory)) + return SafePath(None, (b"missing",), "missing") + + monkeypatch.setattr(live, "_portable_config_required", lambda: False) + monkeypatch.setattr(live, "safe_workspace_path", missing) + + result = live.live_config_snapshot(str(tmp_path), 9.0) + + assert result == (None, None, ()) + assert calls == [ + (str(tmp_path), "architectural-constraints.yml", 9.0, 1024 * 1024, True), + ( + str(tmp_path), + ".tree-sitter-analyzer/constraints.yml", + 9.0, + 1024 * 1024, + True, + ), + ] + + +def test_live_config_snapshot_posix_rejects_ambiguous_probe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(live, "_portable_config_required", lambda: False) + monkeypatch.setattr( + live, + "safe_workspace_path", + lambda *_args, **_kwargs: SafePath(b"payload", (b"identity",), "special"), + ) + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_UNSAFE$"): + live.live_config_snapshot(str(tmp_path), float("inf")) + + +def test_config_changed_response_maps_snapshot_error() -> None: + before = ("architectural-constraints.yml", b"content", (b"identity",)) + + def unavailable(_root: str, _deadline: float): + raise OSError("snapshot unavailable") + + def error_response(code: str, output_format: str, detail: str | None): + return {"code": code, "format": output_format, "detail": detail} + + result = live.config_changed_response( + "/project", + before, + 7.0, + "json", + error_response, + snapshot=unavailable, + ) + + assert result == { + "code": "CONSTRAINT_CONFIG_UNKNOWN", + "format": "json", + "detail": "snapshot unavailable", + } diff --git a/tests/unit/mcp/tools/test_constraint_check_portable_snapshot.py b/tests/unit/mcp/tools/test_constraint_check_portable_snapshot.py new file mode 100644 index 000000000..3c8211be4 --- /dev/null +++ b/tests/unit/mcp/tools/test_constraint_check_portable_snapshot.py @@ -0,0 +1,493 @@ +"""Portable snapshot exactness coverage for constraint checking.""" + +from __future__ import annotations + +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path + +import pytest + +from tests.unit.mcp.tools._constraint_check_support import ( + make_tool as _make_tool, +) +from tests.unit.mcp.tools._constraint_check_support import ( + run as _run, +) +from tests.unit.mcp.tools._constraint_check_support import ( + stage_minimal_constraints as _stage_minimal_constraints, +) + +pytest.importorskip("yaml") + + +def test_portable_snapshot_rejects_symlinked_database( + tmp_path: Path, +) -> None: + # PR #1254 review 3767273223: pathname fallback must never follow links. + from tree_sitter_analyzer.mcp.tools.constraint_index_snapshot import ( + portable_ordinary_snapshot, + ) + + cache = tmp_path / ".ast-cache" + cache.mkdir() + real = tmp_path / "real.db" + sqlite3.connect(real).close() + (cache / "index.db").symlink_to(real) + + with pytest.raises(ValueError, match="^INDEX_PATH_SYMLINK$"): + with portable_ordinary_snapshot(str(tmp_path), deadline=time.monotonic() + 1.0): + pytest.fail("symlink snapshot published") + + +def test_portable_snapshot_rejects_nonempty_writer_sidecar( + tmp_path: Path, +) -> None: + # PR #1254 review 3767273223: WAL bytes make a pathname copy ambiguous. + from tree_sitter_analyzer.mcp.tools.constraint_index_snapshot import ( + portable_ordinary_snapshot, + ) + + cache = tmp_path / ".ast-cache" + cache.mkdir() + sqlite3.connect(cache / "index.db").close() + (cache / "index.db-wal").write_bytes(b"writer") + + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + with portable_ordinary_snapshot(str(tmp_path), deadline=time.monotonic() + 1.0): + pytest.fail("sidecar snapshot published") + + +def test_portable_snapshot_rejects_pathname_swap_before_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 P1: an opened fd must match the exact pre-open lstat identity. + import os + + import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner + + cache = tmp_path / ".ast-cache" + cache.mkdir() + database = cache / "index.db" + replacement = cache / "replacement.db" + sqlite3.connect(database).close() + sqlite3.connect(replacement).close() + real_open = owner._open + swapped = False + + def swap_then_open(path, flags): + nonlocal swapped + if not swapped and Path(path) == database: + swapped = True + os.replace(replacement, database) + return real_open(path, flags) + + monkeypatch.setattr(owner, "_open", swap_then_open) + + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1.0 + ): + pytest.fail("mismatched opened descriptor published") + + +@pytest.mark.parametrize("failure", [FileNotFoundError, OSError]) +def test_portable_missing_cache_is_structured_index_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: type[OSError], +) -> None: + # Windows CI incident 2026-07-01: portable acquisition may see a missing cache. + import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner + + _stage_minimal_constraints(tmp_path) + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: True) + monkeypatch.setattr( + owner, + "_identity", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + failure("portable cache unavailable") + ), + ) + + result = _run( + _make_tool(tmp_path).execute({"persist": False, "output_format": "json"}) + ) + + assert result == { + "success": False, + "verdict": "ERROR", + "error_code": "CONSTRAINT_INDEX_UNKNOWN", + "error": "portable cache unavailable", + } + + +def test_portable_corrupt_copy_closes_connections_before_temporary_cleanup( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Windows CI incident 2026-07-01: open SQLite handles block temp unlink. + import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner + + cache = tmp_path / ".ast-cache" + cache.mkdir() + database = cache / "index.db" + sqlite3.connect(database).close() + events: list[str] = [] + + class Source: + def execute(self, _sql): + return self + + def fetchone(self): + return (4096,) + + def backup(self, *_args, **_kwargs): + raise sqlite3.DatabaseError("corrupt private copy") + + def close(self): + events.append("source.close") + + class Private: + def execute(self, _sql): + return self + + def fetchone(self): + return (2,) + + def close(self): + events.append("private.close") + + connections = iter((Source(), Private())) + + @contextmanager + def locked_temporary_copy(_fd, _expected, _root, *, deadline): + assert deadline > time.monotonic() + try: + yield tmp_path / "private-index.db" + finally: + events.append("temporary.exit") + if events[:2] != ["source.close", "private.close"]: + raise PermissionError("temporary copy is still locked") + + monkeypatch.setattr(owner, "_temporary_copy", locked_temporary_copy) + monkeypatch.setattr( + owner.sqlite3, "connect", lambda *_args, **_kwargs: next(connections) + ) + monkeypatch.setattr(owner, "require_memory_temp_store", lambda _conn: None) + + with pytest.raises(sqlite3.DatabaseError, match="^corrupt private copy$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1.0 + ): + pytest.fail("corrupt snapshot published") + + assert events == ["source.close", "private.close", "temporary.exit"] + + +def test_constraint_source_capture_selects_portable_certifier( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # PR #1254 review 3768096778: Windows must not reuse the POSIX-only oracle. + from types import SimpleNamespace + + import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner + + expected = SimpleNamespace(state="exact") + calls: list[tuple[str, object, float]] = [] + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: True) + monkeypatch.setattr( + owner, + "capture_current_source_snapshot", + lambda *_args, **_kwargs: pytest.fail("selected POSIX source certifier"), + ) + monkeypatch.setattr( + owner, + "capture_portable_source_snapshot", + lambda root, scope, *, deadline: ( + calls.append((root, scope, deadline)) or expected + ), + ) + scope = object() + + result = owner._capture_constraint_sources("C:/project", scope, 9.0) + + assert result is expected + assert calls == [("C:/project", scope, 9.0)] + + +def test_portable_source_certifier_hashes_stable_supported_scope( + tmp_path: Path, +) -> None: + # PR #1254 review 3768096778: portable capture can certify real source bytes. + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + from tree_sitter_analyzer.portable_source_snapshot import ( + capture_portable_source_snapshot, + ) + + source = tmp_path / "pkg" / "sample.py" + source.parent.mkdir() + source.write_bytes(b"value = 1\r\n") + (tmp_path / ".module.py").write_text("hidden = True\n") + + result = capture_portable_source_snapshot( + str(tmp_path), + make_source_scope_descriptor(), + deadline=time.monotonic() + 5.0, + ) + + import hashlib + + from tree_sitter_analyzer.index_source_snapshot import inventory_fingerprint + + digest = hashlib.sha256(b"value = 1\n").hexdigest() + hidden_digest = hashlib.sha256(b"hidden = True\n").hexdigest() + expected_rows = frozenset( + { + ("pkg/sample.py", digest, "python"), + (".module.py", hidden_digest, "python"), + } + ) + expected_fingerprint = inventory_fingerprint(expected_rows) + assert (result.state, result.reason, result.rows) == ( + "exact", + None, + expected_rows, + ) + assert result.fingerprint == expected_fingerprint + assert result.generation == "idxsrc-v3:" + expected_fingerprint.removeprefix( + "sha256:" + ) + + +def test_private_copy_rejects_source_change_during_certification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # PR #1254 review 3768452289: final source evidence must equal initial evidence. + from types import SimpleNamespace + + import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner + from tests.unit.mcp.tools.test_constraint_index_snapshot import ( + _certification_dependencies, + ) + + manifest = { + "source_scope_descriptor": "scope", + "canonical_root": "/project", + "source_fingerprint": "source", + "index_fingerprint": "index", + "file_count": 1, + "manifest_version": 2, + } + _certification_dependencies(monkeypatch, manifest=manifest) + snapshots = iter( + ( + SimpleNamespace( + state="exact", + reason=None, + rows=(("a.py", "hash"),), + fingerprint="source", + ), + SimpleNamespace( + state="exact", + reason=None, + rows=(("a.py", "changed"),), + fingerprint="changed", + ), + ) + ) + monkeypatch.setattr( + owner, "_capture_constraint_sources", lambda *_args: next(snapshots) + ) + conn = sqlite3.connect(":memory:") + try: + with pytest.raises(ValueError, match="^CONCURRENT_SOURCE$"): + owner._certify_private_copy( + conn, "/project", deadline=time.monotonic() + 1.0 + ) + finally: + conn.close() + + +def test_live_config_snapshot_uses_portable_reader_when_required( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3768708964: Windows guards the same bytes it evaluates. + import tree_sitter_analyzer.mcp.tools.constraint_check_live as live + + config = tmp_path / "architectural-constraints.yml" + config.write_bytes(b"version: 1\nconstraints: []\n") + monkeypatch.setattr(live, "_portable_config_required", lambda: True) + monkeypatch.setattr( + live, + "safe_workspace_path", + lambda *_args, **_kwargs: pytest.fail("selected POSIX config reader"), + ) + + result = live.live_config_snapshot(str(tmp_path), time.monotonic() + 1.0) + + assert result[0] == "architectural-constraints.yml" + assert result[1] == b"version: 1\nconstraints: []\n" + assert len(result[2]) == 2 + + +def test_portable_live_config_detects_changed_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3768708964: final portable identity changes fail closed. + import tree_sitter_analyzer.mcp.tools.constraint_check_live as live + + config = tmp_path / "architectural-constraints.yml" + config.write_bytes(b"version: 1\nconstraints: []\n") + before = live._portable_probe( + str(tmp_path), "architectural-constraints.yml", time.monotonic() + 1.0 + ) + config.write_bytes(b"version: 1\nconstraints: [changed]\n") + after = live._portable_probe( + str(tmp_path), "architectural-constraints.yml", time.monotonic() + 1.0 + ) + + assert before != after + assert before[0] == b"version: 1\nconstraints: []\n" + assert after[0] == b"version: 1\nconstraints: [changed]\n" + + +def test_live_constraint_loader_parses_the_captured_bytes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3768708964: rules and publish evidence share one byte read. + import tree_sitter_analyzer.mcp.tools.constraint_check_live as live + + config = tmp_path / "architectural-constraints.yml" + empty = b"version: 1\nconstraints: []\n" + config.write_bytes(empty) + captured = ("architectural-constraints.yml", empty, (b"identity",)) + + def snapshot(_root: str, _deadline: float): + config.write_text( + "version: 1\nconstraints:\n" + " - {id: later, severity: error, rule: forbid, from: 'a/**', " + "to: 'b/**', reason: later}\n" + ) + return captured + + monkeypatch.setattr(live, "live_config_snapshot", snapshot) + observed, constraints = live.load_live_constraints( + str(tmp_path), time.monotonic() + 1.0 + ) + + assert observed == captured + assert constraints == [] + + +def test_private_copy_reports_final_source_unknown(monkeypatch): + from types import SimpleNamespace + + import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner + from tests.unit.mcp.tools.test_constraint_index_snapshot import ( + _certification_dependencies, + ) + + manifest = { + "source_scope_descriptor": "scope", + "canonical_root": "/project", + "source_fingerprint": "source", + "index_fingerprint": "index", + "file_count": 1, + "manifest_version": 2, + } + _certification_dependencies(monkeypatch, manifest=manifest) + snapshots = iter( + ( + SimpleNamespace( + state="exact", + reason=None, + rows=(("a.py", "hash"),), + fingerprint="source", + ), + SimpleNamespace( + state="unknown", + reason="SOURCE_SCOPE_UNREADABLE", + rows=(), + fingerprint=None, + ), + ) + ) + monkeypatch.setattr( + owner, "_capture_constraint_sources", lambda *_args: next(snapshots) + ) + conn = sqlite3.connect(":memory:") + try: + result = owner._certify_private_copy( + conn, "/project", deadline=time.monotonic() + 1 + ) + finally: + conn.close() + assert (result.completeness, result.reason) == ( + "partial", + "SOURCE_SCOPE_UNREADABLE", + ) + + +def test_ordinary_read_only_revalidates_sources_after_evaluation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3769193817: graph evaluation cannot outlive source evidence. + from contextlib import contextmanager + from types import SimpleNamespace + + import tree_sitter_analyzer.index_snapshot as index_snapshots + import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner + + scope = SimpleNamespace(roots=(".",), exclude_patterns=()) + deadlines: list[float | None] = [] + + @contextmanager + def lease(_root, *, deadline=None): + deadlines.append(deadline) + yield SimpleNamespace( + snapshot_id="is_source_guard", + completeness="complete", + source_generation="idxsrc-v3:before", + source_fingerprint="sha256:before", + source_scope=scope, + canonical_root=str(tmp_path.resolve()), + reason=None, + ) + + @contextmanager + def acquire(_snapshot_id, _root, _generation, *, deadline=None): + deadlines.append(deadline) + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + try: + yield SimpleNamespace(), conn + finally: + conn.close() + + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: False) + monkeypatch.setattr(index_snapshots, "lease_existing_snapshot", lease) + monkeypatch.setattr(index_snapshots, "acquire_index_snapshot", acquire) + monkeypatch.setattr(owner, "ordinary_source_scope_is_full", lambda _scope: True) + monkeypatch.setattr( + owner, + "_capture_constraint_sources", + lambda *_args: SimpleNamespace( + state="exact", + reason=None, + generation="idxsrc-v3:after", + fingerprint="sha256:after", + ), + ) + + with pytest.raises(ValueError, match="^SOURCE_GENERATION_MISMATCH$"): + _make_tool(tmp_path)._run_read_only( + tmp_path / "ignored.db", + [object()], + path_filter="", + min_severity_rank=1, + evaluator=lambda _constraints, _conn: [], + ) + assert len(deadlines) == 2 + assert deadlines[0] == deadlines[1] + assert isinstance(deadlines[0], float) diff --git a/tests/unit/mcp/tools/test_constraint_check_read_only.py b/tests/unit/mcp/tools/test_constraint_check_read_only.py new file mode 100644 index 000000000..fb258a544 --- /dev/null +++ b/tests/unit/mcp/tools/test_constraint_check_read_only.py @@ -0,0 +1,481 @@ +"""Focused frozen/read-only exactness tests for constraint checking.""" + +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import tree_sitter_analyzer.diff_snapshot_registry as snapshots +from tests.unit.mcp.tools._constraint_check_support import ( + create_frozen_scope as _create_frozen_scope, +) +from tests.unit.mcp.tools._constraint_check_support import ( + edges_db as _edges_db, +) +from tests.unit.mcp.tools._constraint_check_support import ( + frozen_arguments as _frozen_arguments, +) +from tests.unit.mcp.tools._constraint_check_support import ( + make_tool as _make_tool, +) +from tests.unit.mcp.tools._constraint_check_support import ( + run as _run, +) +from tests.unit.mcp.tools._constraint_check_support import ( + stage_minimal_constraints as _stage_minimal_constraints, +) + +pytest.importorskip("yaml") + +# Round 6 exactness regressions (PR #1254 review semantics). + + +def test_read_only_zero_rules_needs_no_index(tmp_path: Path) -> None: + (tmp_path / "architectural-constraints.yml").write_text( + "version: 1\nconstraints: []\n" + ) + + result = _run(_make_tool(tmp_path).execute({"persist": False})) + + assert result == { + "success": True, + "verdict": "SAFE", + "violations": [], + "rule_count": 0, + "evaluated_edge_count": 0, + } + assert not (tmp_path / ".ast-cache").exists() + + +def test_persist_missing_index_returns_legacy_safe_response(tmp_path: Path) -> None: + _stage_minimal_constraints(tmp_path) + + result = _run(_make_tool(tmp_path).execute({"output_format": "json"})) + + assert result == { + "success": True, + "verdict": "SAFE", + "violations": [], + "rule_count": 1, + "evaluated_edge_count": 0, + "note": ("No AST cache at .ast-cache/index.db; run codegraph_autoindex first."), + } + assert not (tmp_path / ".ast-cache").exists() + + +def test_read_only_missing_index_fails_closed(tmp_path: Path) -> None: + _stage_minimal_constraints(tmp_path) + + result = _run(_make_tool(tmp_path).execute({"persist": False})) + + assert (result["success"], result["error_code"], result["verdict"]) == ( + False, + "CONSTRAINT_INDEX_UNKNOWN", + "ERROR", + ) + assert result["error"] == "MISSING_INDEX" + + +def test_read_only_corrupt_index_fails_closed(tmp_path: Path) -> None: + _stage_minimal_constraints(tmp_path) + db_path = tmp_path / ".ast-cache" / "index.db" + db_path.parent.mkdir() + db_path.write_bytes(b"not a sqlite database") + + result = _run(_make_tool(tmp_path).execute({"persist": False})) + + assert (result["success"], result["error_code"], result["verdict"]) == ( + False, + "CONSTRAINT_INDEX_UNKNOWN", + "ERROR", + ) + + +def test_read_only_malformed_config_is_structured_caution(tmp_path: Path) -> None: + (tmp_path / "architectural-constraints.yml").write_text("constraints: [") + + result = _run(_make_tool(tmp_path).execute({"persist": False})) + + assert (result["success"], result["verdict"], result["rule_count"]) == ( + False, + "CAUTION", + 0, + ) + assert result["violations"] == [] + assert "constraint parse error" in result["error"] + + +def test_read_only_rejects_symlinked_index(tmp_path: Path) -> None: + real = tmp_path / "real.db" + _edges_db(real) + link = tmp_path / ".ast-cache" / "index.db" + link.parent.mkdir() + link.symlink_to(real) + + expected = "INDEX_PATH_SYMLINK" + with pytest.raises(ValueError, match=f"^{expected}$"): + _make_tool(tmp_path)._run_read_only( + link, [object()], path_filter="", min_severity_rank=1 + ) + + +@pytest.mark.parametrize("suffix", ["-wal", "-journal"]) +def test_read_only_rejects_nonempty_writer_sidecar(tmp_path: Path, suffix: str) -> None: + db_path = tmp_path / ".ast-cache" / "index.db" + _edges_db(db_path) + Path(str(db_path) + suffix).write_bytes(b"active writer") + + expected = "CONCURRENT_WRITER" + with pytest.raises(ValueError, match=f"^{expected}$"): + _make_tool(tmp_path)._run_read_only( + db_path, [object()], path_filter="", min_severity_rank=1 + ) + + +def test_read_only_immutable_connection_blocks_evaluator_writes(tmp_path: Path) -> None: + db_path = tmp_path / ".ast-cache" / "index.db" + _edges_db(db_path) + + def writing_evaluator(_constraints, conn): + conn.execute("INSERT INTO edges VALUES ('calls')") + return [] + + with pytest.raises(sqlite3.OperationalError, match="readonly"): + conn = sqlite3.connect(db_path) + conn.execute("PRAGMA query_only=ON") + try: + _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + path_filter="", + min_severity_rank=1, + evaluator=writing_evaluator, + ) + finally: + conn.close() + conn = sqlite3.connect(db_path) + try: + assert conn.execute("SELECT COUNT(*) FROM edges").fetchone() == (1,) + finally: + conn.close() + assert not Path(str(db_path) + "-wal").exists() + + +def test_evaluate_connection_rolls_back_only_its_own_transaction( + tmp_path: Path, +) -> None: + tool = _make_tool(tmp_path) + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.commit() + + def insert_then_return(_constraints, connection): + connection.execute("INSERT INTO edges VALUES ('calls')") + return [] + + rows, count = tool._evaluate_connection( + conn, [object()], min_severity_rank=1, evaluator=insert_then_return + ) + assert (rows, count, conn.in_transaction) == ([], 0, False) + assert conn.execute("SELECT COUNT(*) FROM edges").fetchone() == (0,) + + conn.execute("BEGIN") + rows, count = tool._evaluate_connection( + conn, [object()], min_severity_rank=1, evaluator=insert_then_return + ) + assert (rows, count, conn.in_transaction) == ([], 0, True) + assert conn.execute("SELECT COUNT(*) FROM edges").fetchone() == (1,) + conn.rollback() + conn.close() + + +def test_frozen_zero_rules_precedes_divergent_staged_graph( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from dataclasses import replace + + (tmp_path / "architectural-constraints.yml").write_text( + "version: 1\nconstraints: []\n" + ) + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + state = registry._states[str(created["diff_snapshot_id"])] + state.snapshot = replace( + state.snapshot, + mode="staged", + staged_source_matches_worktree=False, + staged_config_matches_worktree=False, + ) + + result = _run(_make_tool(tmp_path).execute(_frozen_arguments(created))) + + assert (result["success"], result["state"], result["verdict"]) == ( + True, + "applicable", + "SAFE", + ) + assert (result["rule_count"], result["evaluated_edge_count"]) == (0, 0) + + +def test_frozen_index_lease_receives_snapshot_hard_deadline( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from contextlib import contextmanager + + import tree_sitter_analyzer.index_snapshot as index_snapshots + + _stage_minimal_constraints(tmp_path) + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + snapshot = registry._states[str(created["diff_snapshot_id"])].snapshot + observed = [] + + @contextmanager + def lease(_root, *, deadline=None): + observed.append(deadline) + yield SimpleNamespace( + snapshot_id=None, + completeness="unknown", + source_generation=None, + reason="NO_INDEX", + ) + + monkeypatch.setattr(index_snapshots, "lease_existing_snapshot", lease) + result = _run(_make_tool(tmp_path).execute(_frozen_arguments(created))) + + assert result["error_code"] == "NO_INDEX" + assert observed == [snapshot.created_monotonic + snapshots.HARD_LIFETIME_SECONDS] + + +def test_counterpart_graph_requires_full_default_index_scope() -> None: + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + from tree_sitter_analyzer.mcp.tools.constraint_check_frozen import ( + _supported_scope_is_covered, + ) + + # The changed endpoint is inside src, but its caller/callee counterpart may + # be outside src, so a src-only graph cannot certify absence of violations. + partial = make_source_scope_descriptor(roots=("src",)) + assert _supported_scope_is_covered(["src/a.py"], partial) is False + assert ( + _supported_scope_is_covered(["src/a.py"], make_source_scope_descriptor()) + is True + ) + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("README.md", True), + ("tests/golden/corpus_python/sample.py", False), + ("src/.private/a.py", False), + ], +) +def test_frozen_scope_default_policy_edge_cases(path: str, expected: bool) -> None: + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + from tree_sitter_analyzer.mcp.tools.constraint_check_frozen import ( + _supported_scope_is_covered, + ) + + assert ( + _supported_scope_is_covered([path], make_source_scope_descriptor()) is expected + ) + + +def test_frozen_scope_fails_when_replayed_root_no_longer_covers_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import tree_sitter_analyzer.mcp.tools.constraint_check_frozen as frozen + + class MutableReplayScope: + exclude_patterns = () + effective_excludes = frozenset() + + def __init__(self): + self.reads = 0 + + @property + def roots(self): + self.reads += 1 + # Pass the initial authority check, then model a replay whose root + # counterpart does not cover the selected source path. + return (".",) if self.reads == 1 else ("lib",) + + monkeypatch.setattr(frozen, "SourceScopeDescriptor", MutableReplayScope) + + assert ( + frozen._supported_scope_is_covered(["src/a.py"], MutableReplayScope()) is False + ) + + +def test_read_only_rejects_elapsed_deadline_before_index_lease( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(time, "monotonic", lambda: 8.0) + + with pytest.raises(RuntimeError, match="^INDEX_SNAPSHOT_DEADLINE$"): + _make_tool(tmp_path)._run_read_only( + tmp_path / "ignored.db", + [object()], + path_filter="", + min_severity_rank=1, + deadline=8.0, + ) + + +def test_read_only_requires_project_root() -> None: + from tree_sitter_analyzer.mcp.tools.constraint_check_tool import ConstraintCheckTool + + with pytest.raises(ValueError, match="^MISSING_PROJECT_ROOT$"): + ConstraintCheckTool(None)._run_read_only( + Path("ignored.db"), + [object()], + path_filter="", + min_severity_rank=1, + ) + + +def test_read_only_supports_legacy_index_snapshot_seams( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from contextlib import contextmanager + + import tree_sitter_analyzer.index_snapshot as index_snapshots + + observed = [] + + @contextmanager + def legacy_lease(root): + observed.append(("lease", root)) + yield SimpleNamespace( + snapshot_id="is_legacy", + completeness="complete", + source_generation="sg_legacy", + reason=None, + ) + + @contextmanager + def legacy_acquire(snapshot_id, root, generation): + observed.append(("acquire", snapshot_id, root, generation)) + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + try: + yield SimpleNamespace(), conn + finally: + conn.close() + + monkeypatch.setattr(index_snapshots, "lease_existing_snapshot", legacy_lease) + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_index_snapshot.portable_snapshot_required", + lambda: False, + ) + monkeypatch.setattr(index_snapshots, "acquire_index_snapshot", legacy_acquire) + + rows, edge_count = _make_tool(tmp_path)._run_read_only( + tmp_path / "ignored.db", + [object()], + path_filter="", + min_severity_rank=1, + evaluator=lambda _constraints, _conn: [], + ) + + assert (rows, edge_count) == ([], 0) + assert observed == [ + ("lease", str(tmp_path)), + ("acquire", "is_legacy", str(tmp_path), "sg_legacy"), + ] + + +def test_persist_capacity_failure_is_structured_and_fail_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3768096795: capacity exhaustion cannot return SAFE. + _stage_minimal_constraints(tmp_path) + db_path = tmp_path / ".ast-cache" / "index.db" + db_path.parent.mkdir() + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.execute("INSERT INTO edges VALUES ('calls')") + conn.commit() + conn.close() + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.evaluate", + lambda *_args: (_ for _ in ()).throw( + RuntimeError("CONSTRAINT_EVALUATION_CAPACITY") + ), + ) + + result = _run(_make_tool(tmp_path).execute({"output_format": "json"})) + + assert result == { + "success": False, + "verdict": "ERROR", + "error_code": "CONSTRAINT_EVALUATION_CAPACITY", + "error": "CONSTRAINT_EVALUATION_CAPACITY", + } + + +def test_read_only_zero_rules_rechecks_live_config_before_safe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3768708964: an empty rules read cannot race a new rule. + import tree_sitter_analyzer.mcp.tools.constraint_check_tool as constraint_tool + + config = tmp_path / "architectural-constraints.yml" + config.write_text("version: 1\nconstraints: []\n") + real_load = constraint_tool.load_live_constraints + + def load_then_tighten(root: str, deadline: float): + snapshot, rules = real_load(root, deadline) + config.write_text( + "version: 1\nconstraints:\n" + " - {id: r, severity: error, rule: forbid, from: 'a/**', " + "to: 'b/**', reason: tightened}\n" + ) + return snapshot, rules + + monkeypatch.setattr(constraint_tool, "load_live_constraints", load_then_tighten) + result = _run(_make_tool(tmp_path).execute({"persist": False})) + + assert (result["success"], result["error_code"]) == ( + False, + "CONSTRAINT_CONFIG_CHANGED", + ) + + +def test_read_only_nonempty_rules_rechecks_live_config_after_evaluation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3768708964: a completed graph read cannot publish stale rules. + _stage_minimal_constraints(tmp_path) + tool = _make_tool(tmp_path) + + def evaluate_then_tighten(*_args, **_kwargs): + config = tmp_path / "architectural-constraints.yml" + config.write_text(config.read_text() + "\n# tightened\n") + return [], 0 + + monkeypatch.setattr(tool, "_run_read_only", evaluate_then_tighten) + result = _run(tool.execute({"persist": False, "output_format": "json"})) + + assert (result["success"], result["error_code"]) == ( + False, + "CONSTRAINT_CONFIG_CHANGED", + ) + + +def test_frozen_scope_decodes_wire_path_before_index_coverage() -> None: + # PR #1254 review 3769281313: extension/exclusion checks use raw Git paths. + from tree_sitter_analyzer.git_path_codec import path_to_wire + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + from tree_sitter_analyzer.mcp.tools.constraint_check_frozen import ( + _supported_scope_is_covered, + ) + + raw_path = b"tests/golden/corpus_\xff.py".decode("utf-8", "surrogateescape") + assert path_to_wire(raw_path).startswith("git-path-b64:") + assert ( + _supported_scope_is_covered([raw_path], make_source_scope_descriptor()) is False + ) diff --git a/tests/unit/mcp/tools/test_constraint_check_snapshot.py b/tests/unit/mcp/tools/test_constraint_check_snapshot.py new file mode 100644 index 000000000..c353c462a --- /dev/null +++ b/tests/unit/mcp/tools/test_constraint_check_snapshot.py @@ -0,0 +1,497 @@ +"""Focused frozen/read-only exactness tests for constraint checking.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import tree_sitter_analyzer.diff_snapshot_registry as snapshots +from tests.unit.mcp.tools._constraint_check_support import ( + create_frozen_scope as _create_frozen_scope, +) +from tests.unit.mcp.tools._constraint_check_support import ( + frozen_arguments as _frozen_arguments, +) +from tests.unit.mcp.tools._constraint_check_support import ( + init_violations_db as _init_violations_db, +) +from tests.unit.mcp.tools._constraint_check_support import ( + make_tool as _make_tool, +) +from tests.unit.mcp.tools._constraint_check_support import ( + run as _run, +) +from tests.unit.mcp.tools._constraint_check_support import ( + seed_violation as _seed_violation, +) +from tests.unit.mcp.tools._constraint_check_support import ( + stage_minimal_constraints as _stage_minimal_constraints, +) + +pytest.importorskip("yaml") + + +@pytest.mark.parametrize( + "divergent_field", + ["staged_source_matches_worktree", "staged_config_matches_worktree"], +) +def test_staged_frozen_constraints_reject_live_graph_for_divergent_plane( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, divergent_field: str +) -> None: + # PR #1254 reviews 3765536002/3765536016: fail closed without staged graph capability. + _stage_minimal_constraints(tmp_path) + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + from dataclasses import replace + + state = registry._states[str(created["diff_snapshot_id"])] + state.snapshot = replace( + state.snapshot, + mode="staged", + **{divergent_field: False}, + ) + + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + + assert (result["success"], result["error_code"], result["verdict"]) == ( + False, + "CONSTRAINT_STAGED_INDEX_UNKNOWN", + "ERROR", + ) + + +def test_staged_frozen_constraints_without_config_precedes_divergent_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3765918788: no graph is needed when no config exists. + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + from dataclasses import replace + + state = registry._states[str(created["diff_snapshot_id"])] + state.snapshot = replace( + state.snapshot, + mode="staged", + staged_source_matches_worktree=False, + ) + + result = _run( + _make_tool(tmp_path).execute( + { + "persist": False, + "diff_snapshot_id": created["diff_snapshot_id"], + "scope_paths": created["assessed_scope_paths"], + "output_format": "json", + } + ) + ) + + assert (result["success"], result["state"], result["reason"]) == ( + True, + "not_applicable", + "NO_CONFIG", + ) + + +def test_staged_frozen_no_config_final_guard_does_not_probe_worktree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3766246581: staged NO_CONFIG stays on the index plane. + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + from dataclasses import replace + + state = registry._states[str(created["diff_snapshot_id"])] + state.snapshot = replace(state.snapshot, mode="staged") + + from tree_sitter_analyzer.mcp.tools import constraint_check_frozen + + def reject_worktree_probe(*args, **kwargs): + raise AssertionError("staged NO_CONFIG probed the worktree") + + monkeypatch.setattr( + constraint_check_frozen.source_oracle, + "safe_workspace_path", + reject_worktree_probe, + ) + + result = _run(_make_tool(tmp_path).execute(_frozen_arguments(created))) + + assert (result["success"], result["state"], result["reason"]) == ( + True, + "not_applicable", + "NO_CONFIG", + ) + assert registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + + +def test_read_only_missing_edges_returns_structured_index_error(tmp_path: Path) -> None: + # PR #1254 review 3765918809: persist=false must not leak SQLite failures. + _stage_minimal_constraints(tmp_path) + _init_violations_db(tmp_path / ".ast-cache" / "index.db") + + result = _run(_make_tool(tmp_path).execute({"persist": False})) + + assert (result["success"], result["error_code"], result["verdict"]) == ( + False, + "CONSTRAINT_INDEX_UNKNOWN", + "ERROR", + ) + + +def test_persist_path_writes_evaluated_violations(tmp_path, monkeypatch): + from tree_sitter_analyzer.constraints import Violation + + db_path = tmp_path / "index.db" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.execute("INSERT INTO edges VALUES ('calls')") + conn.commit() + conn.close() + item = Violation("r", "src/a.py", "a", 4, "b", "src/b.py", "warn", 0) + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.evaluate", + lambda constraints, conn: [item], + ) + rows, count = _make_tool(tmp_path)._run_and_persist(db_path, [object()]) + conn = sqlite3.connect(db_path) + persisted = conn.execute( + "SELECT rule_id, caller_file, severity FROM ast_constraint_violations" + ).fetchall() + conn.close() + assert (rows, count, persisted) == ([item], 1, [("r", "src/a.py", "warn")]) + + +def test_persist_path_evaluator_failure_preserves_cache(tmp_path, monkeypatch): + db_path = tmp_path / "index.db" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.execute("INSERT INTO edges VALUES ('calls')") + conn.commit() + conn.close() + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.evaluate", + lambda *_args: (_ for _ in ()).throw(RuntimeError("broken")), + ) + assert _make_tool(tmp_path)._run_and_persist(db_path, [object()]) == ([], 1) + + +def test_cached_violation_filters_severity_and_path(tmp_path): + db_path = tmp_path / "index.db" + _init_violations_db(db_path) + _seed_violation( + db_path, + rule_id="warn", + caller_file="src/a.py", + callee_file="dst.py", + severity="warn", + ) + _seed_violation( + db_path, + rule_id="info", + caller_file="other/b.py", + callee_file="dst.py", + severity="info", + ) + rows = _make_tool(tmp_path)._read_filtered_violations( + db_path, path_filter="src/**", min_severity_rank=1 + ) + assert [row["rule_id"] for row in rows] == ["warn"] + + +def test_read_only_evaluation_closes_connection_and_filters_rows(tmp_path, monkeypatch): + from tree_sitter_analyzer.constraints import Violation + + db_path = tmp_path / "index.db" + conn = sqlite3.connect(db_path) + conn.execute("CREATE TABLE edges(kind TEXT)") + conn.execute("INSERT INTO edges VALUES ('calls')") + conn.commit() + conn.close() + rows = [ + Violation("low", "src/a.py", "a", 1, "b", "dst.py", "info", 1), + Violation("path", "other/a.py", "a", 2, "b", "dst.py", "warn", 1), + Violation("keep", "src/b.py", "a", 3, "b", "dst.py", "warn", 1), + ] + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.evaluate", + lambda *_args: rows, + ) + conn = sqlite3.connect(db_path) + try: + result, count = _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + path_filter="src/**", + min_severity_rank=1, + ) + finally: + conn.close() + assert (count, [row["rule_id"] for row in result]) == (1, ["keep"]) + + +@pytest.mark.parametrize( + ("arguments", "message"), + [ + ({"diff_snapshot_id": "", "persist": False}, "non-empty string"), + ({"diff_snapshot_id": "ds", "persist": False}, "scope_paths as strings"), + ( + { + "diff_snapshot_id": "ds", + "persist": False, + "scope_paths": [], + "path_filter": "src/**", + }, + "DIFF_SNAPSHOT_CONFLICTING_ARGUMENTS", + ), + ({"scope_paths": []}, "scope_paths requires diff_snapshot_id"), + ], +) +def test_constraint_snapshot_argument_conflicts_are_exact(tmp_path, arguments, message): + with pytest.raises(ValueError, match=message): + _make_tool(tmp_path).validate_arguments(arguments) + + +def test_frozen_executor_reports_missing_project_root() -> None: + from tree_sitter_analyzer.mcp.tools.constraint_check_frozen import execute_frozen + from tree_sitter_analyzer.mcp.tools.constraint_check_tool import ConstraintCheckTool + + result = execute_frozen( + ConstraintCheckTool(None), + {"diff_snapshot_id": "ds_missing", "output_format": "json"}, + ) + + assert (result["success"], result["error_code"]) == ( + False, + "MISSING_PROJECT_ROOT", + ) + + +def test_frozen_constraints_reject_invalid_captured_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "architectural-constraints.yml").write_text("constraints: [") + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + + result = _run(_make_tool(tmp_path).execute(_frozen_arguments(created))) + + assert (result["success"], result["error_code"]) == ( + False, + "CONSTRAINT_CONFIG_INVALID", + ) + assert registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + + +@pytest.mark.parametrize( + ("snapshot_id", "completeness", "generation", "reason", "error_code"), + [ + (None, "complete", "captured", "NO_INDEX", "NO_INDEX"), + ("is_test", "partial", "captured", "INDEX_PARTIAL", "INDEX_PARTIAL"), + ("is_test", "complete", "other", None, "SOURCE_GENERATION_MISMATCH"), + ], +) +def test_frozen_constraints_require_matching_complete_index_capability( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + snapshot_id, + completeness, + generation, + reason, + error_code, +) -> None: + from contextlib import contextmanager + + import tree_sitter_analyzer.index_snapshot as index_snapshots + + _stage_minimal_constraints(tmp_path) + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + + @contextmanager + def lease(_root): + yield SimpleNamespace( + snapshot_id=snapshot_id, + completeness=completeness, + source_generation=( + created["source_generation"] if generation == "captured" else generation + ), + reason=reason, + ) + + monkeypatch.setattr(index_snapshots, "lease_existing_snapshot", lease) + + result = _run(_make_tool(tmp_path).execute(_frozen_arguments(created))) + + assert (result["success"], result["error_code"]) == (False, error_code) + assert registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + + +def test_frozen_constraints_reject_supported_path_outside_index_scope( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3766246604: graph evidence cannot cover paths it omitted. + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + + _stage_minimal_constraints(tmp_path) + registry, created = _create_frozen_scope( + monkeypatch, + tmp_path, + ["src/a.py", "README.md"], + source_scope=make_source_scope_descriptor(roots=("lib",)), + ) + + result = _run(_make_tool(tmp_path).execute(_frozen_arguments(created))) + + assert (result["success"], result["error_code"], result["verdict"]) == ( + False, + "CONSTRAINT_INDEX_SCOPE_MISMATCH", + "ERROR", + ) + assert registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + + +def test_frozen_constraints_map_index_capture_failure_to_structured_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from contextlib import contextmanager + + import tree_sitter_analyzer.index_snapshot as index_snapshots + + _stage_minimal_constraints(tmp_path) + registry, created = _create_frozen_scope(monkeypatch, tmp_path, ["src/a.py"]) + + @contextmanager + def failed_lease(_root): + raise OSError("index disappeared") + yield + + monkeypatch.setattr(index_snapshots, "lease_existing_snapshot", failed_lease) + + result = _run(_make_tool(tmp_path).execute(_frozen_arguments(created))) + + assert (result["success"], result["error_code"]) == ( + False, + "CONSTRAINT_CAPTURE_UNKNOWN", + ) + assert result["error"] == "index disappeared" + assert registry.close_lease(created["diff_snapshot_id"], created["route_lease_id"]) + + +def test_frozen_constraints_release_consumer_after_snapshot_read_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tree_sitter_analyzer.mcp.tools.constraint_check_frozen import execute_frozen + + released = [] + + class BrokenConsumer: + @property + def snapshot(self): + raise OSError("snapshot unavailable") + + def release(self): + released.append(True) + + class BrokenRegistry: + def acquire(self, snapshot_id, project_root): + return BrokenConsumer(), None + + monkeypatch.setattr(snapshots, "REGISTRY", BrokenRegistry()) + + result = execute_frozen( + _make_tool(tmp_path), + { + "diff_snapshot_id": "ds_broken", + "scope_paths": [], + "output_format": "json", + }, + ) + + assert (result["error_code"], result["error"], released) == ( + "CONSTRAINT_CAPTURE_UNKNOWN", + "snapshot unavailable", + [True], + ) + + +def test_constraint_arguments_reject_non_boolean_persist(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="^persist must be a boolean$"): + _make_tool(tmp_path).validate_arguments({"persist": "false"}) + + +def test_constraint_arguments_reject_unknown_severity(tmp_path: Path) -> None: + with pytest.raises( + ValueError, + match=( + r"^severity_min must be one of \['error', 'info', 'warn'\]; " + r"got 'critical'$" + ), + ): + _make_tool(tmp_path).validate_arguments({"severity_min": "critical"}) + + +def test_scope_predicate_accepts_caller_or_callee_and_rejects_outside( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tree_sitter_analyzer.constraints import Violation + + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE edges(kind TEXT)") + candidates = [ + Violation("caller", "src/in.py", "a", 1, "b", "out.py", "warn", 1), + Violation("callee", "out.py", "a", 2, "b", "src/in.py", "warn", 1), + Violation("outside", "a.py", "a", 3, "b", "b.py", "warn", 1), + ] + + def scoped_evaluate(_constraints, _conn, *, scope_predicate): + return [ + item + for item in candidates + if scope_predicate(item.caller_file, item.callee_file) + ] + + monkeypatch.setattr( + "tree_sitter_analyzer.mcp.tools.constraint_check_tool.evaluate", + scoped_evaluate, + ) + try: + rows, edge_count = _make_tool(tmp_path)._evaluate_connection( + conn, + [object()], + min_severity_rank=1, + scope_paths=frozenset({"src/in.py"}), + ) + finally: + conn.close() + + assert (edge_count, [row["rule_id"] for row in rows]) == ( + 0, + ["callee", "caller"], + ) + + +def test_frozen_scope_rejects_missing_source_scope_descriptor() -> None: + from tree_sitter_analyzer.mcp.tools.constraint_check_frozen import ( + _supported_scope_is_covered, + ) + + assert _supported_scope_is_covered(["src/a.py"], None) is False + + +def test_frozen_scope_rejects_supported_excluded_path() -> None: + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + from tree_sitter_analyzer.mcp.tools.constraint_check_frozen import ( + _supported_scope_is_covered, + ) + + scope = make_source_scope_descriptor(exclude_patterns=("src/*.py",)) + + assert _supported_scope_is_covered(["src/a.py"], scope) is False diff --git a/tests/unit/mcp/tools/test_constraint_check_tool.py b/tests/unit/mcp/tools/test_constraint_check_tool.py index c24a14971..e288229e2 100644 --- a/tests/unit/mcp/tools/test_constraint_check_tool.py +++ b/tests/unit/mcp/tools/test_constraint_check_tool.py @@ -33,6 +33,7 @@ import sqlite3 import time from pathlib import Path +from types import SimpleNamespace import pytest @@ -296,3 +297,65 @@ def test_path_filter_narrows_results(self, tmp_path: Path) -> None: assert callers == ["mcp/handler.py"], ( f"path_filter='mcp/**' must keep only the mcp/* row. Got: {callers}" ) + + +def test_execute_without_project_root_returns_setup_instruction() -> None: + from tree_sitter_analyzer.mcp.tools.constraint_check_tool import ( + ConstraintCheckTool, + ) + + result = _run(ConstraintCheckTool(None).execute({})) + + assert result == { + "success": False, + "error": "Project root not set. Call set_project_path first.", + } + + +def test_persistent_unexpected_runtime_error_is_not_misclassified( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _stage_minimal_constraints(tmp_path) + db = tmp_path / ".ast-cache" / "index.db" + db.parent.mkdir() + db.touch() + tool = _make_tool(tmp_path) + monkeypatch.setattr( + tool, + "_run_and_persist", + lambda *_args: (_ for _ in ()).throw(RuntimeError("unexpected")), + ) + with pytest.raises(RuntimeError, match="^unexpected$"): + _run(tool.execute({})) + + +def test_read_only_reuses_one_absolute_deadline( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3772454771: config, graph, and publish share one budget. + import tree_sitter_analyzer.mcp.tools.constraint_check_tool as owner + + observed: list[float] = [] + snapshot = ("architectural-constraints.yml", b"rules", ()) + monkeypatch.setattr(owner, "time", SimpleNamespace(monotonic=lambda: 1.0)) + monkeypatch.setattr( + owner, + "load_live_constraints", + lambda _root, deadline: (observed.append(deadline) or snapshot, [object()]), + ) + monkeypatch.setattr( + owner, + "_live_config_snapshot", + lambda _root, deadline: observed.append(deadline) or snapshot, + ) + tool = _make_tool(tmp_path) + monkeypatch.setattr( + tool, + "_run_read_only", + lambda *_args, deadline, **_kwargs: (observed.append(deadline) or [], 0), + ) + + result = _run(tool.execute({"persist": False, "output_format": "json"})) + + assert (result["success"], result["verdict"]) == (True, "SAFE") + assert observed == [11.0, 11.0, 11.0] diff --git a/tests/unit/mcp/tools/test_constraint_index_snapshot.py b/tests/unit/mcp/tools/test_constraint_index_snapshot.py new file mode 100644 index 000000000..a9b43516f --- /dev/null +++ b/tests/unit/mcp/tools/test_constraint_index_snapshot.py @@ -0,0 +1,493 @@ +"""Behavioral tests for ordinary constraint index snapshot authority.""" + +from __future__ import annotations + +import io +import os +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner + + +def _database(root: Path, rows: int = 1) -> Path: + cache = root / ".ast-cache" + cache.mkdir() + path = cache / "index.db" + with sqlite3.connect(path) as conn: + conn.execute("CREATE TABLE payload(value TEXT)") + conn.executemany( + "INSERT INTO payload VALUES (?)", [(str(i),) for i in range(rows)] + ) + return path + + +def test_portable_snapshot_pins_bytes_and_publishes_certified_private_database( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path, 2) + certified: list[tuple[str, int]] = [] + + def certify(conn: sqlite3.Connection, root: str, *, deadline: float): + certified.append( + (root, conn.execute("SELECT COUNT(*) FROM payload").fetchone()[0]) + ) + return owner.OrdinaryConstraintSnapshot("complete", None, "scope") + + monkeypatch.setattr(owner, "_certify_private_copy", certify) + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 2 + ) as (snapshot, conn): + values = [ + tuple(row) + for row in conn.execute("SELECT value FROM payload ORDER BY value") + ] + query_only = conn.execute("PRAGMA query_only").fetchone()[0] + + assert (snapshot.completeness, snapshot.reason, snapshot.source_scope) == ( + "complete", + None, + "scope", + ) + assert certified == [(str(tmp_path.resolve()), 2)] + assert values == [("0",), ("1",)] + assert query_only == 1 + + +def test_portable_snapshot_removes_private_temporary_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path) + staged: list[Path] = [] + real_copy = owner._temporary_copy + + @contextmanager + def observe(fd: int, expected: tuple[int, ...], root: str, *, deadline: float): + with real_copy(fd, expected, root, deadline=deadline) as path: + staged.append(path.parent) + yield path + + monkeypatch.setattr(owner, "_temporary_copy", observe) + monkeypatch.setattr( + owner, + "_certify_private_copy", + lambda *_args, **_kwargs: owner.OrdinaryConstraintSnapshot( + "complete", None, None + ), + ) + with owner.portable_ordinary_snapshot(str(tmp_path), deadline=time.monotonic() + 2): + assert len(staged) == 1 + + assert staged[0].exists() is False + + +def test_open_database_fd_rejects_descriptor_identity_mismatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + database = _database(tmp_path) + expected = owner._identity(database, directory=False) + monkeypatch.setattr(owner, "_stat_identity", lambda _info: (9, 9, 9, 9, 9)) + + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + owner._open_database_fd(database, expected) + + +def test_copy_pinned_database_writes_exact_advertised_bytes(tmp_path: Path) -> None: + path = tmp_path / "bytes" + path.write_bytes(b"abcdef") + output = tmp_path / "copy" + fd = os.open(path, os.O_RDONLY) + try: + expected = owner._stat_identity(os.fstat(fd)) + with output.open("xb", buffering=0) as stream: + owner._copy_pinned_database( + fd, expected, stream, deadline=time.monotonic() + 1 + ) + finally: + os.close(fd) + + assert output.read_bytes() == b"abcdef" + + +def test_copy_pinned_database_rejects_truncation(tmp_path: Path) -> None: + path = tmp_path / "bytes" + path.write_bytes(b"x") + fd = os.open(path, os.O_RDONLY) + expected = list(owner._stat_identity(os.fstat(fd))) + expected[2] = 2 + try: + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + owner._copy_pinned_database( + fd, tuple(expected), io.BytesIO(), deadline=time.monotonic() + 1 + ) + finally: + os.close(fd) + + +def test_copy_pinned_database_rejects_growth(tmp_path: Path) -> None: + path = tmp_path / "bytes" + path.write_bytes(b"xy") + fd = os.open(path, os.O_RDONLY) + expected = list(owner._stat_identity(os.fstat(fd))) + expected[2] = 1 + try: + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + owner._copy_pinned_database( + fd, tuple(expected), io.BytesIO(), deadline=time.monotonic() + 1 + ) + finally: + os.close(fd) + + +def test_copy_pinned_database_enforces_deadline( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "bytes" + path.write_bytes(b"x") + fd = os.open(path, os.O_RDONLY) + expected = owner._stat_identity(os.fstat(fd)) + monkeypatch.setattr(owner.time, "monotonic", lambda: 2.0) + try: + with pytest.raises(RuntimeError, match="^INDEX_SNAPSHOT_DEADLINE$"): + owner._copy_pinned_database(fd, expected, io.BytesIO(), deadline=1.0) + finally: + os.close(fd) + + +@pytest.mark.parametrize("suffix", ["-wal", "-journal"]) +def test_sidecar_state_rejects_nonempty_transaction_sidecars( + tmp_path: Path, suffix: str +) -> None: + database = _database(tmp_path) + Path(str(database) + suffix).write_bytes(b"active") + + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + owner._sidecar_state(database) + + +def test_sidecar_state_allows_shm_and_empty_transaction_sidecars( + tmp_path: Path, +) -> None: + database = _database(tmp_path) + Path(str(database) + "-wal").touch() + Path(str(database) + "-journal").touch() + Path(str(database) + "-shm").write_bytes(b"coordination") + + states = owner._sidecar_state(database) + + assert tuple(suffix for suffix, _identity in states) == ("-wal", "-journal", "-shm") + assert tuple(identity is None for _suffix, identity in states) == ( + False, + False, + False, + ) + + +def test_temporary_copy_rejects_unsafe_parent_before_allocation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3772454765: read-only staging never touches the project. + calls = [] + monkeypatch.setattr( + owner, + "safe_external_temp_parent", + lambda _root: (_ for _ in ()).throw( + owner.SourceOracleError("DIFF_SNAPSHOT_UNSAFE_TEMP") + ), + ) + monkeypatch.setattr( + owner.tempfile, "TemporaryDirectory", lambda **kwargs: calls.append(kwargs) + ) + source = tmp_path / "source" + source.write_bytes(b"data") + fd = os.open(source, os.O_RDONLY) + try: + with pytest.raises(ValueError, match="^INDEX_TEMP_OUTSIDE_PROJECT_REQUIRED$"): + with owner._temporary_copy( + fd, + owner._stat_identity(os.fstat(fd)), + str(tmp_path.resolve()), + deadline=time.monotonic() + 1, + ): + pytest.fail("unsafe temporary copy published") + finally: + os.close(fd) + assert calls == [] + + +def test_temporary_copy_rejects_project_path_after_allocation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + @contextmanager + def allocate(**kwargs): + assert kwargs["dir"] == str(tmp_path.parent) + yield str(tmp_path / "allocated") + + monkeypatch.setattr( + owner, "safe_external_temp_parent", lambda _root: str(tmp_path.parent) + ) + monkeypatch.setattr(owner.tempfile, "TemporaryDirectory", allocate) + with pytest.raises(ValueError, match="^INDEX_TEMP_OUTSIDE_PROJECT_REQUIRED$"): + with owner._temporary_copy( + -1, (0, 0, 0, 0, 0), str(tmp_path), deadline=float("inf") + ): + pytest.fail("project-local copy published") + + +def _certification_dependencies( + monkeypatch: pytest.MonkeyPatch, *, manifest: object +) -> None: + import tree_sitter_analyzer.index_snapshot as snapshot_module + import tree_sitter_analyzer.index_source_snapshot as source_module + + scope = object() + current = SimpleNamespace( + state="exact", + reason=None, + rows=(("a.py", "hash"),), + fingerprint="source", + generation="generation", + ) + monkeypatch.setattr(owner, "validate_snapshot_schema", lambda *_a, **_k: None) + monkeypatch.setattr(owner, "build_in_progress", lambda _conn: False) + monkeypatch.setattr(snapshot_module, "_read_bounded_manifest", lambda *_a: manifest) + monkeypatch.setattr(snapshot_module, "_validate_manifest_scalars", lambda _m: None) + monkeypatch.setattr( + source_module, "parse_source_scope_descriptor", lambda _raw: scope + ) + monkeypatch.setattr(owner, "_capture_constraint_sources", lambda *_a: current) + monkeypatch.setattr(owner, "recorded_source_rows", lambda *_a, **_k: current.rows) + monkeypatch.setattr(owner, "index_fingerprint", lambda *_a, **_k: "index") + monkeypatch.setattr(owner, "exact_call_graph_marker", lambda *_a, **_k: True) + monkeypatch.setattr(owner, "sqlite_compile_supports_fts5", lambda _conn: True) + monkeypatch.setattr(owner, "has_ordinary_symbol_projection", lambda *_a: True) + monkeypatch.setattr(owner, "symbol_projection_is_exact", lambda *_a, **_k: True) + + +def test_certify_private_copy_accepts_exact_full_manifest( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = { + "source_scope_descriptor": "scope", + "canonical_root": "/project", + "source_fingerprint": "source", + "index_fingerprint": "index", + "file_count": 1, + "manifest_version": 2, + } + _certification_dependencies(monkeypatch, manifest=manifest) + conn = sqlite3.connect(":memory:") + try: + result = owner._certify_private_copy( + conn, "/project", deadline=time.monotonic() + 1 + ) + finally: + conn.close() + + assert ( + result.completeness, + result.reason, + result.source_generation, + result.source_fingerprint, + result.canonical_root, + ) == ("complete", None, "generation", "source", "/project") + assert result.source_scope is not None + + +@pytest.mark.parametrize( + ("mutation", "reason"), + [ + ({"manifest": None}, "SOURCE_SCOPE_DESCRIPTOR_MISSING"), + ({"canonical_root": "/other"}, "NO_EXACT_FULL_INDEX_MANIFEST"), + ({"projection": False}, "SYMBOL_PROJECTION_INCOMPLETE"), + ], +) +def test_certify_private_copy_reports_non_authoritative_states( + monkeypatch: pytest.MonkeyPatch, mutation: dict[str, object], reason: str +) -> None: + manifest = { + "source_scope_descriptor": "scope", + "canonical_root": "/project", + "source_fingerprint": "source", + "index_fingerprint": "index", + "file_count": 1, + "manifest_version": 2, + } + if "canonical_root" in mutation: + manifest["canonical_root"] = mutation["canonical_root"] + _certification_dependencies( + monkeypatch, manifest=mutation.get("manifest", manifest) + ) + if mutation.get("projection") is False: + monkeypatch.setattr( + owner, "symbol_projection_is_exact", lambda *_a, **_k: False + ) + conn = sqlite3.connect(":memory:") + try: + result = owner._certify_private_copy( + conn, "/project", deadline=time.monotonic() + 1 + ) + finally: + conn.close() + + assert (result.completeness, result.reason) == ("partial", reason) + + +def test_evaluate_ordinary_snapshot_uses_portable_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + conn = sqlite3.connect(":memory:") + scope = object() + authority = SimpleNamespace( + completeness="complete", + reason=None, + source_scope=scope, + source_generation=None, + source_fingerprint="fingerprint", + canonical_root="/project", + ) + + @contextmanager + def portable(root: str, *, deadline: float): + assert (root, deadline) == ("/project", 7.0) + yield authority, conn + + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: True) + monkeypatch.setattr(owner, "portable_ordinary_snapshot", portable) + monkeypatch.setattr( + owner, "ordinary_source_scope_is_full", lambda value: value is scope + ) + monkeypatch.setattr( + owner, + "_capture_constraint_sources", + lambda *_args: SimpleNamespace( + state="exact", + reason=None, + generation="different-generation", + fingerprint="fingerprint", + ), + ) + calls: list[tuple[object, ...]] = [] + tool = SimpleNamespace( + project_root="/project", + _evaluate_connection=lambda *args, **kwargs: ( + calls.append((args, kwargs)) or ([{"ok": True}], 3) + ), + ) + result = owner.evaluate_ordinary_snapshot( + tool, + ["rule"], + path_filter="src", + min_severity_rank=2, + scope_paths=frozenset({"src"}), + evaluator="eval", + deadline=7.0, + ) + + assert result == ([{"ok": True}], 3) + assert calls[0][0] == (conn, ["rule"]) + assert calls[0][1] == { + "path_filter": "src", + "min_severity_rank": 2, + "scope_paths": frozenset({"src"}), + "evaluator": "eval", + "deadline": 7.0, + } + conn.close() + + +def test_evaluate_ordinary_snapshot_uses_registry_lease( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import tree_sitter_analyzer.index_snapshot as registry + + conn = sqlite3.connect(":memory:") + scope = SimpleNamespace(roots=(".",), exclude_patterns=()) + index = SimpleNamespace( + snapshot_id="snap", + completeness="complete", + reason=None, + source_scope=scope, + source_generation="gen", + canonical_root="/project", + ) + events: list[tuple[object, ...]] = [] + + @contextmanager + def lease(root: str, *, deadline: float): + events.append(("lease", root, deadline)) + yield index + + @contextmanager + def acquire(snapshot_id: str, root: str, generation: str, *, deadline: float): + events.append(("acquire", snapshot_id, root, generation, deadline)) + yield index, conn + + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: False) + monkeypatch.setattr(registry, "lease_existing_snapshot", lease) + monkeypatch.setattr(registry, "acquire_index_snapshot", acquire) + monkeypatch.setattr( + owner, "ordinary_source_scope_is_full", lambda candidate: candidate is scope + ) + current = SimpleNamespace( + state="exact", reason=None, generation="gen", fingerprint="fingerprint" + ) + monkeypatch.setattr(owner, "_capture_constraint_sources", lambda *_args: current) + tool = SimpleNamespace( + project_root="/project", _evaluate_connection=lambda *_a, **_k: ([], 0) + ) + + result = owner.evaluate_ordinary_snapshot( + tool, + [], + path_filter="", + min_severity_rank=0, + scope_paths=None, + evaluator=None, + deadline=8.0, + ) + + assert result == ([], 0) + assert events == [ + ("lease", "/project", 8.0), + ("acquire", "snap", "/project", "gen", 8.0), + ] + conn.close() + + +def test_evaluate_ordinary_snapshot_rejects_registry_scope_mismatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + @contextmanager + def portable(*_args, **_kwargs): + yield owner.OrdinaryConstraintSnapshot("complete", None, object()), object() + + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: True) + monkeypatch.setattr(owner, "portable_ordinary_snapshot", portable) + monkeypatch.setattr(owner, "ordinary_source_scope_is_full", lambda _scope: False) + tool = SimpleNamespace(project_root="/project") + + with pytest.raises(ValueError, match="^CONSTRAINT_INDEX_SCOPE_MISMATCH$"): + owner.evaluate_ordinary_snapshot( + tool, + [], + path_filter="", + min_severity_rank=0, + scope_paths=None, + evaluator=None, + deadline=1.0, + ) + + +def test_constraint_source_capture_selects_descriptor_oracle(monkeypatch) -> None: + expected = object() + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: False) + monkeypatch.setattr( + owner, "capture_current_source_snapshot", lambda *_a, **_k: expected + ) + assert owner._capture_constraint_sources("/project", object(), 1.0) is expected diff --git a/tests/unit/mcp/tools/test_constraint_index_snapshot_budget.py b/tests/unit/mcp/tools/test_constraint_index_snapshot_budget.py new file mode 100644 index 000000000..a16bafc6a --- /dev/null +++ b/tests/unit/mcp/tools/test_constraint_index_snapshot_budget.py @@ -0,0 +1,233 @@ +"""Budget and final-sidecar checks for portable constraint index snapshots.""" + +from __future__ import annotations + +import io +import os +import time +from pathlib import Path + +import pytest + +import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner +from tests.unit.mcp.tools.test_constraint_index_snapshot import _database + + +def test_portable_snapshot_rejects_page_count_budget_after_pinned_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path) + + class Source: + def execute(self, sql: str): + self.sql = sql + return self + + def fetchone(self): + if "page_count" in self.sql: + monkeypatch.setattr(owner, "_MAX_BACKUP_BYTES", 1) + return (1,) + return (4096,) + + def close(self): + pass + + monkeypatch.setattr(owner.sqlite3, "connect", lambda *_a, **_k: Source()) + monkeypatch.setattr(owner, "require_memory_temp_store", lambda _conn: None) + + with pytest.raises(RuntimeError, match="^INDEX_BACKUP_BUDGET$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("oversized page set published") + + +def test_portable_snapshot_progress_rechecks_backup_budget( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path) + + class Source: + def execute(self, sql: str): + self.sql = sql + return self + + def fetchone(self): + return (4096,) if "page_size" in self.sql else (0,) + + def backup(self, _private, **kwargs): + monkeypatch.setattr(owner, "_MAX_BACKUP_BYTES", 1) + kwargs["progress"](0, 0, 1) + + def close(self): + pass + + class Private: + def close(self): + pass + + connections = iter((Source(), Private())) + monkeypatch.setattr(owner.sqlite3, "connect", lambda *_a, **_k: next(connections)) + monkeypatch.setattr(owner, "require_memory_temp_store", lambda _conn: None) + + with pytest.raises(RuntimeError, match="^INDEX_BACKUP_BUDGET$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("progress budget violation published") + + +@pytest.mark.parametrize("changed_on_call", [3, 4]) +def test_portable_snapshot_rechecks_sidecars_before_and_after_certification( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, changed_on_call: int +) -> None: + _database(tmp_path) + calls = 0 + stable = (("-wal", None), ("-journal", None), ("-shm", None)) + + def sidecars(_path: Path): + nonlocal calls + calls += 1 + return stable if calls != changed_on_call else (("-wal", (1, 2, 3, 4, 5)),) + + monkeypatch.setattr(owner, "_sidecar_state", sidecars) + monkeypatch.setattr( + owner, + "_certify_private_copy", + lambda *_a, **_k: owner.OrdinaryConstraintSnapshot("complete", None, None), + ) + + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("changed sidecar state published") + + assert calls == changed_on_call + + +class _RecordingWriter: + def __init__(self, events: list[tuple[str, bytes]]) -> None: + self.events = events + + def write(self, data: memoryview) -> int: + payload = bytes(data) + self.events.append(("write", payload)) + return len(payload) + + +def test_pinned_copy_interleaves_bounded_reads_and_staging_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3768545528: never accumulate a second whole DB in memory. + source = tmp_path / "source.db" + source.write_bytes(b"abcdef") + fd = owner.os.open(source, owner.os.O_RDONLY) + expected = owner._stat_identity(owner.os.fstat(fd)) + chunks = iter((b"abc", b"def", b"")) + events: list[tuple[str, bytes]] = [] + + def read(_fd: int, _size: int) -> bytes: + chunk = next(chunks) + events.append(("read", chunk)) + return chunk + + monkeypatch.setattr(owner.os, "read", read) + try: + owner._copy_pinned_database( + fd, + expected, + _RecordingWriter(events), + deadline=time.monotonic() + 1, + ) + finally: + owner.os.close(fd) + + assert events == [ + ("read", b"abc"), + ("write", b"abc"), + ("read", b"def"), + ("write", b"def"), + ("read", b""), + ] + + +def test_pinned_copy_polls_deadline_after_partial_staging_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3768614248: staging writes share the absolute deadline. + source = tmp_path / "source.db" + source.write_bytes(b"abcdef") + fd = owner.os.open(source, owner.os.O_RDONLY) + expected = owner._stat_identity(owner.os.fstat(fd)) + checks = 0 + writes: list[bytes] = [] + + class PartialWriter: + def write(self, data: memoryview) -> int: + writes.append(bytes(data)) + return 1 + + def deadline(_absolute: float) -> None: + nonlocal checks + checks += 1 + if checks == 3: + raise RuntimeError("INDEX_SNAPSHOT_DEADLINE") + + monkeypatch.setattr(owner, "_deadline", deadline) + try: + with pytest.raises(RuntimeError, match="^INDEX_SNAPSHOT_DEADLINE$"): + owner._copy_pinned_database(fd, expected, PartialWriter(), deadline=1.0) + finally: + owner.os.close(fd) + + assert (writes, checks) == ([b"abcdef"], 3) + + +@pytest.mark.parametrize("written", [None, 0, -1, 7]) +def test_pinned_copy_rejects_invalid_staging_write_count( + tmp_path: Path, written: int | None +) -> None: + source = tmp_path / "source.db" + source.write_bytes(b"abcdef") + fd = owner.os.open(source, owner.os.O_RDONLY) + expected = owner._stat_identity(owner.os.fstat(fd)) + + class InvalidWriter: + def write(self, _data: memoryview): + return written + + try: + with pytest.raises(OSError, match="^INDEX_STAGE_WRITE_FAILED$"): + owner._copy_pinned_database( + fd, expected, InvalidWriter(), deadline=time.monotonic() + 1 + ) + finally: + owner.os.close(fd) + + +def test_portable_snapshot_maps_disappearing_cache_to_missing_index( + tmp_path: Path, +) -> None: + with pytest.raises(ValueError, match="^MISSING_INDEX$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("missing index published") + + +def test_copy_pinned_database_rejects_backup_budget( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "bytes" + path.write_bytes(b"xx") + fd = os.open(path, os.O_RDONLY) + expected = owner._stat_identity(os.fstat(fd)) + monkeypatch.setattr(owner, "_MAX_BACKUP_BYTES", 1) + try: + with pytest.raises(RuntimeError, match="^INDEX_BACKUP_BUDGET$"): + owner._copy_pinned_database( + fd, expected, io.BytesIO(), deadline=time.monotonic() + 1 + ) + finally: + os.close(fd) diff --git a/tests/unit/mcp/tools/test_constraint_index_snapshot_faults.py b/tests/unit/mcp/tools/test_constraint_index_snapshot_faults.py new file mode 100644 index 000000000..ed164f24a --- /dev/null +++ b/tests/unit/mcp/tools/test_constraint_index_snapshot_faults.py @@ -0,0 +1,493 @@ +"""Fault-boundary behaviors for ordinary constraint index snapshots.""" + +from __future__ import annotations + +import io +import sqlite3 +import time +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import tree_sitter_analyzer.mcp.tools.constraint_index_snapshot as owner +from tests.unit.mcp.tools.test_constraint_index_snapshot import ( + _certification_dependencies, + _database, +) + + +def test_identity_rejects_non_directory_and_non_regular_paths(tmp_path: Path) -> None: + regular = tmp_path / "file" + regular.touch() + directory = tmp_path / "directory" + directory.mkdir() + + with pytest.raises(ValueError, match="^INDEX_PATH_UNSAFE$"): + owner._identity(regular, directory=True) + with pytest.raises(ValueError, match="^INDEX_PATH_UNSAFE$"): + owner._identity(directory, directory=False) + + +def test_copy_pinned_database_rejects_identity_change( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "bytes" + path.write_bytes(b"x") + fd = owner._open(path, 0) + expected = owner._stat_identity(owner.os.fstat(fd)) + monkeypatch.setattr(owner, "_stat_identity", lambda _info: (9, 9, 9, 9, 9)) + try: + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + owner._copy_pinned_database( + fd, expected, io.BytesIO(), deadline=time.monotonic() + 1 + ) + finally: + owner.os.close(fd) + + +def test_temporary_copy_handles_incomparable_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + real_commonpath = owner.os.path.commonpath + monkeypatch.setattr( + owner.os.path, "commonpath", lambda _paths: (_ for _ in ()).throw(ValueError()) + ) + + source = tmp_path / "source" + source.write_bytes(b"exact") + fd = owner._open(source, owner.os.O_RDONLY) + try: + expected = owner._stat_identity(owner.os.fstat(fd)) + with owner._temporary_copy( + fd, expected, str(tmp_path), deadline=time.monotonic() + 1 + ) as copy: + contents = copy.read_bytes() + finally: + owner.os.close(fd) + + monkeypatch.setattr(owner.os.path, "commonpath", real_commonpath) + assert contents == b"exact" + + +def test_certify_private_copy_rejects_active_build( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(owner, "validate_snapshot_schema", lambda *_a, **_k: None) + monkeypatch.setattr(owner, "build_in_progress", lambda _conn: True) + conn = sqlite3.connect(":memory:") + try: + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + owner._certify_private_copy(conn, "/project", deadline=time.monotonic() + 1) + finally: + conn.close() + + +def test_certify_private_copy_reports_invalid_scope_descriptor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = {"source_scope_descriptor": "invalid"} + _certification_dependencies(monkeypatch, manifest=manifest) + import tree_sitter_analyzer.index_source_snapshot as source_module + + monkeypatch.setattr( + source_module, + "parse_source_scope_descriptor", + lambda _raw: (_ for _ in ()).throw(ValueError()), + ) + conn = sqlite3.connect(":memory:") + try: + result = owner._certify_private_copy( + conn, "/project", deadline=time.monotonic() + 1 + ) + finally: + conn.close() + + assert (result.completeness, result.reason, result.source_scope) == ( + "partial", + "SOURCE_SCOPE_DESCRIPTOR_INVALID", + None, + ) + + +def test_certify_private_copy_reports_inexact_current_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = {"source_scope_descriptor": "scope"} + _certification_dependencies(monkeypatch, manifest=manifest) + monkeypatch.setattr( + owner, + "_capture_constraint_sources", + lambda *_a: SimpleNamespace(state="partial", reason="SOURCE_CHANGED"), + ) + conn = sqlite3.connect(":memory:") + try: + result = owner._certify_private_copy( + conn, "/project", deadline=time.monotonic() + 1 + ) + finally: + conn.close() + + assert (result.completeness, result.reason) == ("partial", "SOURCE_CHANGED") + assert result.source_scope is not None + + +def test_portable_snapshot_rejects_identity_change_after_pinned_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path) + real_identity = owner._identity + root_calls = 0 + + def identity(path: Path, *, directory: bool): + nonlocal root_calls + value = real_identity(path, directory=directory) + if path == tmp_path: + root_calls += 1 + if root_calls == 2: + return (value[0] + 1, *value[1:]) + return value + + monkeypatch.setattr(owner, "_identity", identity) + with pytest.raises(ValueError, match="^CONCURRENT_WRITER$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("changed identity published") + + +def test_portable_snapshot_rejects_missing_private_connection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path) + + class Source: + def execute(self, sql: str): + self.sql = sql + return self + + def fetchone(self): + return (4096,) if "page_size" in self.sql else (0,) + + def backup(self, _private, **_kwargs): + pass + + def close(self): + pass + + connections = iter((Source(), None)) + monkeypatch.setattr(owner.sqlite3, "connect", lambda *_a, **_k: next(connections)) + monkeypatch.setattr(owner, "require_memory_temp_store", lambda _conn: None) + + with pytest.raises(ValueError, match="^CONSTRAINT_INDEX_UNKNOWN$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("missing connection published") + + +def test_portable_snapshot_retries_source_cleanup_after_close_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path) + + class Source: + closes = 0 + + def execute(self, sql: str): + self.sql = sql + return self + + def fetchone(self): + return (4096,) if "page_size" in self.sql else (0,) + + def backup(self, _private, **_kwargs): + pass + + def close(self): + self.closes += 1 + if self.closes == 1: + raise RuntimeError("close failed") + + source = Source() + private = SimpleNamespace(close=lambda: None) + connections = iter((source, private)) + monkeypatch.setattr(owner.sqlite3, "connect", lambda *_a, **_k: next(connections)) + monkeypatch.setattr(owner, "require_memory_temp_store", lambda _conn: None) + + with pytest.raises(RuntimeError, match="^close failed$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("failed cleanup published") + + assert source.closes == 2 + + +def test_portable_snapshot_closes_no_descriptor_when_open_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path) + monkeypatch.setattr( + owner, + "_open_database_fd", + lambda *_a: (_ for _ in ()).throw(OSError("open failed")), + ) + + with pytest.raises(OSError, match="^open failed$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("failed open published") + + +def test_portable_snapshot_closes_private_connection_when_backup_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path) + + class Source: + def execute(self, sql: str): + self.sql = sql + return self + + def fetchone(self): + return (4096,) if "page_size" in self.sql else (0,) + + def backup(self, _private, **_kwargs): + raise RuntimeError("backup failed") + + def close(self): + pass + + closed: list[str] = [] + private = SimpleNamespace(close=lambda: closed.append("private")) + connections = iter((Source(), private)) + monkeypatch.setattr(owner.sqlite3, "connect", lambda *_a, **_k: next(connections)) + monkeypatch.setattr(owner, "require_memory_temp_store", lambda _conn: None) + + with pytest.raises(RuntimeError, match="^backup failed$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("failed backup published") + + assert closed == ["private"] + + +def test_portable_snapshot_rejects_unavailable_source_connection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _database(tmp_path) + monkeypatch.setattr(owner.sqlite3, "connect", lambda *_a, **_k: None) + + with pytest.raises(AttributeError, match="execute"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("unavailable source published") + + +def test_close_optional_fd_handles_absent_and_open_descriptor(monkeypatch): + calls = [] + monkeypatch.setattr(owner.os, "close", calls.append) + owner._close_optional_fd(None) + owner._close_optional_fd(7) + assert calls == [7] + + +def test_portable_snapshot_maps_certification_database_error(tmp_path, monkeypatch): + _database(tmp_path) + monkeypatch.setattr( + owner, + "_certify_private_copy", + lambda *_a, **_kw: (_ for _ in ()).throw(sqlite3.DatabaseError("bad schema")), + ) + with pytest.raises(ValueError, match="^CORRUPT_INDEX$"): + with owner.portable_ordinary_snapshot( + str(tmp_path), deadline=time.monotonic() + 1 + ): + pytest.fail("corrupt certification published") + + +def test_evaluate_ordinary_snapshot_rejects_partial_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + @contextmanager + def portable(*_args, **_kwargs): + yield owner.OrdinaryConstraintSnapshot("partial", "STALE", None), object() + + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: True) + monkeypatch.setattr(owner, "portable_ordinary_snapshot", portable) + tool = SimpleNamespace(project_root="/project") + + with pytest.raises(ValueError, match="^STALE$"): + owner.evaluate_ordinary_snapshot( + tool, + [], + path_filter="", + min_severity_rank=0, + scope_paths=None, + evaluator=None, + deadline=1.0, + ) + + +@pytest.mark.parametrize( + ("state", "expected_fp", "current_fp", "error"), + [ + ("partial", "fp", "fp", "SOURCE_CHANGED"), + ("exact", None, "fp", "SOURCE_GENERATION_MISMATCH"), + ("exact", "fp", "other", "SOURCE_GENERATION_MISMATCH"), + ("exact", "fp", "fp", None), + ], +) +def test_evaluate_ordinary_snapshot_revalidates_source_after_evaluation( + monkeypatch, state, expected_fp, current_fp, error +) -> None: + conn = sqlite3.connect(":memory:") + scope = object() + authority = SimpleNamespace( + completeness="complete", + reason=None, + source_scope=scope, + source_generation=None, + source_fingerprint=expected_fp, + canonical_root="/project", + ) + + @contextmanager + def portable(*_args, **_kwargs): + yield authority, conn + + current = SimpleNamespace( + state=state, + reason="SOURCE_CHANGED", + generation="generation", + fingerprint=current_fp, + ) + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: True) + monkeypatch.setattr(owner, "portable_ordinary_snapshot", portable) + monkeypatch.setattr( + owner, "ordinary_source_scope_is_full", lambda value: value is scope + ) + monkeypatch.setattr(owner, "_capture_constraint_sources", lambda *_args: current) + tool = SimpleNamespace( + project_root="/project", _evaluate_connection=lambda *_a, **_k: ([], 0) + ) + + def call(): + return owner.evaluate_ordinary_snapshot( + tool, + [], + path_filter="", + min_severity_rank=0, + scope_paths=None, + evaluator=None, + deadline=1.0, + ) + + try: + if error is None: + assert call() == ([], 0) + else: + with pytest.raises(ValueError, match=f"^{error}$"): + call() + finally: + conn.close() + + +def test_evaluate_ordinary_snapshot_revalidates_through_canonical_root( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # PR #1254 review 3771670613: a symlink spelling cannot poison final evidence. + conn = sqlite3.connect(":memory:") + scope = object() + authority = SimpleNamespace( + completeness="complete", + reason=None, + source_scope=scope, + source_generation="generation", + source_fingerprint="fingerprint", + canonical_root="/real/project", + ) + + @contextmanager + def portable(*_args, **_kwargs): + yield authority, conn + + calls: list[tuple[object, ...]] = [] + + def capture(root, selected_scope, deadline): + calls.append((root, selected_scope, deadline)) + return SimpleNamespace( + state="exact", + reason=None, + generation="generation", + fingerprint="fingerprint", + ) + + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: True) + monkeypatch.setattr(owner, "portable_ordinary_snapshot", portable) + monkeypatch.setattr(owner, "ordinary_source_scope_is_full", lambda value: True) + monkeypatch.setattr(owner, "_capture_constraint_sources", capture) + tool = SimpleNamespace( + project_root="/link/project", _evaluate_connection=lambda *_a, **_k: ([], 0) + ) + + try: + result = owner.evaluate_ordinary_snapshot( + tool, + [], + path_filter="", + min_severity_rank=0, + scope_paths=None, + evaluator=None, + deadline=7.0, + ) + finally: + conn.close() + + assert result == ([], 0) + assert calls == [("/real/project", scope, 7.0)] + + +def test_evaluate_ordinary_snapshot_requires_canonical_source_root( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Complete source evidence must stay bound to the certified root identity. + conn = sqlite3.connect(":memory:") + scope = object() + authority = SimpleNamespace( + completeness="complete", + reason=None, + source_scope=scope, + source_generation="generation", + canonical_root=None, + ) + + @contextmanager + def portable(*_args, **_kwargs): + yield authority, conn + + monkeypatch.setattr(owner, "portable_snapshot_required", lambda: True) + monkeypatch.setattr(owner, "portable_ordinary_snapshot", portable) + monkeypatch.setattr(owner, "ordinary_source_scope_is_full", lambda _value: True) + tool = SimpleNamespace( + project_root="/project", _evaluate_connection=lambda *_a, **_k: ([], 0) + ) + + try: + with pytest.raises(ValueError, match="^CONSTRAINT_INDEX_UNKNOWN$"): + owner.evaluate_ordinary_snapshot( + tool, + [], + path_filter="", + min_severity_rank=0, + scope_paths=None, + evaluator=None, + deadline=7.0, + ) + finally: + conn.close() diff --git a/tests/unit/mcp/tools/test_edit_facade.py b/tests/unit/mcp/tools/test_edit_facade.py index e24324b23..0c857409c 100644 --- a/tests/unit/mcp/tools/test_edit_facade.py +++ b/tests/unit/mcp/tools/test_edit_facade.py @@ -24,18 +24,11 @@ from __future__ import annotations import asyncio -from pathlib import Path from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest -import tree_sitter_analyzer.diff_snapshot_registry as snapshots -from tests.unit._diff_snapshot_support import ( - POSIX_SNAPSHOT_TEST, - install_fake_snapshot_materializer, - make_repo, -) from tree_sitter_analyzer.mcp.tools.base_tool import BaseMCPTool from tree_sitter_analyzer.mcp.tools.facade_tool import FacadeTool @@ -464,476 +457,14 @@ def test_constraints_action_does_not_leak_action_to_inner(tmp_path: Any) -> None assert "success" in result -# --------------------------------------------------------------------------- -# 12. Annotations correctness — edit facade must NOT declare readOnlyHint=True -# --------------------------------------------------------------------------- - - -def test_edit_annotations_not_read_only() -> None: - """edit facade spans mutating-intent actions — readOnlyHint must be False.""" - from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_ANNOTATIONS - - assert _EDIT_ANNOTATIONS["readOnlyHint"] is False, ( - "edit facade cannot claim readOnlyHint=True (mixed read+mutating-intent actions)" - ) - - -def test_edit_annotations_not_destructive() -> None: - """edit facade suggests/analyses; it does not write files.""" - from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_ANNOTATIONS - - assert _EDIT_ANNOTATIONS["destructiveHint"] is False - - -def test_edit_annotations_all_four_hints_present() -> None: - """test_every_tool_declares_mcp_annotations requires all 4 hint keys.""" - from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_ANNOTATIONS - - required = {"readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"} - assert required.issubset(_EDIT_ANNOTATIONS.keys()) - - -def test_edit_facade_definition_includes_annotations() -> None: +def test_scope_paths_is_rejected_outside_impact_and_constraints() -> None: + # PR #1254 review 3769281322: explicit facade scope must never be dropped. from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade facade = build_edit_facade(project_root=None) - defn = facade.get_tool_definition() - assert "annotations" in defn - annot = defn["annotations"] - assert annot["readOnlyHint"] is False - assert annot["destructiveHint"] is False - - -# --------------------------------------------------------------------------- -# 13. Facade description honesty — ast_diff description uses REAL mode params -# (Leg D of issue #529 triple-fix) -# --------------------------------------------------------------------------- - - -def test_ast_diff_facade_description_uses_real_mode_params() -> None: - """Leg D: the ast_diff description in the edit facade must reference the - REAL mode signatures (old_file/new_file | old_source/new_source | - old_ref/new_ref) and must NOT use the nonexistent 'before, after' params. - """ - from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_DESCRIPTION - - # Must contain real param names - assert "old_ref" in _EDIT_DESCRIPTION, ( - "ast_diff facade description must mention 'old_ref' (diff_git signature)" - ) - assert "old_file" in _EDIT_DESCRIPTION or "new_file" in _EDIT_DESCRIPTION, ( - "ast_diff facade description must mention 'old_file'/'new_file' (diff_files signature)" - ) - assert "old_source" in _EDIT_DESCRIPTION or "new_source" in _EDIT_DESCRIPTION, ( - "ast_diff facade description must mention 'old_source'/'new_source' (diff_strings signature)" - ) - - # Must NOT use the nonexistent 'before, after' params - assert "before, after" not in _EDIT_DESCRIPTION, ( - "ast_diff facade description must NOT use nonexistent 'before, after' params" - ) - - -# --------------------------------------------------------------------------- -# Schema sanity -# --------------------------------------------------------------------------- - - -def test_edit_facade_schema_includes_action_and_required() -> None: - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + result = asyncio.run(facade.execute({"action": "safe", "scope_paths": ["src"]})) - facade = build_edit_facade(project_root=None) - schema = facade.get_tool_schema() - props = schema["properties"] - assert "action" in props - assert "action" in schema.get("required", []) - # action enum must list all 8 actions. - enum_vals = set(props["action"].get("enum", [])) - expected = { - "safe", - "guard", - "impact", - "refactor", - "constraints", - "pr", - "classify", - "ast_diff", - "release_snapshot", - } - assert expected == enum_vals - - -def test_edit_facade_schema_lenient_additional_properties() -> None: - """The merged facade schema must be lenient (additionalProperties not False).""" - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - facade = build_edit_facade(project_root=None) - schema = facade.get_tool_schema() - # The schema must be additionalProperties: True (lenient), not False (strict). - assert schema.get("additionalProperties") is True - - -# --------------------------------------------------------------------------- -# Issue #451 — edit action=pr without pr_url must fail loudly via the facade -# --------------------------------------------------------------------------- - - -def test_edit_pr_action_missing_pr_url_fails_loudly() -> None: - """action=pr without pr_url → success:False, ERROR verdict, not 'No changed files'. - - Regression guard for issue #451: an agent that misnames the param (e.g. - uses query= instead of pr_url=) would have the extra param stripped by - facade projection, leaving only {mode:pr}. The inner must return an error - envelope, not silently fall through to an empty local diff review. - """ - facade, inners = _make_fake_facade() - # Replace the fake 'pr' inner with a real CodeGraphPRReviewTool - from tree_sitter_analyzer.mcp.tools.codegraph_pr_review_tool import ( - CodeGraphPRReviewTool, - ) - - real_pr_inner = CodeGraphPRReviewTool(project_root=None) - facade.action_map["pr"] = real_pr_inner - - # mode=pr but no pr_url (simulates post-projection args) - result = asyncio.run(facade.execute({"action": "pr", "mode": "pr"})) assert result["success"] is False - assert result.get("verdict") == "ERROR" - assert "pr_url" in result.get("error", "") - - -if __name__ == "__main__": - raise SystemExit(pytest.main([__file__, "-q"])) - - -# --------------------------------------------------------------------------- -# Issue #641 — edit facade schema must expose modification_type with enum -# for action=guard discoverability (extra_public_params, NOT required:[]) -# --------------------------------------------------------------------------- - - -def test_edit_facade_schema_has_modification_type_property() -> None: - """Schema must declare modification_type so schema-reading agents see it. - - Before fix: modification_type was only reachable via additionalProperties - (invisible to schema inspection). After fix: it appears in properties with - the authoritative enum — matching the inner ModificationGuardTool schema. - """ - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - facade = build_edit_facade(project_root=None) - schema = facade.get_tool_schema() - props = schema["properties"] - assert "modification_type" in props, ( - "modification_type must be declared in the edit facade's public schema " - "(not hidden behind additionalProperties)" + assert result["error"] == ( + "parameter 'scope_paths' applies only to action(s): constraints, impact" ) - - -def test_edit_facade_modification_type_has_enum() -> None: - """modification_type property must carry the full authoritative enum.""" - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - from tree_sitter_analyzer.mcp.tools.modification_guard_tool import ( - MODIFICATION_TYPES, - ) - - facade = build_edit_facade(project_root=None) - schema = facade.get_tool_schema() - prop = schema["properties"]["modification_type"] - assert "enum" in prop, "modification_type must declare an enum" - assert set(prop["enum"]) == set(MODIFICATION_TYPES), ( - "facade modification_type enum must match the inner tool's MODIFICATION_TYPES constant" - ) - - -def test_edit_facade_modification_type_NOT_in_required() -> None: - """modification_type must NOT be in facade required[] (runtime-resolved param). - - LOCKED convention: runtime-required params are described in the description - text, not in schema required: [] — this prevents the facade validator from - rejecting calls before routing (facade required only lists 'action'). - """ - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - facade = build_edit_facade(project_root=None) - schema = facade.get_tool_schema() - assert "modification_type" not in schema.get("required", []), ( - "modification_type must NOT appear in facade required[] " - "(runtime-resolved param — locked convention, #397 family)" - ) - - -def test_edit_facade_guard_description_marks_modification_type_required() -> None: - """action=guard description must mark modification_type as required (e.g. with *). - - Before fix: the description listed 'Params: symbol, modification_type, - file_path' without any required marker — agents had no signal that omitting - modification_type triggers an error on the first call. - """ - from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_DESCRIPTION - - # The guard line must mark modification_type as required (trailing * or explicit note) - guard_lines = [ - line for line in _EDIT_DESCRIPTION.splitlines() if "action=guard" in line - ] - assert guard_lines, "edit facade description must have an action=guard line" - guard_line = guard_lines[0] - assert ( - "modification_type*" in guard_line - or "modification_type (required" in guard_line - ), ( - f"action=guard description line must mark modification_type as required " - f"(e.g. 'modification_type*'); got: {guard_line!r}" - ) - - -def test_action_pr_without_mode_or_pr_url_fails_loudly() -> None: - """Codex P1 (#483): facade action=pr with NO explicit mode must not - fall back to the inner's diff default and return empty success. - - ``edit({"action": "pr", "query": ""})`` (typoed param) previously - reached the inner without mode → diff mode → success "No changed files". - The facade pr route now implies mode=pr, so the pr_url guard fires.""" - import asyncio - - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - facade = build_edit_facade(".") - result = asyncio.run( - facade.execute({"action": "pr", "query": "https://github.com/o/r/pull/1"}) - ) - assert result["success"] is False - assert "pr_url" in result["error"] - - -def test_action_pr_explicit_diff_mode_still_reaches_diff() -> None: - """Direct sub-mode selection stays available through the facade.""" - import asyncio - - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - facade = build_edit_facade(".") - with patch( - "tree_sitter_analyzer.mcp.tools.codegraph_pr_review_tool._get_local_diff", - return_value="", - ) as get_local_diff: - result = asyncio.run(facade.execute({"action": "pr", "mode": "diff"})) - - get_local_diff.assert_called_once_with("diff", ".") - # diff mode reviews local changes — must not demand pr_url - assert result["success"] is True - assert result.get("error") is None or "pr_url" not in str(result.get("error")) - - -@pytest.mark.asyncio -async def test_edit_impact_preserves_legacy_branch_mode(monkeypatch) -> None: - from tree_sitter_analyzer.mcp.tools.change_impact_tool import ChangeImpactTool - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - seen: list[dict[str, object]] = [] - - async def fake_execute(self, arguments): - seen.append(arguments) - return {"success": True} - - monkeypatch.setattr(ChangeImpactTool, "execute", fake_execute) - await build_edit_facade(None).execute({"action": "impact", "mode": "branch"}) - - assert seen == [{"mode": "branch"}] - - -@pytest.mark.asyncio -async def test_edit_snapshot_consumer_rejects_conflicting_arguments() -> None: - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - with pytest.raises(ValueError, match="DIFF_SNAPSHOT_CONFLICTING_ARGUMENTS"): - await build_edit_facade(None).execute( - { - "action": "ast_diff", - "diff_snapshot_id": "ds", - "file_path": "x.py", - "old_code": "bad", - } - ) - - -@pytest.mark.asyncio -async def test_edit_snapshot_consumer_accepts_only_frozen_arguments() -> None: - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - result = await build_edit_facade(None).execute( - { - "action": "ast_diff", - "diff_snapshot_id": "missing", - "file_path": "x.py", - "output_format": "json", - } - ) - assert result["error_code"] == "DIFF_SNAPSHOT_EXPIRED" - - -@POSIX_SNAPSHOT_TEST -def test_edit_impact_snapshot_opt_in_does_not_write(tmp_path: Path) -> None: - import asyncio - - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - root = make_repo(tmp_path) - (root / "old.py").write_text("value = 2\n") - before = {path.relative_to(root) for path in root.rglob("*")} - facade = build_edit_facade(str(root)) - - result = asyncio.run( - facade.execute( - { - "action": "impact", - "mode": "diff", - "capture_diff_snapshot": True, - "output_format": "json", - } - ) - ) - - assert result["success"] is True - assert result["changed_files"] == ["old.py"] - assert {path.relative_to(root) for path in root.rglob("*")} == before - assert ( - snapshots.close_route_lease( - str(result["diff_snapshot_id"]), str(result["route_lease_id"]) - ) - is True - ) - - -@POSIX_SNAPSHOT_TEST -def test_edit_impact_rejects_clean_tracked_transient_write_restore( - tmp_path: Path, monkeypatch -) -> None: - """Strict impact cannot certify analysis that observed a transient clean file.""" - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - from tree_sitter_analyzer.mcp.tools.utils import change_impact_analysis - - # RFC-0022 P0.2 review 2026-07-01: dependency analysis consumed a clean - # tracked transient and certified success after the callback restored it. - root = make_repo(tmp_path) - changed = root / "old.py" - dependency = root / "gone.py" - changed.write_text("value = 2\n") - original = dependency.read_bytes() - observations: list[bytes] = [] - - def legacy_dependency_analysis(_project_root): - dependency.write_bytes(b"TRANSIENT = True\n") - observations.append(dependency.read_bytes()) - dependency.write_bytes(original) - return None - - monkeypatch.setattr( - change_impact_analysis, "_load_dependency_graph", legacy_dependency_analysis - ) - - result = asyncio.run( - build_edit_facade(str(root)).execute( - { - "action": "impact", - "mode": "diff", - "capture_diff_snapshot": True, - "output_format": "json", - } - ) - ) - - assert observations == [] - assert dependency.read_bytes() == original - assert result["affected_files_unknown"] is True - - -def test_edit_release_snapshot_is_same_process_reachable_and_idempotent( - tmp_path: Path, monkeypatch -) -> None: - # PR #1252 review thread 3746878592. - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - root = make_repo(tmp_path) - (root / "old.py").write_text("value = 2\n") - install_fake_snapshot_materializer(monkeypatch, root) - registry = snapshots.DiffSnapshotRegistry() - monkeypatch.setattr(snapshots, "REGISTRY", registry) - created = registry.create(str(root), "diff", []) - args = { - "action": "release_snapshot", - "diff_snapshot_id": created["diff_snapshot_id"], - "route_lease_id": created["route_lease_id"], - "output_format": "json", - } - facade = build_edit_facade(str(root)) - - first = asyncio.run(facade.execute(args)) - second = asyncio.run(facade.execute(args)) - - assert (first["released"], second["released"]) == (True, True) - - -def test_edit_release_snapshot_rejects_wrong_ownership_token( - tmp_path: Path, monkeypatch -) -> None: - # PR #1252 review thread 3746878592. - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - root = make_repo(tmp_path) - install_fake_snapshot_materializer(monkeypatch, root) - registry = snapshots.DiffSnapshotRegistry() - monkeypatch.setattr(snapshots, "REGISTRY", registry) - created = registry.create(str(root), "diff", []) - - result = asyncio.run( - build_edit_facade(str(root)).execute( - { - "action": "release_snapshot", - "diff_snapshot_id": created["diff_snapshot_id"], - "route_lease_id": "wrong", - "output_format": "json", - } - ) - ) - - assert result["error_code"] == "DIFF_SNAPSHOT_LEASE_MISMATCH" - - -def test_edit_release_snapshot_rejects_alternate_source_arguments() -> None: - # PR #1252 review thread 3746878592. - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - with pytest.raises(ValueError, match="DIFF_SNAPSHOT_CONFLICTING_ARGUMENTS"): - asyncio.run( - build_edit_facade(".").execute( - { - "action": "release_snapshot", - "diff_snapshot_id": "ds", - "route_lease_id": "lease", - "file_path": "alternate.py", - } - ) - ) - - -def test_edit_release_snapshot_requires_both_ownership_ids() -> None: - # PR #1252 review thread 3746878592. - from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade - - with pytest.raises( - ValueError, match="diff_snapshot_id and route_lease_id are required" - ): - asyncio.run( - build_edit_facade(".").execute( - {"action": "release_snapshot", "diff_snapshot_id": "ds"} - ) - ) - - -def test_change_impact_annotation_is_non_idempotent_for_optional_capture() -> None: - # PR #1252 review thread 3747113064. - from tree_sitter_analyzer.mcp.tools.change_impact_tool import ChangeImpactTool - - definition = ChangeImpactTool().get_tool_definition() - assert definition["annotations"]["idempotentHint"] is False diff --git a/tests/unit/mcp/tools/test_edit_facade_schema.py b/tests/unit/mcp/tools/test_edit_facade_schema.py new file mode 100644 index 000000000..81fc74c21 --- /dev/null +++ b/tests/unit/mcp/tools/test_edit_facade_schema.py @@ -0,0 +1,238 @@ +"""Schema and public-contract coverage for the edit facade.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +from tests.unit.mcp.tools.test_edit_facade import _make_fake_facade + + +def test_edit_annotations_not_read_only() -> None: + """edit facade spans mutating-intent actions — readOnlyHint must be False.""" + from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_ANNOTATIONS + + assert _EDIT_ANNOTATIONS["readOnlyHint"] is False, ( + "edit facade cannot claim readOnlyHint=True (mixed read+mutating-intent actions)" + ) + + +def test_edit_annotations_not_destructive() -> None: + """edit facade suggests/analyses; it does not write files.""" + from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_ANNOTATIONS + + assert _EDIT_ANNOTATIONS["destructiveHint"] is False + + +def test_edit_annotations_all_four_hints_present() -> None: + """test_every_tool_declares_mcp_annotations requires all 4 hint keys.""" + from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_ANNOTATIONS + + required = {"readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"} + assert required.issubset(_EDIT_ANNOTATIONS.keys()) + + +def test_edit_facade_definition_includes_annotations() -> None: + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + facade = build_edit_facade(project_root=None) + defn = facade.get_tool_definition() + assert "annotations" in defn + annot = defn["annotations"] + assert annot["readOnlyHint"] is False + assert annot["destructiveHint"] is False + + +def test_ast_diff_facade_description_uses_real_mode_params() -> None: + """Leg D: the ast_diff description in the edit facade must reference the + REAL mode signatures (old_file/new_file | old_source/new_source | + old_ref/new_ref) and must NOT use the nonexistent 'before, after' params. + """ + from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_DESCRIPTION + + # Must contain real param names + assert "old_ref" in _EDIT_DESCRIPTION, ( + "ast_diff facade description must mention 'old_ref' (diff_git signature)" + ) + assert "old_file" in _EDIT_DESCRIPTION or "new_file" in _EDIT_DESCRIPTION, ( + "ast_diff facade description must mention 'old_file'/'new_file' (diff_files signature)" + ) + assert "old_source" in _EDIT_DESCRIPTION or "new_source" in _EDIT_DESCRIPTION, ( + "ast_diff facade description must mention 'old_source'/'new_source' (diff_strings signature)" + ) + + # Must NOT use the nonexistent 'before, after' params + assert "before, after" not in _EDIT_DESCRIPTION, ( + "ast_diff facade description must NOT use nonexistent 'before, after' params" + ) + + +def test_edit_facade_schema_includes_action_and_required() -> None: + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + facade = build_edit_facade(project_root=None) + schema = facade.get_tool_schema() + props = schema["properties"] + assert "action" in props + assert "action" in schema.get("required", []) + # action enum must list all 8 actions. + enum_vals = set(props["action"].get("enum", [])) + expected = { + "safe", + "guard", + "impact", + "refactor", + "constraints", + "pr", + "classify", + "ast_diff", + "release_snapshot", + } + assert expected == enum_vals + + +def test_edit_facade_schema_lenient_additional_properties() -> None: + """The merged facade schema must be lenient (additionalProperties not False).""" + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + facade = build_edit_facade(project_root=None) + schema = facade.get_tool_schema() + # The schema must be additionalProperties: True (lenient), not False (strict). + assert schema.get("additionalProperties") is True + + +def test_edit_pr_action_missing_pr_url_fails_loudly() -> None: + """action=pr without pr_url → success:False, ERROR verdict, not 'No changed files'. + + Regression guard for issue #451: an agent that misnames the param (e.g. + uses query= instead of pr_url=) would have the extra param stripped by + facade projection, leaving only {mode:pr}. The inner must return an error + envelope, not silently fall through to an empty local diff review. + """ + facade, inners = _make_fake_facade() + # Replace the fake 'pr' inner with a real CodeGraphPRReviewTool + from tree_sitter_analyzer.mcp.tools.codegraph_pr_review_tool import ( + CodeGraphPRReviewTool, + ) + + real_pr_inner = CodeGraphPRReviewTool(project_root=None) + facade.action_map["pr"] = real_pr_inner + + # mode=pr but no pr_url (simulates post-projection args) + result = asyncio.run(facade.execute({"action": "pr", "mode": "pr"})) + assert result["success"] is False + assert result.get("verdict") == "ERROR" + assert "pr_url" in result.get("error", "") + + +def test_edit_facade_schema_has_modification_type_property() -> None: + """Schema must declare modification_type so schema-reading agents see it. + + Before fix: modification_type was only reachable via additionalProperties + (invisible to schema inspection). After fix: it appears in properties with + the authoritative enum — matching the inner ModificationGuardTool schema. + """ + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + facade = build_edit_facade(project_root=None) + schema = facade.get_tool_schema() + props = schema["properties"] + assert "modification_type" in props, ( + "modification_type must be declared in the edit facade's public schema " + "(not hidden behind additionalProperties)" + ) + + +def test_edit_facade_modification_type_has_enum() -> None: + """modification_type property must carry the full authoritative enum.""" + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + from tree_sitter_analyzer.mcp.tools.modification_guard_tool import ( + MODIFICATION_TYPES, + ) + + facade = build_edit_facade(project_root=None) + schema = facade.get_tool_schema() + prop = schema["properties"]["modification_type"] + assert "enum" in prop, "modification_type must declare an enum" + assert set(prop["enum"]) == set(MODIFICATION_TYPES), ( + "facade modification_type enum must match the inner tool's MODIFICATION_TYPES constant" + ) + + +def test_edit_facade_modification_type_NOT_in_required() -> None: + """modification_type must NOT be in facade required[] (runtime-resolved param). + + LOCKED convention: runtime-required params are described in the description + text, not in schema required: [] — this prevents the facade validator from + rejecting calls before routing (facade required only lists 'action'). + """ + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + facade = build_edit_facade(project_root=None) + schema = facade.get_tool_schema() + assert "modification_type" not in schema.get("required", []), ( + "modification_type must NOT appear in facade required[] " + "(runtime-resolved param — locked convention, #397 family)" + ) + + +def test_edit_facade_guard_description_marks_modification_type_required() -> None: + """action=guard description must mark modification_type as required (e.g. with *). + + Before fix: the description listed 'Params: symbol, modification_type, + file_path' without any required marker — agents had no signal that omitting + modification_type triggers an error on the first call. + """ + from tree_sitter_analyzer.mcp.tools.edit_facade import _EDIT_DESCRIPTION + + # The guard line must mark modification_type as required (trailing * or explicit note) + guard_lines = [ + line for line in _EDIT_DESCRIPTION.splitlines() if "action=guard" in line + ] + assert guard_lines, "edit facade description must have an action=guard line" + guard_line = guard_lines[0] + assert ( + "modification_type*" in guard_line + or "modification_type (required" in guard_line + ), ( + f"action=guard description line must mark modification_type as required " + f"(e.g. 'modification_type*'); got: {guard_line!r}" + ) + + +def test_action_pr_without_mode_or_pr_url_fails_loudly() -> None: + """Codex P1 (#483): facade action=pr with NO explicit mode must not + fall back to the inner's diff default and return empty success. + + ``edit({"action": "pr", "query": ""})`` (typoed param) previously + reached the inner without mode → diff mode → success "No changed files". + The facade pr route now implies mode=pr, so the pr_url guard fires.""" + import asyncio + + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + facade = build_edit_facade(".") + result = asyncio.run( + facade.execute({"action": "pr", "query": "https://github.com/o/r/pull/1"}) + ) + assert result["success"] is False + assert "pr_url" in result["error"] + + +def test_action_pr_explicit_diff_mode_still_reaches_diff() -> None: + """Direct sub-mode selection stays available through the facade.""" + import asyncio + + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + facade = build_edit_facade(".") + with patch( + "tree_sitter_analyzer.mcp.tools.codegraph_pr_review_tool._get_local_diff", + return_value="", + ) as get_local_diff: + result = asyncio.run(facade.execute({"action": "pr", "mode": "diff"})) + + get_local_diff.assert_called_once_with("diff", ".") + # diff mode reviews local changes — must not demand pr_url + assert result["success"] is True + assert result.get("error") is None or "pr_url" not in str(result.get("error")) diff --git a/tests/unit/mcp/tools/test_edit_facade_snapshot_routes.py b/tests/unit/mcp/tools/test_edit_facade_snapshot_routes.py new file mode 100644 index 000000000..2393f0c3a --- /dev/null +++ b/tests/unit/mcp/tools/test_edit_facade_snapshot_routes.py @@ -0,0 +1,370 @@ +"""Snapshot and constraint route coverage for the edit facade.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +import tree_sitter_analyzer.diff_snapshot_registry as snapshots +from tests.unit._diff_snapshot_support import ( + POSIX_SNAPSHOT_TEST, + install_fake_snapshot_materializer, + make_repo, +) + + +@pytest.mark.asyncio +async def test_edit_impact_preserves_legacy_branch_mode(monkeypatch) -> None: + from tree_sitter_analyzer.mcp.tools.change_impact_tool import ChangeImpactTool + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + seen: list[dict[str, object]] = [] + + async def fake_execute(self, arguments): + seen.append(arguments) + return {"success": True} + + monkeypatch.setattr(ChangeImpactTool, "execute", fake_execute) + await build_edit_facade(None).execute({"action": "impact", "mode": "branch"}) + + assert seen == [{"mode": "branch"}] + + +@pytest.mark.asyncio +async def test_edit_snapshot_consumer_rejects_conflicting_arguments() -> None: + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + with pytest.raises(ValueError, match="DIFF_SNAPSHOT_CONFLICTING_ARGUMENTS"): + await build_edit_facade(None).execute( + { + "action": "ast_diff", + "diff_snapshot_id": "ds", + "file_path": "x.py", + "old_code": "bad", + } + ) + + +@pytest.mark.asyncio +async def test_edit_snapshot_consumer_accepts_only_frozen_arguments() -> None: + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + result = await build_edit_facade(None).execute( + { + "action": "ast_diff", + "diff_snapshot_id": "missing", + "file_path": "x.py", + "output_format": "json", + } + ) + assert result["error_code"] == "DIFF_SNAPSHOT_EXPIRED" + + +@POSIX_SNAPSHOT_TEST +def test_edit_impact_snapshot_opt_in_does_not_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import asyncio + + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + root = make_repo(tmp_path) + (root / "old.py").write_text("value = 2\n") + from tree_sitter_analyzer.diff_snapshot_capture import ChangedFile + + install_fake_snapshot_materializer( + monkeypatch, + root, + records=[ChangedFile("old.py", "M", True, True, False)], + inventory_paths=["old.py"], + ) + before = {path.relative_to(root) for path in root.rglob("*")} + facade = build_edit_facade(str(root)) + + result = asyncio.run( + facade.execute( + { + "action": "impact", + "mode": "diff", + "capture_diff_snapshot": True, + "output_format": "json", + } + ) + ) + + assert result["success"] is True + assert result["changed_files"] == ["old.py"] + assert {path.relative_to(root) for path in root.rglob("*")} == before + assert ( + snapshots.close_route_lease( + str(result["diff_snapshot_id"]), str(result["route_lease_id"]) + ) + is True + ) + + +@POSIX_SNAPSHOT_TEST +def test_edit_impact_rejects_clean_tracked_transient_write_restore( + tmp_path: Path, monkeypatch +) -> None: + """Strict impact cannot certify analysis that observed a transient clean file.""" + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + from tree_sitter_analyzer.mcp.tools.utils import change_impact_analysis + + # RFC-0022 P0.2 review 2026-07-01: dependency analysis consumed a clean + # tracked transient and certified success after the callback restored it. + root = make_repo(tmp_path) + changed = root / "old.py" + dependency = root / "gone.py" + changed.write_text("value = 2\n") + original = dependency.read_bytes() + from tree_sitter_analyzer.diff_snapshot_capture import ChangedFile + + install_fake_snapshot_materializer( + monkeypatch, + root, + records=[ChangedFile("old.py", "M", True, True, False)], + inventory_paths=["old.py"], + ) + observations: list[bytes] = [] + + def legacy_dependency_analysis(_project_root): + dependency.write_bytes(b"TRANSIENT = True\n") + observations.append(dependency.read_bytes()) + dependency.write_bytes(original) + return None + + monkeypatch.setattr( + change_impact_analysis, "_load_dependency_graph", legacy_dependency_analysis + ) + + result = asyncio.run( + build_edit_facade(str(root)).execute( + { + "action": "impact", + "mode": "diff", + "capture_diff_snapshot": True, + "output_format": "json", + } + ) + ) + + assert observations == [] + assert dependency.read_bytes() == original + assert result["affected_files_unknown"] is True + + +def test_edit_release_snapshot_is_same_process_reachable_and_idempotent( + tmp_path: Path, monkeypatch +) -> None: + # PR #1252 review thread 3746878592. + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + root = make_repo(tmp_path) + (root / "old.py").write_text("value = 2\n") + install_fake_snapshot_materializer(monkeypatch, root) + registry = snapshots.DiffSnapshotRegistry() + monkeypatch.setattr(snapshots, "REGISTRY", registry) + created = registry.create(str(root), "diff", []) + args = { + "action": "release_snapshot", + "diff_snapshot_id": created["diff_snapshot_id"], + "route_lease_id": created["route_lease_id"], + "output_format": "json", + } + facade = build_edit_facade(str(root)) + + first = asyncio.run(facade.execute(args)) + second = asyncio.run(facade.execute(args)) + + assert (first["released"], second["released"]) == (True, True) + + +def test_edit_release_snapshot_rejects_wrong_ownership_token( + tmp_path: Path, monkeypatch +) -> None: + # PR #1252 review thread 3746878592. + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + root = make_repo(tmp_path) + install_fake_snapshot_materializer(monkeypatch, root) + registry = snapshots.DiffSnapshotRegistry() + monkeypatch.setattr(snapshots, "REGISTRY", registry) + created = registry.create(str(root), "diff", []) + + result = asyncio.run( + build_edit_facade(str(root)).execute( + { + "action": "release_snapshot", + "diff_snapshot_id": created["diff_snapshot_id"], + "route_lease_id": "wrong", + "output_format": "json", + } + ) + ) + + assert result["error_code"] == "DIFF_SNAPSHOT_LEASE_MISMATCH" + + +def test_edit_release_snapshot_rejects_alternate_source_arguments() -> None: + # PR #1252 review thread 3746878592. + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + with pytest.raises(ValueError, match="DIFF_SNAPSHOT_CONFLICTING_ARGUMENTS"): + asyncio.run( + build_edit_facade(".").execute( + { + "action": "release_snapshot", + "diff_snapshot_id": "ds", + "route_lease_id": "lease", + "file_path": "alternate.py", + } + ) + ) + + +def test_edit_release_snapshot_requires_both_ownership_ids() -> None: + # PR #1252 review thread 3746878592. + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + with pytest.raises( + ValueError, match="diff_snapshot_id and route_lease_id are required" + ): + asyncio.run( + build_edit_facade(".").execute( + {"action": "release_snapshot", "diff_snapshot_id": "ds"} + ) + ) + + +def test_change_impact_annotation_is_non_idempotent_for_optional_capture() -> None: + # PR #1252 review thread 3747113064. + from tree_sitter_analyzer.mcp.tools.change_impact_tool import ChangeImpactTool + + definition = ChangeImpactTool().get_tool_definition() + assert definition["annotations"]["idempotentHint"] is False + + +@pytest.mark.asyncio +async def test_edit_constraints_projects_exact_frozen_scope_arguments( + monkeypatch, +) -> None: + from tree_sitter_analyzer.mcp.tools.constraint_check_tool import ConstraintCheckTool + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + seen: list[dict[str, object]] = [] + + async def fake_execute(self, arguments): + seen.append(dict(arguments)) + return {"success": True} + + monkeypatch.setattr(ConstraintCheckTool, "execute", fake_execute) + await build_edit_facade(None).execute( + { + "action": "constraints", + "persist": False, + "diff_snapshot_id": "ds_contract", + "scope_paths": ["src/a.py", "src/b.py"], + "output_format": "json", + } + ) + + assert seen == [ + { + "persist": False, + "diff_snapshot_id": "ds_contract", + "scope_paths": ["src/a.py", "src/b.py"], + "output_format": "json", + } + ] + + +def test_edit_constraints_snapshot_parameters_are_schema_discoverable() -> None: + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + properties = build_edit_facade(None).get_tool_definition()["inputSchema"][ + "properties" + ] + + assert properties["persist"] == { + "type": "boolean", + "default": True, + "description": ( + "Write evaluated violations through to the cache. Set false for " + "RFC-0022 read-only evaluation; no database or file is created." + ), + } + assert properties["scope_paths"] == { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Primitive-issued frozen scope for action=constraints, or impact " + "capture scope for action=impact." + ), + } + + +@pytest.mark.asyncio +async def test_edit_constraints_rejects_snapshot_without_persist_false() -> None: + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + with pytest.raises(ValueError, match="diff_snapshot_id requires persist=false"): + await build_edit_facade(None).execute( + { + "action": "constraints", + "diff_snapshot_id": "ds_contract", + "scope_paths": ["src/a.py"], + } + ) + + +@pytest.mark.asyncio +async def test_edit_persist_is_rejected_outside_constraints_action() -> None: + # PR #1254 review 3768545538: an explicit action option cannot be dropped. + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + result = await build_edit_facade(None).execute( + {"action": "safe", "file_path": "src/a.py", "persist": False} + ) + + assert ( + result["success"], + result["verdict"], + result["error"], + ) == ( + False, + "ERROR", + "parameter 'persist' applies only to action(s): constraints", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("parameter", "value", "allowed"), + [ + ("capture_diff_snapshot", True, "impact"), + ( + "diff_snapshot_id", + "ds_contract", + "ast_diff, classify, constraints, release_snapshot", + ), + ("route_lease_id", "lease_contract", "release_snapshot"), + ], +) +async def test_edit_snapshot_controls_are_rejected_outside_supported_actions( + parameter, value, allowed +) -> None: + # PR #1254 review 3771670610: explicit snapshot intent cannot be discarded. + from tree_sitter_analyzer.mcp.tools.edit_facade import build_edit_facade + + result = await build_edit_facade(None).execute( + {"action": "safe", "file_path": "src/a.py", parameter: value} + ) + + assert (result["success"], result["verdict"], result["error"]) == ( + False, + "ERROR", + f"parameter {parameter!r} applies only to action(s): {allowed}", + ) diff --git a/tests/unit/test_ast_diff_node_budget.py b/tests/unit/test_ast_diff_node_budget.py new file mode 100644 index 000000000..cdf9a4101 --- /dev/null +++ b/tests/unit/test_ast_diff_node_budget.py @@ -0,0 +1,162 @@ +"""Response-size and node-body budget coverage for AST diff.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from tree_sitter_analyzer.mcp.tools.ast_diff_tool import ASTDiffTool + +_OLD_SRC = "def foo():\n pass\n" +_NEW_SRC = "def foo():\n pass\n\ndef bar():\n pass\n" +_LANG = "python" + + +@pytest.fixture +def tool(): + return ASTDiffTool(project_root="/tmp/test_project") + + +class TestASTDiffNodeBudget: + """Issue #552 — default response must NOT contain children; opt-in adds them.""" + + @pytest.mark.asyncio + async def test_default_response_has_no_children_key(self, tool): + """Hunk nodes must NOT contain a 'children' key when include_node_bodies is omitted.""" + result = await tool.execute( + { + "mode": "diff_strings", + "old_source": _OLD_SRC, + "new_source": _NEW_SRC, + "language": _LANG, + "output_format": "json", + } + ) + hunks = result.get("hunks", []) + assert len(hunks) == 1 # exactly one added hunk + hunk = hunks[0] + new_node = hunk.get("new") + assert new_node is not None, "hunk must have 'new' node" + assert "children" not in new_node, ( + "default response must NOT inline children — issue #552" + ) + + @pytest.mark.asyncio + async def test_default_response_has_child_count(self, tool): + """Hunk nodes MUST have child_count (exact pin) when include_node_bodies is omitted.""" + result = await tool.execute( + { + "mode": "diff_strings", + "old_source": _OLD_SRC, + "new_source": _NEW_SRC, + "language": _LANG, + "output_format": "json", + } + ) + hunks = result.get("hunks", []) + assert len(hunks) == 1 + new_node = hunks[0].get("new", {}) + assert "child_count" in new_node, "child_count must be present in default mode" + # Grammar-pinned exact assertion: function_definition has 5 children + # (def keyword, name identifier, parameters, colon, body block). + # If a grammar bump shifts this, the test MUST go red. + assert new_node["child_count"] == 5 + + @pytest.mark.asyncio + async def test_schema_has_include_node_bodies_param(self, tool): + """Schema must expose include_node_bodies boolean param.""" + schema = tool.get_tool_schema() + props = schema.get("properties", {}) + assert "include_node_bodies" in props, ( + "include_node_bodies param must be in the tool schema" + ) + assert props["include_node_bodies"].get("type") == "boolean" + # Must NOT be in required (runtime-resolved param convention) + assert "include_node_bodies" not in schema.get("required", []) + + @pytest.mark.asyncio + async def test_default_bytes_smaller_than_include_bodies_bytes(self, tool): + """DOCUMENTED RELATIONSHIP: default response < include_node_bodies=True response.""" + import json + + args_base = { + "mode": "diff_strings", + "old_source": _OLD_SRC, + "new_source": _NEW_SRC, + "language": _LANG, + "output_format": "json", + } + default_result = await tool.execute(args_base) + bodies_result = await tool.execute({**args_base, "include_node_bodies": True}) + + # Strip volatile envelope fields (timing varies run-to-run) so the + # byte counts are deterministic and can be pinned EXACTLY (CLAUDE.md + # locked rule: no loose assertions — a grammar bump SHOULD go red + # and force a conscious re-pin). + _volatile = { + "elapsed_ms", + "cache_age_s", + "from_cache", + "cache_invalidated_reason", + } + + def _stable_bytes(d): + return len(json.dumps({k: v for k, v in d.items() if k not in _volatile})) + + default_bytes = _stable_bytes(default_result) + bodies_bytes = _stable_bytes(bodies_result) + + # Cost invariant (CLAUDE.md rule 11) pinned exactly: 746 < 1877 — the + # default is dramatically smaller than the full-body opt-in. + # Re-pinned after Codex P2 made agent_summary a dict (next_step+verdict). + assert default_bytes == 746 + assert bodies_bytes == 1877 + + @pytest.mark.asyncio + async def test_include_node_bodies_true_has_children(self, tool): + """When include_node_bodies=True, hunk nodes MUST contain 'children' key.""" + result = await tool.execute( + { + "mode": "diff_strings", + "old_source": _OLD_SRC, + "new_source": _NEW_SRC, + "language": _LANG, + "output_format": "json", + "include_node_bodies": True, + } + ) + hunks = result.get("hunks", []) + assert len(hunks) == 1 + new_node = hunks[0].get("new", {}) + assert "children" in new_node, ( + "include_node_bodies=True must inline children in hunk nodes" + ) + # Exact pin: function_definition has 5 children (grammar-pinned) + assert len(new_node["children"]) == 5 + + @pytest.mark.asyncio + async def test_over_budget_sets_children_truncated(self, tool): + """When include_node_bodies=True and response exceeds budget, set children_truncated.""" + # Patch the budget constant to 1 byte to guarantee truncation + with patch( + "tree_sitter_analyzer.mcp.tools.ast_diff_tool.NODE_BODIES_BUDGET", 1 + ): + result = await tool.execute( + { + "mode": "diff_strings", + "old_source": _OLD_SRC, + "new_source": _NEW_SRC, + "language": _LANG, + "output_format": "json", + "include_node_bodies": True, + } + ) + # Must set the transparency flag when budget is exceeded + assert result.get("children_truncated") is True, ( + "children_truncated must be True when budget is exceeded" + ) + assert "bytes_omitted" in result, "bytes_omitted must be present when truncated" + # Exact pin (deterministic for this fixture at budget=1): the omitted + # bytes equal full-body minus compact = 1627 - 496 = 1131. + assert result["bytes_omitted"] == 1131 diff --git a/tests/unit/test_ast_diff_snapshot_consumers.py b/tests/unit/test_ast_diff_snapshot_consumers.py new file mode 100644 index 000000000..79bb298b7 --- /dev/null +++ b/tests/unit/test_ast_diff_snapshot_consumers.py @@ -0,0 +1,432 @@ +"""Frozen snapshot consumer coverage for AST diff and semantic classify.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import patch + +import pytest + +import tree_sitter_analyzer.diff_snapshot_epoch as epoch_verification +import tree_sitter_analyzer.diff_snapshot_registry as snapshots +from tests.unit._diff_snapshot_support import POSIX_SNAPSHOT_TEST, make_repo +from tree_sitter_analyzer.mcp.tools.ast_diff_tool import ASTDiffTool +from tree_sitter_analyzer.mcp.tools.semantic_classify_tool import SemanticClassifyTool + + +@pytest.fixture +def tool(): + return ASTDiffTool(project_root="/tmp/test_project") + + +def _create_stable_consumer_snapshot( + monkeypatch: pytest.MonkeyPatch, root: Path +) -> dict[str, object]: + """Capture once, then make consumer publication verification deterministic.""" + monkeypatch.setattr( + epoch_verification.FrozenGitEnvironment, + "verify_source_epoch", + lambda self: None, + ) + created = snapshots.REGISTRY.create(str(root), "diff", []) + identity = snapshots.canonical_root(str(root))[1] + generation = str(created["source_generation"]) + state = snapshots.REGISTRY._states[str(created["diff_snapshot_id"])] + git_generation = state.snapshot.git_generation + monkeypatch.setattr( + snapshots, + "oracle_generation", + lambda project_root, mode="diff", *, deadline=None: (git_generation, identity), + ) + monkeypatch.setattr( + snapshots, + "shared_source_generation", + lambda *_args, **_kwargs: generation, + ) + return created + + +@pytest.mark.asyncio +async def test_snapshot_requires_file_path(tool) -> None: + with pytest.raises(ValueError, match="DIFF_SNAPSHOT_FILE_REQUIRED"): + await tool.execute({"diff_snapshot_id": "ds"}) + + +@pytest.mark.asyncio +async def test_snapshot_translates_registry_error(tool, monkeypatch) -> None: + from tree_sitter_analyzer import diff_snapshot_registry as registry + + monkeypatch.setattr( + registry.REGISTRY, "acquire", lambda *a: (None, "DIFF_SNAPSHOT_EXPIRED") + ) + result = await tool.execute( + {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} + ) + assert result["error_code"] == "DIFF_SNAPSHOT_EXPIRED" + + +@pytest.mark.asyncio +async def test_snapshot_reports_missing_frozen_file(tool, monkeypatch) -> None: + from types import SimpleNamespace + + from tree_sitter_analyzer import diff_snapshot_registry as registry + + consumer = SimpleNamespace( + snapshot=SimpleNamespace(file=lambda path: None), release=lambda: None + ) + monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) + result = await tool.execute( + {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} + ) + assert result["error_code"] == "DIFF_SNAPSHOT_FILE_NOT_FOUND" + + +@pytest.mark.asyncio +async def test_snapshot_rejects_non_utf8_frozen_bytes(tool, monkeypatch) -> None: + from types import SimpleNamespace + + from tree_sitter_analyzer import diff_snapshot_registry as registry + + frozen = SimpleNamespace( + record=SimpleNamespace(path="x.py", binary=False), + old_bytes=b"\xff", + new_bytes=b"", + ) + consumer = SimpleNamespace( + snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None + ) + monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) + result = await tool.execute( + {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} + ) + assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" + + +@pytest.mark.parametrize( + ("tool_type", "field", "expected"), + [(ASTDiffTool, "hunks", 2), (SemanticClassifyTool, "change_count", 2)], +) +@POSIX_SNAPSHOT_TEST +def test_snapshot_consumer_uses_frozen_utf8_bytes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tool_type, + field: str, + expected: int, +) -> None: + root = make_repo(tmp_path) + (root / "old.py").write_text("value = 2\n") + result = _create_stable_consumer_snapshot(monkeypatch, root) + request = { + "diff_snapshot_id": result["diff_snapshot_id"], + "file_path": "old.py", + "output_format": "json", + } + response = asyncio.run(tool_type(str(root)).execute(request)) + assert ( + len(response[field]) if isinstance(response[field], list) else response[field] + ) == expected + assert ( + snapshots.REGISTRY.close_lease( + str(result["diff_snapshot_id"]), str(result["route_lease_id"]) + ) + is True + ) + + +@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) +@pytest.mark.parametrize("output_format", ["json", "toon"]) +@POSIX_SNAPSHOT_TEST +def test_snapshot_consumer_echoes_exact_frozen_identity( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tool_type, + output_format: str, +) -> None: + # PR #1252 review thread 3748730795: consumers must not infer identity. + root = make_repo(tmp_path) + (root / "old.py").write_text("value = 2\n") + created = _create_stable_consumer_snapshot(monkeypatch, root) + response = asyncio.run( + tool_type(str(root)).execute( + { + "diff_snapshot_id": created["diff_snapshot_id"], + "file_path": "old.py", + "output_format": output_format, + } + ) + ) + assert response["diff_snapshot_id"] == created["diff_snapshot_id"] + assert response["source_generation"] == created["source_generation"] + assert ( + snapshots.REGISTRY.close_lease( + str(created["diff_snapshot_id"]), str(created["route_lease_id"]) + ) + is True + ) + + +@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) +@POSIX_SNAPSHOT_TEST +def test_snapshot_consumer_rejects_binary_content( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, tool_type +) -> None: + root = make_repo(tmp_path) + (root / "blob.py").write_bytes(b"a\0b") + result = _create_stable_consumer_snapshot(monkeypatch, root) + request = { + "diff_snapshot_id": result["diff_snapshot_id"], + "file_path": "blob.py", + "output_format": "json", + } + response = asyncio.run(tool_type(str(root)).execute(request)) + assert response["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" + assert ( + snapshots.REGISTRY.close_lease( + str(result["diff_snapshot_id"]), str(result["route_lease_id"]) + ) + is True + ) + + +@pytest.mark.parametrize( + ("tool_type", "module_name"), + [ + (ASTDiffTool, "tree_sitter_analyzer.mcp.tools.ast_diff_tool"), + ( + SemanticClassifyTool, + "tree_sitter_analyzer.mcp.tools.semantic_classify_tool", + ), + ], +) +@pytest.mark.parametrize( + ("validation_error", "request_format", "expected_error"), + [ + ( + "DIFF_SNAPSHOT_SOURCE_CHANGED", + "toon", + "DIFF_SNAPSHOT_SOURCE_CHANGED", + ), + ("DIFF_SNAPSHOT_EXPIRED", None, "DIFF_SNAPSHOT_EXPIRED"), + ( + "DIFF_SNAPSHOT_FUTURE_ERROR", + None, + "DIFF_SNAPSHOT_VALIDATION_ERROR", + ), + ], +) +@pytest.mark.asyncio +async def test_strict_snapshot_final_publish_errors_preserve_toon( + tool_type, + module_name: str, + validation_error: str, + request_format: str | None, + expected_error: str, + monkeypatch, +) -> None: + from importlib import import_module + from types import SimpleNamespace + + from tree_sitter_analyzer import diff_snapshot_registry as registry + + events: list[tuple[str, bool]] = [] + released = False + frozen = SimpleNamespace( + record=SimpleNamespace(path="x.py", binary=False), + old_bytes=b"value = 1\n", + new_bytes=b"value = 2\n", + ) + + def release() -> None: + nonlocal released + released = True + events.append(("release", released)) + + consumer = SimpleNamespace( + snapshot=SimpleNamespace(file=lambda path: frozen), release=release + ) + monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) + + def validate_publish(pinned) -> str: + events.append(("validate", released)) + return validation_error + + monkeypatch.setattr(registry.REGISTRY, "validate_publish", validate_publish) + tool_module = import_module(module_name) + original_formatter = tool_module.apply_toon_format_to_response + + def formatting(response, output_format): + events.append(("format", released)) + return original_formatter(response, output_format) + + monkeypatch.setattr(tool_module, "apply_toon_format_to_response", formatting) + request = {"diff_snapshot_id": "ds", "file_path": "x.py"} + if request_format is not None: + request["output_format"] = request_format + response = await tool_type(".").execute(request) + # PR #1252 review thread 3750964908: final validation must not bypass TOON. + assert response["error_code"] == expected_error + assert response["format"] == "toon" + assert isinstance(response["toon_content"], str) + assert events == [("format", False)] * 22 + [ + ("validate", False), + ("release", True), + ] + + +def test_snapshot_consumers_reject_symlink_kind() -> None: + # PR #1252 review thread 3746878582. + from types import SimpleNamespace + + from tree_sitter_analyzer.mcp.tools.ast_diff_tool import ASTDiffTool + + frozen = SimpleNamespace( + record=SimpleNamespace( + path="module.py", binary=False, old_kind="symlink", new_kind="symlink" + ), + old_bytes=b"target.py", + new_bytes=b"other.py", + ) + consumer = SimpleNamespace( + snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None + ) + with patch( + "tree_sitter_analyzer.diff_snapshot_registry.REGISTRY.acquire", + return_value=(consumer, None), + ): + result = asyncio.run( + ASTDiffTool(".").execute( + { + "diff_snapshot_id": "ds", + "file_path": "module.py", + "output_format": "json", + } + ) + ) + assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" + + +def test_snapshot_ast_options_are_not_source_conflicts() -> None: + # PR #1252 review thread 3746878597. + from tree_sitter_analyzer.mcp.tools.ast_diff_tool import ASTDiffTool + + assert ( + ASTDiffTool(".").validate_arguments( + { + "diff_snapshot_id": "ds", + "file_path": "module.py", + "include_node_bodies": True, + "output_format": "json", + } + ) + is True + ) + + +def test_ast_diff_execute_rejects_unreachable_unknown_mode() -> None: + tool = ASTDiffTool(".") + with ( + patch.object(tool, "validate_arguments", return_value=True), + patch.object(tool, "_resolve_mode", return_value="unknown"), + pytest.raises(ValueError, match="Unknown mode: unknown"), + ): + asyncio.run(tool.execute({"output_format": "json"})) + + +@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) +@pytest.mark.parametrize("unavailable_side", ["old_available", "new_available"]) +@pytest.mark.asyncio +async def test_snapshot_consumer_rejects_each_unavailable_side( + tool_type, unavailable_side: str, monkeypatch +) -> None: + # PR #1252 review thread 3748259951. + from types import SimpleNamespace + + from tree_sitter_analyzer import diff_snapshot_registry as registry + + availability = {"old_available": True, "new_available": True} + availability[unavailable_side] = False + frozen = SimpleNamespace( + record=SimpleNamespace(path="x.py", binary=False, **availability), + old_bytes=b"value = 1\n", + new_bytes=b"value = 2\n", + ) + consumer = SimpleNamespace( + snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None + ) + monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) + + result = await tool_type(".").execute( + {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} + ) + + assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" + + +@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) +@pytest.mark.parametrize("status", ["R", "C"]) +@pytest.mark.asyncio +async def test_snapshot_consumer_rejects_rename_and_copy_status( + tool_type, status: str, monkeypatch +) -> None: + # PR #1252 review thread 3748575979. + from types import SimpleNamespace + + frozen = SimpleNamespace( + record=SimpleNamespace(path="x.py", status=status, binary=False), + old_bytes=b"value = 1\n", + new_bytes=b"value = 2\n", + ) + consumer = SimpleNamespace( + snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None + ) + monkeypatch.setattr(snapshots.REGISTRY, "acquire", lambda *a: (consumer, None)) + result = await tool_type(".").execute( + {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} + ) + assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" + + +@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) +def test_snapshot_consumer_rejects_caller_language_override(tool_type) -> None: + # PR #1252 review thread 4873: strict language is bound to captured path. + with pytest.raises(ValueError, match="DIFF_SNAPSHOT_CONFLICTING_ARGUMENTS"): + tool_type(".").validate_arguments( + { + "diff_snapshot_id": "ds", + "file_path": "module.py", + "language": "javascript", + } + ) + + +@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) +@pytest.mark.asyncio +async def test_snapshot_consumer_rejects_unknown_captured_extension( + tool_type, monkeypatch +) -> None: + # PR #1252 review thread 4873: unknown captured extension is stable unsupported. + from types import SimpleNamespace + + from tree_sitter_analyzer import diff_snapshot_registry as registry + + frozen = SimpleNamespace( + record=SimpleNamespace(path="module.unknown", binary=False), + old_bytes=b"old", + new_bytes=b"new", + ) + consumer = SimpleNamespace( + snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None + ) + monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) + + result = await tool_type(".").execute( + { + "diff_snapshot_id": "ds", + "file_path": "module.unknown", + "output_format": "json", + } + ) + + assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_LANGUAGE" diff --git a/tests/unit/test_ast_diff_tool.py b/tests/unit/test_ast_diff_tool.py index e6c027568..c5176a201 100644 --- a/tests/unit/test_ast_diff_tool.py +++ b/tests/unit/test_ast_diff_tool.py @@ -2,37 +2,11 @@ from __future__ import annotations -import asyncio -from pathlib import Path from unittest.mock import MagicMock, patch import pytest -import tree_sitter_analyzer.diff_snapshot_epoch as epoch_verification -import tree_sitter_analyzer.diff_snapshot_registry as snapshots -from tests.unit._diff_snapshot_support import POSIX_SNAPSHOT_TEST, make_repo from tree_sitter_analyzer.mcp.tools.ast_diff_tool import ASTDiffTool -from tree_sitter_analyzer.mcp.tools.semantic_classify_tool import SemanticClassifyTool - - -def _create_stable_consumer_snapshot( - monkeypatch: pytest.MonkeyPatch, root: Path -) -> dict[str, object]: - """Capture once, then make consumer publication verification deterministic.""" - monkeypatch.setattr( - epoch_verification.FrozenGitEnvironment, - "verify_source_epoch", - lambda self: None, - ) - created = snapshots.REGISTRY.create(str(root), "diff", []) - identity = snapshots.canonical_root(str(root))[1] - generation = str(created["source_generation"]) - monkeypatch.setattr( - snapshots, - "oracle_generation", - lambda project_root, mode="diff", *, deadline=None: (generation, identity), - ) - return created @pytest.fixture @@ -379,150 +353,6 @@ def test_no_match_error_mentions_all_three_modes(self, tool): _LANG = "python" -class TestASTDiffNodeBudget: - """Issue #552 — default response must NOT contain children; opt-in adds them.""" - - @pytest.mark.asyncio - async def test_default_response_has_no_children_key(self, tool): - """Hunk nodes must NOT contain a 'children' key when include_node_bodies is omitted.""" - result = await tool.execute( - { - "mode": "diff_strings", - "old_source": _OLD_SRC, - "new_source": _NEW_SRC, - "language": _LANG, - "output_format": "json", - } - ) - hunks = result.get("hunks", []) - assert len(hunks) == 1 # exactly one added hunk - hunk = hunks[0] - new_node = hunk.get("new") - assert new_node is not None, "hunk must have 'new' node" - assert "children" not in new_node, ( - "default response must NOT inline children — issue #552" - ) - - @pytest.mark.asyncio - async def test_default_response_has_child_count(self, tool): - """Hunk nodes MUST have child_count (exact pin) when include_node_bodies is omitted.""" - result = await tool.execute( - { - "mode": "diff_strings", - "old_source": _OLD_SRC, - "new_source": _NEW_SRC, - "language": _LANG, - "output_format": "json", - } - ) - hunks = result.get("hunks", []) - assert len(hunks) == 1 - new_node = hunks[0].get("new", {}) - assert "child_count" in new_node, "child_count must be present in default mode" - # Grammar-pinned exact assertion: function_definition has 5 children - # (def keyword, name identifier, parameters, colon, body block). - # If a grammar bump shifts this, the test MUST go red. - assert new_node["child_count"] == 5 - - @pytest.mark.asyncio - async def test_schema_has_include_node_bodies_param(self, tool): - """Schema must expose include_node_bodies boolean param.""" - schema = tool.get_tool_schema() - props = schema.get("properties", {}) - assert "include_node_bodies" in props, ( - "include_node_bodies param must be in the tool schema" - ) - assert props["include_node_bodies"].get("type") == "boolean" - # Must NOT be in required (runtime-resolved param convention) - assert "include_node_bodies" not in schema.get("required", []) - - @pytest.mark.asyncio - async def test_default_bytes_smaller_than_include_bodies_bytes(self, tool): - """DOCUMENTED RELATIONSHIP: default response < include_node_bodies=True response.""" - import json - - args_base = { - "mode": "diff_strings", - "old_source": _OLD_SRC, - "new_source": _NEW_SRC, - "language": _LANG, - "output_format": "json", - } - default_result = await tool.execute(args_base) - bodies_result = await tool.execute({**args_base, "include_node_bodies": True}) - - # Strip volatile envelope fields (timing varies run-to-run) so the - # byte counts are deterministic and can be pinned EXACTLY (CLAUDE.md - # locked rule: no loose assertions — a grammar bump SHOULD go red - # and force a conscious re-pin). - _volatile = { - "elapsed_ms", - "cache_age_s", - "from_cache", - "cache_invalidated_reason", - } - - def _stable_bytes(d): - return len(json.dumps({k: v for k, v in d.items() if k not in _volatile})) - - default_bytes = _stable_bytes(default_result) - bodies_bytes = _stable_bytes(bodies_result) - - # Cost invariant (CLAUDE.md rule 11) pinned exactly: 746 < 1877 — the - # default is dramatically smaller than the full-body opt-in. - # Re-pinned after Codex P2 made agent_summary a dict (next_step+verdict). - assert default_bytes == 746 - assert bodies_bytes == 1877 - - @pytest.mark.asyncio - async def test_include_node_bodies_true_has_children(self, tool): - """When include_node_bodies=True, hunk nodes MUST contain 'children' key.""" - result = await tool.execute( - { - "mode": "diff_strings", - "old_source": _OLD_SRC, - "new_source": _NEW_SRC, - "language": _LANG, - "output_format": "json", - "include_node_bodies": True, - } - ) - hunks = result.get("hunks", []) - assert len(hunks) == 1 - new_node = hunks[0].get("new", {}) - assert "children" in new_node, ( - "include_node_bodies=True must inline children in hunk nodes" - ) - # Exact pin: function_definition has 5 children (grammar-pinned) - assert len(new_node["children"]) == 5 - - @pytest.mark.asyncio - async def test_over_budget_sets_children_truncated(self, tool): - """When include_node_bodies=True and response exceeds budget, set children_truncated.""" - # Patch the budget constant to 1 byte to guarantee truncation - with patch( - "tree_sitter_analyzer.mcp.tools.ast_diff_tool.NODE_BODIES_BUDGET", 1 - ): - result = await tool.execute( - { - "mode": "diff_strings", - "old_source": _OLD_SRC, - "new_source": _NEW_SRC, - "language": _LANG, - "output_format": "json", - "include_node_bodies": True, - } - ) - # Must set the transparency flag when budget is exceeded - assert result.get("children_truncated") is True, ( - "children_truncated must be True when budget is exceeded" - ) - assert "bytes_omitted" in result, "bytes_omitted must be present when truncated" - # Exact pin (deterministic for this fixture at budget=1): the omitted - # bytes equal full-body minus compact = 1627 - 496 = 1131. - assert result["bytes_omitted"] == 1131 - - class TestAstDiffAgentSummaryEnvelope: """#744: ast_diff must include agent_summary and summary_line in response.""" @@ -587,388 +417,3 @@ async def test_response_has_agent_summary_parse_failure(self, tool): assert result["verdict"] == "ERROR" assert result["agent_summary"]["verdict"] == "ERROR" assert result["agent_summary"]["summary_line"] == "Both sources failed to parse" - - -@pytest.mark.asyncio -async def test_snapshot_requires_file_path(tool) -> None: - with pytest.raises(ValueError, match="DIFF_SNAPSHOT_FILE_REQUIRED"): - await tool.execute({"diff_snapshot_id": "ds"}) - - -@pytest.mark.asyncio -async def test_snapshot_translates_registry_error(tool, monkeypatch) -> None: - from tree_sitter_analyzer import diff_snapshot_registry as registry - - monkeypatch.setattr( - registry.REGISTRY, "acquire", lambda *a: (None, "DIFF_SNAPSHOT_EXPIRED") - ) - result = await tool.execute( - {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} - ) - assert result["error_code"] == "DIFF_SNAPSHOT_EXPIRED" - - -@pytest.mark.asyncio -async def test_snapshot_reports_missing_frozen_file(tool, monkeypatch) -> None: - from types import SimpleNamespace - - from tree_sitter_analyzer import diff_snapshot_registry as registry - - consumer = SimpleNamespace( - snapshot=SimpleNamespace(file=lambda path: None), release=lambda: None - ) - monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) - result = await tool.execute( - {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} - ) - assert result["error_code"] == "DIFF_SNAPSHOT_FILE_NOT_FOUND" - - -@pytest.mark.asyncio -async def test_snapshot_rejects_non_utf8_frozen_bytes(tool, monkeypatch) -> None: - from types import SimpleNamespace - - from tree_sitter_analyzer import diff_snapshot_registry as registry - - frozen = SimpleNamespace( - record=SimpleNamespace(path="x.py", binary=False), - old_bytes=b"\xff", - new_bytes=b"", - ) - consumer = SimpleNamespace( - snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None - ) - monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) - result = await tool.execute( - {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} - ) - assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" - - -@pytest.mark.parametrize( - ("tool_type", "field", "expected"), - [(ASTDiffTool, "hunks", 2), (SemanticClassifyTool, "change_count", 2)], -) -@POSIX_SNAPSHOT_TEST -def test_snapshot_consumer_uses_frozen_utf8_bytes( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - tool_type, - field: str, - expected: int, -) -> None: - root = make_repo(tmp_path) - (root / "old.py").write_text("value = 2\n") - result = _create_stable_consumer_snapshot(monkeypatch, root) - request = { - "diff_snapshot_id": result["diff_snapshot_id"], - "file_path": "old.py", - "output_format": "json", - } - response = asyncio.run(tool_type(str(root)).execute(request)) - assert ( - len(response[field]) if isinstance(response[field], list) else response[field] - ) == expected - assert ( - snapshots.REGISTRY.close_lease( - str(result["diff_snapshot_id"]), str(result["route_lease_id"]) - ) - is True - ) - - -@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) -@pytest.mark.parametrize("output_format", ["json", "toon"]) -@POSIX_SNAPSHOT_TEST -def test_snapshot_consumer_echoes_exact_frozen_identity( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - tool_type, - output_format: str, -) -> None: - # PR #1252 review thread 3748730795: consumers must not infer identity. - root = make_repo(tmp_path) - (root / "old.py").write_text("value = 2\n") - created = _create_stable_consumer_snapshot(monkeypatch, root) - response = asyncio.run( - tool_type(str(root)).execute( - { - "diff_snapshot_id": created["diff_snapshot_id"], - "file_path": "old.py", - "output_format": output_format, - } - ) - ) - assert response["diff_snapshot_id"] == created["diff_snapshot_id"] - assert response["source_generation"] == created["source_generation"] - assert ( - snapshots.REGISTRY.close_lease( - str(created["diff_snapshot_id"]), str(created["route_lease_id"]) - ) - is True - ) - - -@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) -@POSIX_SNAPSHOT_TEST -def test_snapshot_consumer_rejects_binary_content( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, tool_type -) -> None: - root = make_repo(tmp_path) - (root / "blob.py").write_bytes(b"a\0b") - result = _create_stable_consumer_snapshot(monkeypatch, root) - request = { - "diff_snapshot_id": result["diff_snapshot_id"], - "file_path": "blob.py", - "output_format": "json", - } - response = asyncio.run(tool_type(str(root)).execute(request)) - assert response["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" - assert ( - snapshots.REGISTRY.close_lease( - str(result["diff_snapshot_id"]), str(result["route_lease_id"]) - ) - is True - ) - - -@pytest.mark.parametrize( - ("tool_type", "module_name"), - [ - (ASTDiffTool, "tree_sitter_analyzer.mcp.tools.ast_diff_tool"), - ( - SemanticClassifyTool, - "tree_sitter_analyzer.mcp.tools.semantic_classify_tool", - ), - ], -) -@pytest.mark.parametrize( - ("validation_error", "request_format", "expected_error"), - [ - ( - "DIFF_SNAPSHOT_SOURCE_CHANGED", - "toon", - "DIFF_SNAPSHOT_SOURCE_CHANGED", - ), - ("DIFF_SNAPSHOT_EXPIRED", None, "DIFF_SNAPSHOT_EXPIRED"), - ( - "DIFF_SNAPSHOT_FUTURE_ERROR", - None, - "DIFF_SNAPSHOT_VALIDATION_ERROR", - ), - ], -) -@pytest.mark.asyncio -async def test_strict_snapshot_final_publish_errors_preserve_toon( - tool_type, - module_name: str, - validation_error: str, - request_format: str | None, - expected_error: str, - monkeypatch, -) -> None: - from importlib import import_module - from types import SimpleNamespace - - from tree_sitter_analyzer import diff_snapshot_registry as registry - - events: list[tuple[str, bool]] = [] - released = False - frozen = SimpleNamespace( - record=SimpleNamespace(path="x.py", binary=False), - old_bytes=b"value = 1\n", - new_bytes=b"value = 2\n", - ) - - def release() -> None: - nonlocal released - released = True - events.append(("release", released)) - - consumer = SimpleNamespace( - snapshot=SimpleNamespace(file=lambda path: frozen), release=release - ) - monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) - - def validate_publish(pinned) -> str: - events.append(("validate", released)) - return validation_error - - monkeypatch.setattr(registry.REGISTRY, "validate_publish", validate_publish) - tool_module = import_module(module_name) - original_formatter = tool_module.apply_toon_format_to_response - - def formatting(response, output_format): - events.append(("format", released)) - return original_formatter(response, output_format) - - monkeypatch.setattr(tool_module, "apply_toon_format_to_response", formatting) - request = {"diff_snapshot_id": "ds", "file_path": "x.py"} - if request_format is not None: - request["output_format"] = request_format - response = await tool_type(".").execute(request) - # PR #1252 review thread 3750964908: final validation must not bypass TOON. - assert response["error_code"] == expected_error - assert response["format"] == "toon" - assert isinstance(response["toon_content"], str) - assert events == [("format", False)] * 22 + [ - ("validate", False), - ("release", True), - ] - - -def test_snapshot_consumers_reject_symlink_kind() -> None: - # PR #1252 review thread 3746878582. - from types import SimpleNamespace - - from tree_sitter_analyzer.mcp.tools.ast_diff_tool import ASTDiffTool - - frozen = SimpleNamespace( - record=SimpleNamespace( - path="module.py", binary=False, old_kind="symlink", new_kind="symlink" - ), - old_bytes=b"target.py", - new_bytes=b"other.py", - ) - consumer = SimpleNamespace( - snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None - ) - with patch( - "tree_sitter_analyzer.diff_snapshot_registry.REGISTRY.acquire", - return_value=(consumer, None), - ): - result = asyncio.run( - ASTDiffTool(".").execute( - { - "diff_snapshot_id": "ds", - "file_path": "module.py", - "output_format": "json", - } - ) - ) - assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" - - -def test_snapshot_ast_options_are_not_source_conflicts() -> None: - # PR #1252 review thread 3746878597. - from tree_sitter_analyzer.mcp.tools.ast_diff_tool import ASTDiffTool - - assert ( - ASTDiffTool(".").validate_arguments( - { - "diff_snapshot_id": "ds", - "file_path": "module.py", - "include_node_bodies": True, - "output_format": "json", - } - ) - is True - ) - - -def test_ast_diff_execute_rejects_unreachable_unknown_mode() -> None: - tool = ASTDiffTool(".") - with ( - patch.object(tool, "validate_arguments", return_value=True), - patch.object(tool, "_resolve_mode", return_value="unknown"), - pytest.raises(ValueError, match="Unknown mode: unknown"), - ): - asyncio.run(tool.execute({"output_format": "json"})) - - -@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) -@pytest.mark.parametrize("unavailable_side", ["old_available", "new_available"]) -@pytest.mark.asyncio -async def test_snapshot_consumer_rejects_each_unavailable_side( - tool_type, unavailable_side: str, monkeypatch -) -> None: - # PR #1252 review thread 3748259951. - from types import SimpleNamespace - - from tree_sitter_analyzer import diff_snapshot_registry as registry - - availability = {"old_available": True, "new_available": True} - availability[unavailable_side] = False - frozen = SimpleNamespace( - record=SimpleNamespace(path="x.py", binary=False, **availability), - old_bytes=b"value = 1\n", - new_bytes=b"value = 2\n", - ) - consumer = SimpleNamespace( - snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None - ) - monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) - - result = await tool_type(".").execute( - {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} - ) - - assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" - - -@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) -@pytest.mark.parametrize("status", ["R", "C"]) -@pytest.mark.asyncio -async def test_snapshot_consumer_rejects_rename_and_copy_status( - tool_type, status: str, monkeypatch -) -> None: - # PR #1252 review thread 3748575979. - from types import SimpleNamespace - - frozen = SimpleNamespace( - record=SimpleNamespace(path="x.py", status=status, binary=False), - old_bytes=b"value = 1\n", - new_bytes=b"value = 2\n", - ) - consumer = SimpleNamespace( - snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None - ) - monkeypatch.setattr(snapshots.REGISTRY, "acquire", lambda *a: (consumer, None)) - result = await tool_type(".").execute( - {"diff_snapshot_id": "ds", "file_path": "x.py", "output_format": "json"} - ) - assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT" - - -@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) -def test_snapshot_consumer_rejects_caller_language_override(tool_type) -> None: - # PR #1252 review thread 4873: strict language is bound to captured path. - with pytest.raises(ValueError, match="DIFF_SNAPSHOT_CONFLICTING_ARGUMENTS"): - tool_type(".").validate_arguments( - { - "diff_snapshot_id": "ds", - "file_path": "module.py", - "language": "javascript", - } - ) - - -@pytest.mark.parametrize("tool_type", [ASTDiffTool, SemanticClassifyTool]) -@pytest.mark.asyncio -async def test_snapshot_consumer_rejects_unknown_captured_extension( - tool_type, monkeypatch -) -> None: - # PR #1252 review thread 4873: unknown captured extension is stable unsupported. - from types import SimpleNamespace - - from tree_sitter_analyzer import diff_snapshot_registry as registry - - frozen = SimpleNamespace( - record=SimpleNamespace(path="module.unknown", binary=False), - old_bytes=b"old", - new_bytes=b"new", - ) - consumer = SimpleNamespace( - snapshot=SimpleNamespace(file=lambda path: frozen), release=lambda: None - ) - monkeypatch.setattr(registry.REGISTRY, "acquire", lambda *a: (consumer, None)) - - result = await tool_type(".").execute( - { - "diff_snapshot_id": "ds", - "file_path": "module.unknown", - "output_format": "json", - } - ) - - assert result["error_code"] == "DIFF_SNAPSHOT_UNSUPPORTED_LANGUAGE" diff --git a/tests/unit/test_benchmark_harness.py b/tests/unit/test_benchmark_harness.py index 6cbd5442c..882165a45 100644 --- a/tests/unit/test_benchmark_harness.py +++ b/tests/unit/test_benchmark_harness.py @@ -10142,7 +10142,9 @@ def coordinated_read(descriptor: int, size: int) -> bytes: assert writer.is_alive() is False -def test_index_tree_hash_handles_one_thousand_directory_levels(tmp_path: Path): +def test_index_tree_hash_handles_one_thousand_directory_levels( + tmp_path: Path, request: pytest.FixtureRequest +): # PR #1247: producer-controlled depth must not consume Python recursion. import hashlib import os @@ -10152,6 +10154,23 @@ def test_index_tree_hash_handles_one_thousand_directory_levels(tmp_path: Path): index = tmp_path / "index" index.mkdir() root_fd = os.open(index, os.O_RDONLY | os.O_DIRECTORY) + + def cleanup() -> None: + descriptors = [os.dup(root_fd)] + try: + for _ in range(1000): + descriptors.append( + os.open("d", os.O_RDONLY | os.O_DIRECTORY, dir_fd=descriptors[-1]) + ) + os.unlink("leaf.bin", dir_fd=descriptors[-1]) + for number in range(999, -1, -1): + os.rmdir("d", dir_fd=descriptors[number]) + finally: + for descriptor in reversed(descriptors): + os.close(descriptor) + os.close(root_fd) + + request.addfinalizer(cleanup) current = os.dup(root_fd) try: for _ in range(1000): @@ -10175,22 +10194,7 @@ def test_index_tree_hash_handles_one_thousand_directory_levels(tmp_path: Path): directory = "/".join(("d",) * depth).encode() digest.update(b"D" + len(directory).to_bytes(8, "big") + directory) digest.update(b"C" + (1).to_bytes(8, "big") + (1000).to_bytes(8, "big")) - try: - assert _hash_tree(index) == digest.hexdigest() - finally: - descriptors = [os.dup(root_fd)] - try: - for _ in range(1000): - descriptors.append( - os.open("d", os.O_RDONLY | os.O_DIRECTORY, dir_fd=descriptors[-1]) - ) - os.unlink("leaf.bin", dir_fd=descriptors[-1]) - for number in range(999, -1, -1): - os.rmdir("d", dir_fd=descriptors[number]) - finally: - for descriptor in reversed(descriptors): - os.close(descriptor) - os.close(root_fd) + assert _hash_tree(index) == digest.hexdigest() def test_index_tree_hash_rejects_same_size_concurrent_rewrite( @@ -14039,7 +14043,9 @@ def test_producer_gate_releases_only_exact_signal(tmp_path: Path, monkeypatch): received = [] reader = threading.Thread(target=lambda: received.append(gate.read_bytes())) reader.start() - monkeypatch.setattr(runner, "_run", lambda *_args: b'[{"State":{"Running":true}}]') + monkeypatch.setattr( + runner, "_run", lambda *_args, **_kwargs: b'[{"State":{"Running":true}}]' + ) runner._release_producer_gate(gate, "container", __import__("time").monotonic() + 2) reader.join(timeout=2) @@ -14085,7 +14091,9 @@ def test_producer_gate_readiness_timeout_is_terminal(tmp_path: Path, monkeypatch OSError(errno.ENXIO, "no reader") ), ) - monkeypatch.setattr(runner, "_run", lambda *_args: b'[{"State":{"Running":true}}]') + monkeypatch.setattr( + runner, "_run", lambda *_args, **_kwargs: b'[{"State":{"Running":true}}]' + ) calls = iter((0.0,)) monkeypatch.setattr(runner.time, "monotonic", lambda: next(calls, 11.0)) with pytest.raises(TimeoutError, match="gate readiness expired"): @@ -14136,8 +14144,10 @@ def test_service_launch_release_is_blocked_until_private_release_exists(tmp_path waiter.start() time.sleep(0.05) assert observed == [] - release.write_bytes(b"RELEASE\n") - release.chmod(0o400) + staged_release = tmp_path / "RELEASE.pending" + staged_release.write_bytes(b"RELEASE\n") + staged_release.chmod(0o400) + os.replace(staged_release, release) waiter.join(timeout=2) assert observed == [b"{}"] diff --git a/tests/unit/test_change_impact_git.py b/tests/unit/test_change_impact_git.py index fe3711692..16e2f61f5 100644 --- a/tests/unit/test_change_impact_git.py +++ b/tests/unit/test_change_impact_git.py @@ -5,6 +5,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import pytest + from tree_sitter_analyzer.diff_snapshot_capture import ChangedFile, FrozenFile from tree_sitter_analyzer.mcp.tools.change_impact_frozen import ( build_frozen_scope_result, @@ -176,3 +178,16 @@ def test_frozen_rename_out_of_cache_reports_visible_new_side() -> None: assert records == frozen["changed_records"] assert changed == ["source.py"] assert result["changed_files"] == ["source.py"] + + +@pytest.mark.parametrize("scope_mode", ["report", "strict"]) +def test_frozen_rename_assesses_both_visible_identities(scope_mode: str) -> None: + # PR #1254 review 3772454791: renamed-away config must expand graph scope. + frozen, consumer = _frozen_rename("architectural-constraints.yml", "renamed.yml") + + _result, _records, changed, assessed = build_frozen_scope_result( + frozen, consumer, "staged", [], scope_mode + ) + + assert changed == ["renamed.yml"] + assert assessed == ["architectural-constraints.yml", "renamed.yml"] diff --git a/tests/unit/test_codegraph_full_index_tool.py b/tests/unit/test_codegraph_full_index_tool.py index 278801714..8edb36992 100644 --- a/tests/unit/test_codegraph_full_index_tool.py +++ b/tests/unit/test_codegraph_full_index_tool.py @@ -1474,7 +1474,8 @@ async def test_incremental_scope_change_prunes_newly_excluded_rows(tmp_path): first = await tool.execute( {"mode": "full", "resolve_synapse": False, "output_format": "json"} ) - assert first["success"] is (os.name != "nt") + # PR #1254: full indexing and its portable manifest are cross-platform. + assert first["success"] is True second = await tool.execute( { diff --git a/tests/unit/test_codegraph_sitemap.py b/tests/unit/test_codegraph_sitemap.py index bc76653cf..1b5b64c75 100644 --- a/tests/unit/test_codegraph_sitemap.py +++ b/tests/unit/test_codegraph_sitemap.py @@ -1,6 +1,8 @@ """Tests for codegraph_sitemap MCP tool and CLI parity.""" +import errno import json +import os import sys from io import StringIO @@ -127,6 +129,33 @@ def test_tool_definition(self, indexed_project): assert defn["name"] == "codegraph_sitemap" assert "inputSchema" in defn + def test_tool_lifetime_releases_pinned_cache_directory(self, indexed_project): + # GH-1253: short-lived MCP tools must not exhaust the process FD limit. + tool = self._make_tool(indexed_project) + fd = tool._get_cache()._cache_dir_fd + assert isinstance(fd, int) + + del tool + + with pytest.raises(OSError) as exc_info: + os.fstat(fd) + assert exc_info.value.errno == errno.EBADF + + def test_cache_finalizer_suppresses_cleanup_errors( + self, indexed_project, monkeypatch + ): + # GH-1253: finalizer failures must not escape interpreter cleanup. + tool = self._make_tool(indexed_project) + cache = tool._get_cache() + + def fail_close(_cache): + raise OSError("close") + + with monkeypatch.context() as patcher: + patcher.setattr(type(cache), "close", fail_close) + assert cache.__del__() is None + cache.close() + def test_validate_arguments_bad_mode(self, indexed_project): tool = self._make_tool(indexed_project) with pytest.raises(ValueError, match="Invalid mode"): diff --git a/tests/unit/test_constraint_dsl.py b/tests/unit/test_constraint_dsl.py index 7e9e38c91..b8d73ea90 100644 --- a/tests/unit/test_constraint_dsl.py +++ b/tests/unit/test_constraint_dsl.py @@ -36,7 +36,6 @@ from __future__ import annotations import sqlite3 -import time from pathlib import Path import pytest @@ -460,567 +459,3 @@ def fullmatch(self, value: str) -> Any: assert violations == [] assert from_spy.calls == 0 - - -# --------------------------------------------------------------------------- -# Evaluator tests — exercise the streaming edge scan. -# --------------------------------------------------------------------------- - - -class TestEvaluator: - """Synthesize an ast_call_edges row and verify the evaluator's verdict.""" - - def test_violation_detected_mcp_to_cli(self, tmp_path: Path) -> None: - """A real edge that crosses a forbidden boundary → 1 error violation.""" - from tree_sitter_analyzer.constraints import ( - evaluate, - load_constraints, - ) - - # Stage constraints + db with one offending edge. - project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") - db_path = project / ".ast-cache" / "index.db" - _build_call_edges_db( - db_path, - rows=[ - ( - "do_thing", # caller_name - "tree_sitter_analyzer/mcp/x.py", # caller_file - 42, # caller_line - "cli_helper", # callee_name - "cli_helper", # callee_full - "tree_sitter_analyzer/cli/y.py", # callee_file - ), - ], - ) - - constraints = load_constraints(str(project)) - conn = sqlite3.connect(str(db_path)) - try: - violations = evaluate(constraints, conn) - finally: - conn.close() - - # Exactly one violation, with the right severity and source. - assert len(violations) == 1, ( - f"Expected exactly one violation, got {len(violations)}: {violations}" - ) - v = violations[0] - assert v.severity == "error" - assert v.rule_id == "dogfood-mcp-no-cli" - assert v.caller_file == "tree_sitter_analyzer/mcp/x.py" - assert v.callee_file == "tree_sitter_analyzer/cli/y.py" - assert v.caller_line == 42 - - def test_exception_suppresses_violation(self, tmp_path: Path) -> None: - """An edge whose caller is in ``exceptions:`` produces zero violations. - - The exception list is the only way a rule can be locally overridden - without disabling the whole rule, so this test pins down that the - match is exact (not a substring). - """ - from tree_sitter_analyzer.constraints import ( - evaluate, - load_constraints, - ) - - project = _stage_constraints_file(tmp_path, "exception_rule.yml") - db_path = project / ".ast-cache" / "index.db" - _build_call_edges_db( - db_path, - rows=[ - ( - "use_cli", - "mcp/bridge.py", # caller is explicitly excepted - 10, - "run_cli", - "run_cli", - "cli/runner.py", - ), - ], - ) - - constraints = load_constraints(str(project)) - conn = sqlite3.connect(str(db_path)) - try: - violations = evaluate(constraints, conn) - finally: - conn.close() - - assert violations == [], ( - f"Excepted caller must produce zero violations, got: {violations}" - ) - - def test_evaluate_keeps_rows_when_from_glob_has_no_literal_prefix( - self, tmp_path: Path - ) -> None: - """A leading wildcard disables SQL prefix filtering without data loss.""" - from tree_sitter_analyzer.constraints import evaluate - from tree_sitter_analyzer.constraints.schema import Constraint - - db_path = tmp_path / "index.db" - _build_call_edges_db( - db_path, - rows=[ - ( - "use_cli", - "custom/bridge.py", - 7, - "run_cli", - "run_cli", - "cli/runner.py", - ), - ], - ) - constraint = Constraint( - id="wildcard-caller", - severity="error", - rule="forbid", - from_glob="**", - to_glob="cli/**", - reason="test wildcard fallback", - ) - - conn = sqlite3.connect(str(db_path)) - try: - violations = evaluate([constraint], conn) - finally: - conn.close() - - assert len(violations) == 1 - assert violations[0].rule_id == "wildcard-caller" - - def test_select_query_keeps_callee_filter_when_callers_exceed_limit( - self, - ) -> None: - """PR #1225: an oversized caller set must not discard the callee filter.""" - from tree_sitter_analyzer.constraints.evaluator import ( - _MAX_SQL_PREFIX_FILTERS, - _build_select_query, - ) - from tree_sitter_analyzer.constraints.parser import compile_constraints - from tree_sitter_analyzer.constraints.schema import Constraint - - constraints = [ - Constraint( - id=f"rule-{index}", - severity="error", - rule="forbid", - from_glob=f"package-{index}/**", - to_glob="forbidden/**", - reason="test SQL filter bound", - ) - for index in range(_MAX_SQL_PREFIX_FILTERS + 1) - ] - - conn = sqlite3.connect(":memory:") - try: - select_sql, params = _build_select_query( - conn, - compile_constraints(constraints), - ) - finally: - conn.close() - - assert select_sql.count("instr(file_path, ?) = 1") == 0 - assert select_sql.count("callee_resolved_file") == 4 - assert params == ("forbidden/",) - - def test_select_query_keeps_caller_filter_when_callees_exceed_limit( - self, - ) -> None: - """PR #1225: an oversized callee set must not discard the caller filter.""" - from tree_sitter_analyzer.constraints.evaluator import ( - _MAX_SQL_PREFIX_FILTERS, - _build_select_query, - ) - from tree_sitter_analyzer.constraints.parser import compile_constraints - from tree_sitter_analyzer.constraints.schema import Constraint - - constraints = [ - Constraint( - id=f"rule-{index}", - severity="error", - rule="forbid", - from_glob="tree_sitter_analyzer/mcp/**", - to_glob=f"forbidden-{index}/**", - reason="test independent SQL filter bound", - ) - for index in range(_MAX_SQL_PREFIX_FILTERS + 1) - ] - - conn = sqlite3.connect(":memory:") - try: - select_sql, params = _build_select_query( - conn, - compile_constraints(constraints), - ) - finally: - conn.close() - - assert select_sql.count("instr(file_path, ?) = 1") == 1 - assert select_sql.count("callee_resolved_file") == 2 - assert params == ("tree_sitter_analyzer/mcp/",) - - def test_select_query_falls_back_when_both_prefix_sets_exceed_limit( - self, - ) -> None: - """PR #1225: two oversized prefix sets retain the unfiltered fallback.""" - from tree_sitter_analyzer.constraints.evaluator import ( - _MAX_SQL_PREFIX_FILTERS, - _build_select_query, - ) - from tree_sitter_analyzer.constraints.parser import compile_constraints - from tree_sitter_analyzer.constraints.schema import Constraint - - constraints = [ - Constraint( - id=f"rule-{index}", - severity="error", - rule="forbid", - from_glob=f"package-{index}/**", - to_glob=f"forbidden-{index}/**", - reason="test SQL filter fallback", - ) - for index in range(_MAX_SQL_PREFIX_FILTERS + 1) - ] - - conn = sqlite3.connect(":memory:") - try: - select_sql, params = _build_select_query( - conn, - compile_constraints(constraints), - ) - finally: - conn.close() - - assert select_sql.endswith("FROM edges WHERE kind = 'calls'") - assert params == () - - @pytest.mark.slow_ok - @pytest.mark.quarantine - @pytest.mark.timeout(120) - def test_eval_perf_on_synthetic_edges_under_500ms(self, tmp_path: Path) -> None: - """50k edges × 5 rules in <500 ms (Linux/macOS) / <2000 ms (Windows). - - The budget reflects how often this runs (every - ``analyze_change_impact`` call) and the size of a moderately - large repo's call-edge table. Going over the budget means the - evaluator is fighting the agent's loop instead of helping it. - - Marked ``slow_ok`` because the synthesis itself takes longer - than the per-test 5s budget on slow runners — but the measured - eval window stays within budget regardless. - Marked ``quarantine`` + ``timeout(120)`` because Windows CI - runners are ~10x slower than Linux; the 30s default timeout kills - the 50k-row setup before evaluate() is even reached. - """ - from tree_sitter_analyzer.constraints import ( - evaluate, - load_constraints, - ) - - project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") - db_path = project / ".ast-cache" / "index.db" - - # Synthesize 50,000 edges across five layered file roots. - # Roughly 10% are intentional violations so the evaluator's - # "violation" path is exercised, not just the early-exit happy path. - rows: list[tuple[str, str, int, str, str, str]] = [] - for i in range(50_000): - if i % 10 == 0: - caller_file = f"tree_sitter_analyzer/mcp/mod_{i}.py" - callee_file = f"tree_sitter_analyzer/cli/cli_{i}.py" - else: - caller_file = f"src/pkg_{i % 50}/mod_{i}.py" - callee_file = f"src/pkg_{(i + 1) % 50}/mod_{i + 1}.py" - rows.append( - ( - f"caller_{i}", - caller_file, - i % 1000 + 1, - f"callee_{i}", - "", - callee_file, - ) - ) - _build_call_edges_db(db_path, rows) - - # Augment the dogfood file with three more rules to hit 5 total — - # done in-memory so we don't bloat the checked-in fixture. - extra_rules_yml = """ - - id: bench-rule-extra-1 - severity: warn - rule: forbid - from: "src/pkg_1/**" - to: "src/pkg_2/**" - reason: "extra" - - id: bench-rule-extra-2 - severity: warn - rule: forbid - from: "src/pkg_3/**" - to: "src/pkg_4/**" - reason: "extra" - - id: bench-rule-extra-3 - severity: info - rule: forbid - from: "src/pkg_5/**" - to: "src/pkg_6/**" - reason: "extra" -""".rstrip("\n") - cfg = project / "architectural-constraints.yml" - cfg.write_text(cfg.read_text() + "\n" + extra_rules_yml + "\n") - - constraints = load_constraints(str(project)) - assert len(constraints) == 5, ( - f"Benchmark setup expects 5 rules, got {len(constraints)}" - ) - - import sys - - if sys.gettrace() is not None: - pytest.skip( - "tracked: coverage instrumentation invalidates the 500 ms " - "wall-clock perf budget; non-coverage CI enforces it." - ) - - # Hosted Windows runners and macOS 26 ARM64 runners are materially - # slower than Linux for this SQLite-heavy benchmark. Keep the strict - # Linux budget while allowing both constrained hosted platforms enough - # headroom to preserve the regression signal without runner flakiness. - budget_ms = 2000.0 if sys.platform in {"win32", "darwin"} else 500.0 - - conn = sqlite3.connect(str(db_path)) - try: - t0 = time.monotonic() - violations = evaluate(constraints, conn) - elapsed_ms = (time.monotonic() - t0) * 1000 - finally: - conn.close() - - # Sanity: the synthesised data really did trigger violations. - assert violations, "Benchmark data should produce violations" - - assert elapsed_ms < budget_ms, ( - f"evaluate() over 50k edges × 5 rules took {elapsed_ms:.0f} ms; " - f"budget is {budget_ms:.0f} ms on {sys.platform}. See spec — " - f"constraint checking runs on every change_impact call and must stay cheap." - ) - - def test_duplicate_pk_violations_deduplicated(self, tmp_path: Path) -> None: - """evaluate() dedupes violations that share the same PK. - - Regression test for #544: when the ``edges`` table contains two rows - for the same call site (same caller_file, caller_line, callee_name) - but with different ``callee_resolved_file`` values (e.g., because the - same call was indexed twice via different resolution paths), both rows - can match the same constraint rule and produce two ``Violation`` - objects with identical ``(rule_id, caller_file, caller_line, - callee_name)`` — which is the PRIMARY KEY of - ``ast_constraint_violations``. The old code's ``executemany`` would - then crash with ``UNIQUE constraint failed``. - - Fix: evaluate() must deduplicate on PK before returning so the persist - path always receives at most one Violation per PK tuple. - - The test asserts that exactly 1 violation is returned (not 2) so the - pin is tight and drift raises the test rather than silently passing - with a loose bound. - """ - import json as _json - - from tree_sitter_analyzer.constraints import evaluate, load_constraints - from tree_sitter_analyzer.graph.edge_store import ( - EDGE_STORE_SCHEMA, - EdgeKind, - symbol_node, - ) - - project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") - db_path = project / ".ast-cache" / "index.db" - db_path.parent.mkdir(parents=True, exist_ok=True) - - # Build two edges with identical (caller_file, caller_line, callee_name) - # but different callee_resolved_file — simulating a call site that was - # resolved to two targets by different indexing passes. - caller_file = "tree_sitter_analyzer/mcp/x.py" - caller_name = "do_thing" - caller_line = 42 - callee_name = "cli_helper" - callee_file_a = "tree_sitter_analyzer/cli/y.py" - callee_file_b = "tree_sitter_analyzer/cli/z.py" - - conn = sqlite3.connect(str(db_path)) - try: - conn.executescript(EDGE_STORE_SCHEMA) - for callee_file in (callee_file_a, callee_file_b): - source = symbol_node(caller_file, caller_name, caller_line) - target = symbol_node(callee_file, callee_name, 0) - metadata = _json.dumps( - { - "language": "python", - "caller_name": caller_name, - "caller_line": caller_line, - "callee_name": callee_name, - "callee_full": callee_name, - "callee_resolution": "project", - "callee_resolved_file": callee_file, - }, - ensure_ascii=False, - sort_keys=True, - ) - conn.execute( - "INSERT OR REPLACE INTO edges " - "(source_node_id, target_node_id, kind, line, provenance, " - " metadata, caller_name, callee_name, file_path, caller_line, " - " callee_full, callee_line, language, callee_resolution, " - " callee_resolved_file) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ( - source, - target, - EdgeKind.CALLS.value, - caller_line, - "tree-sitter", - metadata, - caller_name, - callee_name, - caller_file, - caller_line, - callee_name, - 0, - "python", - "project", - callee_file, - ), - ) - conn.commit() - - constraints = load_constraints(str(project)) - # Must NOT raise — two same-PK violations must be deduplicated. - violations = evaluate(constraints, conn) - finally: - conn.close() - - # Exactly 1 violation: the PK is (rule_id, caller_file, caller_line, - # callee_name). Two edges with the same call site are ONE violation, - # not two. The exact count is pinned so drift raises the test. - assert len(violations) == 1, ( - f"Expected exactly 1 violation after PK deduplication, " - f"got {len(violations)}: {violations}" - ) - v = violations[0] - assert v.rule_id == "dogfood-mcp-no-cli" - assert v.caller_file == caller_file - assert v.caller_line == caller_line - assert v.callee_name == callee_name - - def test_phantom_bare_name_resolution_skipped_when_no_import( - self, tmp_path: Path - ) -> None: - """Regression #780: bare-name callee resolved to forbidden module is - SKIPPED when the caller has no import from that module. - - Scenario mirrors the real bug: ``_hash_one_file`` in a core file - calls ``sha256_hash.update()``. The synapse resolver resolves - ``update`` to ``file_health_blocks.py`` (a forbidden mcp module) - because both define a method named ``update``. The caller imports - only ``hashlib`` — no ``mcp`` import at all. The evaluator must - NOT flag this as a constraint violation. - """ - from tree_sitter_analyzer.constraints import evaluate, load_constraints - - project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") - db_path = project / ".ast-cache" / "index.db" - - # Edge: core/_hash_one_file calls update(), resolver wrongly sets - # callee_resolved_file to an mcp module. - _build_call_edges_db( - db_path, - rows=[ - ( - "_hash_one_file", # caller_name - "tree_sitter_analyzer/core/analysis_session.py", # caller_file - 188, # caller_line - "update", # callee_name - "sha256_hash.update", # callee_full - "tree_sitter_analyzer/mcp/tools/utils/file_health_blocks.py", # callee_file (WRONG resolution) - ), - ], - ) - - # Populate ast_imports: analysis_session.py imports only hashlib, - # NOT file_health_blocks or any mcp module. - _populate_ast_imports( - db_path, - rows=[ - ("tree_sitter_analyzer/core/analysis_session.py", "hashlib"), - ("tree_sitter_analyzer/core/analysis_session.py", "json"), - ("tree_sitter_analyzer/core/analysis_session.py", "pathlib"), - ], - ) - - constraints = load_constraints(str(project)) - conn = sqlite3.connect(str(db_path)) - try: - violations = evaluate(constraints, conn) - finally: - conn.close() - - assert len(violations) == 0, ( - f"Expected 0 violations (phantom bare-name resolution must be filtered), " - f"got {len(violations)}: {violations}" - ) - - def test_real_violation_not_filtered_when_import_present( - self, tmp_path: Path - ) -> None: - """Regression #780: a genuine cross-boundary call IS flagged when the - caller actually imports from the forbidden module. - - Ensures the import-reachability guard does not over-filter real - violations — only phantom bare-name resolutions are suppressed. - """ - from tree_sitter_analyzer.constraints import evaluate, load_constraints - - project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") - db_path = project / ".ast-cache" / "index.db" - - # Edge: mcp/x.py calls cli_helper() which is genuinely in cli/y.py. - _build_call_edges_db( - db_path, - rows=[ - ( - "do_thing", # caller_name - "tree_sitter_analyzer/mcp/x.py", # caller_file - 42, # caller_line - "cli_helper", # callee_name - "cli_helper", # callee_full - "tree_sitter_analyzer/cli/y.py", # callee_file (REAL violation) - ), - ], - ) - - # mcp/x.py really does import from cli/y — this is the genuine case. - _populate_ast_imports( - db_path, - rows=[ - ("tree_sitter_analyzer/mcp/x.py", "tree_sitter_analyzer.cli.y"), - ], - ) - - constraints = load_constraints(str(project)) - conn = sqlite3.connect(str(db_path)) - try: - violations = evaluate(constraints, conn) - finally: - conn.close() - - assert len(violations) == 1, ( - f"Expected exactly 1 real violation (import IS present), " - f"got {len(violations)}: {violations}" - ) - v = violations[0] - assert v.rule_id == "dogfood-mcp-no-cli" - assert v.caller_file == "tree_sitter_analyzer/mcp/x.py" - assert v.callee_file == "tree_sitter_analyzer/cli/y.py" diff --git a/tests/unit/test_diff_snapshot_capture.py b/tests/unit/test_diff_snapshot_capture.py index ddda482d6..b2449a7a7 100644 --- a/tests/unit/test_diff_snapshot_capture.py +++ b/tests/unit/test_diff_snapshot_capture.py @@ -39,6 +39,7 @@ def test_create_rejects_payload_larger_than_reservation( ) -> None: root = _repo(tmp_path) registry = snapshots.DiffSnapshotRegistry() + install_fake_snapshot_materializer(monkeypatch, root) identity = snapshots.RootIdentity(str(root), 1, 2) monkeypatch.setattr(snapshots, "MAX_MATERIALIZED_BYTES", 1) monkeypatch.setattr( diff --git a/tests/unit/test_diff_snapshot_constraints.py b/tests/unit/test_diff_snapshot_constraints.py new file mode 100644 index 000000000..f30b4e945 --- /dev/null +++ b/tests/unit/test_diff_snapshot_constraints.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import pytest + +import tree_sitter_analyzer.diff_snapshot_constraints as constraints +from tree_sitter_analyzer.source_oracle import SourceOracleError +from tree_sitter_analyzer.source_oracle_git import GitEpoch + + +class _ConstraintEpoch: + index_bytes = b"index" + object_format = "sha1" + + def __init__(self, entry: bytes) -> None: + self._entry = entry + + def index_map(self) -> dict[bytes, bytes]: + return {b"architectural-constraints.yml": self._entry} + + +class _ConstraintGit: + def __init__(self, *args, **kwargs) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, *args) -> None: + return None + + +_REAL_IGNORED_SUBMODULE_SOURCES = constraints._ignored_submodule_sources + + +def _epoch() -> GitEpoch: + return GitEpoch(b"a" * 40, "sha1", (), (), (), ()) + + +@pytest.fixture(autouse=True) +def _no_ignored_submodule_sources(monkeypatch): + monkeypatch.setattr(constraints, "_ignored_submodule_sources", lambda *_args: ()) + + +def test_entry_parts_returns_missing_for_absent_index_entry() -> None: + assert constraints._entry_parts(None) == (None, None, "missing") + + +def test_entry_parts_rejects_malformed_index_entry() -> None: + with pytest.raises(SourceOracleError, match="^DIFF_SNAPSHOT_GIT_ERROR$"): + constraints._entry_parts(b"100644") + + +def test_frozen_constraint_config_rejects_non_file_index_entry(monkeypatch) -> None: + monkeypatch.setattr(constraints, "FrozenGitEnvironment", _ConstraintGit) + epoch = _ConstraintEpoch(b"120000 " + b"a" * 40 + b" 0") + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_UNSAFE$"): + constraints.frozen_index_constraint_config(".", epoch, 1e20, 1024) + + +def test_frozen_constraint_config_rejects_unreadable_blob(monkeypatch) -> None: + monkeypatch.setattr(constraints, "FrozenGitEnvironment", _ConstraintGit) + monkeypatch.setattr(constraints, "_blob", lambda *args: None) + epoch = _ConstraintEpoch(b"100644 " + b"a" * 40 + b" 0") + + with pytest.raises(SourceOracleError, match="^CONSTRAINT_CONFIG_UNSAFE$"): + constraints.frozen_index_constraint_config(".", epoch, 1e20, 1024) + + +def test_frozen_source_match_rejects_dirty_supported_source(monkeypatch) -> None: + outputs = iter((b"changed.py\0", b"")) + monkeypatch.setattr( + constraints, "frozen_index_output", lambda *args, **kwargs: next(outputs) + ) + + result = constraints.frozen_index_sources_match_worktree(".", _epoch(), 1e20, 1024) + + assert result is False + + +def test_frozen_source_match_allows_dirty_unsupported_file(monkeypatch) -> None: + outputs = iter((b"README.md\0", b"notes.txt\0")) + monkeypatch.setattr( + constraints, "frozen_index_output", lambda *args, **kwargs: next(outputs) + ) + + result = constraints.frozen_index_sources_match_worktree(".", _epoch(), 1e20, 1024) + + assert result is True + + +def test_frozen_source_match_includes_ignored_untracked_sources(monkeypatch) -> None: + calls = [] + + def output(_root, _index, args, **_kwargs): + calls.append(args) + return b"" if len(calls) == 1 else b"ignored.py\0" + + monkeypatch.setattr(constraints, "frozen_index_output", output) + + assert ( + constraints.frozen_index_sources_match_worktree(".", _epoch(), 1e20, 1024) + is False + ) + assert calls == [ + [ + "diff-files", + "--name-only", + "-z", + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=none", + ], + ["ls-files", "--others", "-z"], + ] + + +@pytest.mark.parametrize("hint", ["--assume-unchanged", "--skip-worktree"]) +def test_frozen_source_match_clears_index_hints_before_worktree_compare( + tmp_path, hint +) -> None: + # PR #1254 reviews 3767273213/3767273219: advisory index bits cannot hide bytes. + import subprocess + + subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True + ) + subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp_path, check=True) + source = tmp_path / "main.py" + source.write_text("old\n") + subprocess.run(["git", "add", "main.py"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "commit", "-m", "base"], cwd=tmp_path, check=True, capture_output=True + ) + subprocess.run(["git", "update-index", hint, "main.py"], cwd=tmp_path, check=True) + index_bytes = (tmp_path / ".git" / "index").read_bytes() + source.write_text("new\n") + epoch = GitEpoch(b"a" * 40, "sha1", (), (), (), (), index_bytes=index_bytes) + + assert ( + constraints.frozen_index_sources_match_worktree( + str(tmp_path), epoch, __import__("time").monotonic() + 30, 1024 + ) + is False + ) + + +def test_frozen_source_match_rejects_dirty_gitlink_container(monkeypatch) -> None: + # PR #1254 review 3768096801: extensionless gitlinks own indexed descendants. + outputs = iter((b"libs/component\0", b"")) + monkeypatch.setattr( + constraints, "frozen_index_output", lambda *args, **kwargs: next(outputs) + ) + entry = b"160000 " + b"a" * 40 + b" 0" + epoch = GitEpoch( + b"a" * 40, + "sha1", + ((b"libs/component", entry),), + (b"libs/component",), + (), + (), + index_bytes=b"index", + ) + + result = constraints.frozen_index_sources_match_worktree(".", epoch, 1e20, 1024) + + assert result is False + + +def test_ignored_submodule_supported_source_marks_staged_plane_divergent( + monkeypatch, +) -> None: + # PR #1254 review 3769193852: ignored child files do not dirty a gitlink. + outputs = iter((b"", b"")) + monkeypatch.setattr( + constraints, "frozen_index_output", lambda *_args, **_kwargs: next(outputs) + ) + monkeypatch.setattr( + constraints, "_ignored_submodule_sources", lambda *_args: (b"vendor/hidden.py",) + ) + + result = constraints.frozen_index_sources_match_worktree(".", _epoch(), 1e20, 1024) + + assert result is False + + +def test_constraint_error_maps_snapshot_capacity_to_config_capacity() -> None: + error = SourceOracleError("DIFF_SNAPSHOT_CAPACITY") + + assert constraints._constraint_error(error) == "CONSTRAINT_CONFIG_CAPACITY" + + +def test_constraint_error_hides_unclassified_source_oracle_failure() -> None: + error = SourceOracleError("DIFF_SNAPSHOT_GIT_ERROR") + + assert constraints._constraint_error(error) == "CONSTRAINT_CONFIG_UNSAFE" + + +def test_staged_constraint_config_preserves_consumer_specific_error() -> None: + def rejected(*_args): + raise SourceOracleError("DIFF_SNAPSHOT_CAPACITY") + + result = constraints.staged_constraint_config( + "/project", _epoch(), 10.0, 20, rejected + ) + assert result == (None, None, (), "CONSTRAINT_CONFIG_CAPACITY") + + +def test_ignored_submodule_inventory_parses_supported_and_unsupported_records( + tmp_path, monkeypatch +) -> None: + monkeypatch.setattr( + constraints, "_ignored_submodule_sources", _REAL_IGNORED_SUBMODULE_SOURCES + ) + (tmp_path / ".gitmodules").write_text("[submodule]\n") + gitlink = b"160000 " + b"a" * 40 + b" 0" + epoch = GitEpoch(b"a" * 40, "sha1", ((b"vendor", gitlink),), (), (), ()) + monkeypatch.setattr( + constraints, + "run_git_bounded", + lambda *_a, **_k: b"H\0vendor\0? ignored.txt\0? ignored.py\0", + ) + assert constraints._ignored_submodule_sources(str(tmp_path), epoch, 1e20, 20) == ( + b"vendor/ignored.py", + ) + + +def test_ignored_submodule_inventory_rejects_orphan_record( + tmp_path, monkeypatch +) -> None: + monkeypatch.setattr( + constraints, "_ignored_submodule_sources", _REAL_IGNORED_SUBMODULE_SOURCES + ) + (tmp_path / ".gitmodules").write_text("[submodule]\n") + gitlink = b"160000 " + b"a" * 40 + b" 0" + epoch = GitEpoch(b"a" * 40, "sha1", ((b"vendor", gitlink),), (), (), ()) + outputs = iter((b"orphan.py", b"H")) + monkeypatch.setattr(constraints, "run_git_bounded", lambda *_a, **_k: next(outputs)) + for _case in range(2): + with pytest.raises(SourceOracleError, match="DIFF_SNAPSHOT_GIT_ERROR"): + constraints._ignored_submodule_sources(str(tmp_path), epoch, 1e20, 20) + + +def test_staged_source_uncertainty_is_consumer_scoped() -> None: + # PR #1254 review 3771670605: generic snapshots survive constraint-only scans. + def rejected(*_args): + raise SourceOracleError("DIFF_SNAPSHOT_GIT_ERROR") + + assert ( + constraints.staged_sources_match_worktree( + "/project", _epoch(), 10.0, 20, rejected + ) + is False + ) diff --git a/tests/unit/test_diff_snapshot_leases.py b/tests/unit/test_diff_snapshot_leases.py index cba596b87..449d89b63 100644 --- a/tests/unit/test_diff_snapshot_leases.py +++ b/tests/unit/test_diff_snapshot_leases.py @@ -178,3 +178,25 @@ def test_release_failure_still_clears_consumer_reference(tmp_path, monkeypatch) consumer.release() assert consumer._snapshot is None + + +def test_frozen_snapshot_legacy_constructor_keeps_original_signature() -> None: + from tree_sitter_analyzer.diff_snapshot_leases import FrozenDiffSnapshot + from tree_sitter_analyzer.source_oracle import RootIdentity + + snapshot = FrozenDiffSnapshot( + "ds_legacy", + "idxsrc-v3:token", + RootIdentity("/repo", 1, 2), + "diff", + b"", + (), + (), + (), + 1.0, + 0, + ) + assert (snapshot.source_generation, snapshot.git_generation) == ( + "idxsrc-v3:token", + None, + ) diff --git a/tests/unit/test_diff_snapshot_registry.py b/tests/unit/test_diff_snapshot_registry.py index 4b28f2fec..1fd3bf490 100644 --- a/tests/unit/test_diff_snapshot_registry.py +++ b/tests/unit/test_diff_snapshot_registry.py @@ -43,6 +43,22 @@ def test_capacity_is_stable_error_and_close_releases_charge( assert registry.stats() == (0, 0) +def test_create_releases_reservation_after_keyboard_interrupt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3772454774: interruptions cannot poison registry capacity. + registry = snapshots.DiffSnapshotRegistry() + monkeypatch.setattr( + snapshots, + "canonical_root", + lambda _value: (_ for _ in ()).throw(KeyboardInterrupt("interrupted")), + ) + + with pytest.raises(KeyboardInterrupt, match="^interrupted$"): + registry.create(str(tmp_path), "diff", []) + assert registry._reservations == {} + + def test_expiry_retains_active_consumer_bytes_until_release( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -68,12 +84,22 @@ def test_expiry_retains_active_consumer_bytes_until_release( assert registry.stats() == (0, 0) -@POSIX_SNAPSHOT_TEST -def test_snapshot_id_is_bound_to_exact_root_identity(tmp_path: Path) -> None: - root = _repo(tmp_path / "one") - other = _repo(tmp_path / "two") - (root / "old.py").write_text("value = 2\n") - (other / "old.py").write_text("value = 2\n") +def test_snapshot_id_is_bound_to_exact_root_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "one" + other = tmp_path / "two" + expected = install_fake_snapshot_materializer(monkeypatch, root) + other.mkdir() + other_canonical = str(other.resolve()) + other_identity = snapshots.RootIdentity(other_canonical, 3, 4) + + def identify(value: str): + if str(Path(value).resolve()) == other_canonical: + return other_canonical, other_identity + return expected.realpath, expected + + monkeypatch.setattr(snapshots, "canonical_root", identify) registry = snapshots.DiffSnapshotRegistry() result = registry.create(str(root), "diff", []) @@ -380,35 +406,6 @@ def test_bind_assessed_scope_replaces_scope_for_single_pin( consumer.release() -def test_validate_publish_bounds_oracle_by_remaining_lifetime( - tmp_path: Path, monkeypatch -) -> None: - # PR #1252 review thread 3746940417. - now = [0.0] - root = tmp_path - install_fake_snapshot_materializer(monkeypatch, root) - registry = snapshots.DiffSnapshotRegistry(clock=lambda: now[0]) - created = registry.create(str(root), "diff", []) - consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(root)) - assert error is None - assert consumer is not None - now[0] = snapshots.HARD_LIFETIME_SECONDS - 1.0 - deadlines: list[float] = [] - monkeypatch.setattr(snapshots.time, "monotonic", lambda: 100.0) - - def oracle_with_deadline(root, mode, *, deadline=None): - deadlines.append(deadline) - return consumer.snapshot.source_generation, consumer.snapshot.root_identity - - monkeypatch.setattr(snapshots, "oracle_generation", oracle_with_deadline) - - result = registry.validate_publish(consumer) - - assert result is None - assert deadlines == [101.0] - consumer.release() - - def test_validate_publish_marks_closed_pinned_state_expired( tmp_path: Path, monkeypatch ) -> None: diff --git a/tests/unit/test_diff_snapshot_validation.py b/tests/unit/test_diff_snapshot_validation.py new file mode 100644 index 000000000..4f25ad5f2 --- /dev/null +++ b/tests/unit/test_diff_snapshot_validation.py @@ -0,0 +1,481 @@ +"""Authority, generation, and deadline coverage for diff snapshots.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import tree_sitter_analyzer.diff_snapshot_registry as snapshots +from tests.unit._diff_snapshot_support import install_fake_snapshot_materializer + + +def _created(tmp_path: Path, monkeypatch): + install_fake_snapshot_materializer(monkeypatch, tmp_path) + registry = snapshots.DiffSnapshotRegistry() + result = registry.create(str(tmp_path), "diff", []) + return tmp_path, registry, result + + +def test_create_rejects_lifetime_elapsed_during_materialization( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + now = [0.0] + install_fake_snapshot_materializer(monkeypatch, tmp_path) + registry = snapshots.DiffSnapshotRegistry(clock=lambda: now[0]) + capture = snapshots._capture_payload + + def finish_after_expiry(*args, **kwargs): + result = capture(*args, **kwargs) + now[0] = snapshots.HARD_LIFETIME_SECONDS + return result + + monkeypatch.setattr(snapshots, "_capture_payload", finish_after_expiry) + + result = registry.create(str(tmp_path), "diff", []) + + assert result == {"success": False, "error_code": "DIFF_SNAPSHOT_TIMEOUT"} + assert registry.stats() == (0, 0) + + +def test_create_rechecks_capacity_after_materialization( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + install_fake_snapshot_materializer(monkeypatch, tmp_path) + registry = snapshots.DiffSnapshotRegistry() + capture = snapshots._capture_payload + + def reserve_capacity_while_capturing(*args, **kwargs): + result = capture(*args, **kwargs) + registry._reservations["competing"] = snapshots.MAX_MATERIALIZED_BYTES + 1 + return result + + monkeypatch.setattr(snapshots, "_capture_payload", reserve_capacity_while_capturing) + + result = registry.create(str(tmp_path), "diff", []) + + assert result == {"success": False, "error_code": "DIFF_SNAPSHOT_CAPACITY"} + assert registry.stats() == (0, 0) + + +def test_validate_publish_rejects_generation_change_during_validation( + tmp_path: Path, monkeypatch +) -> None: + root = tmp_path + identity = install_fake_snapshot_materializer(monkeypatch, root) + registry = snapshots.DiffSnapshotRegistry() + created = registry.create(str(root), "diff", []) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(root)) + assert error is None + assert consumer is not None + generations = iter(("before", "after")) + monkeypatch.setattr( + snapshots, + "oracle_generation", + lambda *args, **kwargs: (next(generations), identity), + ) + + result = registry.validate_publish(consumer) + + assert result == "DIFF_SNAPSHOT_SOURCE_CHANGED" + consumer.release() + + +def test_validate_publish_rejects_consumer_released_by_publish_guard( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root, registry, created = _created(tmp_path, monkeypatch) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(root)) + assert error is None + assert consumer is not None + + result = registry.validate_publish(consumer, publish_guard=consumer.release) + + assert result == "DIFF_SNAPSHOT_EXPIRED" + + +def test_generic_snapshot_records_unsafe_constraint_config(tmp_path, monkeypatch): + # PR #1254 review 3769193867: generic consumers remain config-independent. + install_fake_snapshot_materializer(monkeypatch, tmp_path) + from tree_sitter_analyzer.source_oracle import SafePath + + monkeypatch.setattr( + snapshots, + "safe_workspace_path", + lambda *_a, **_k: SafePath(None, (), "symlink"), + ) + registry = snapshots.DiffSnapshotRegistry() + result = registry.create(str(tmp_path), "diff", []) + consumer, error = registry.acquire(str(result["diff_snapshot_id"]), str(tmp_path)) + + assert (result["success"], error, consumer is not None) == (True, None, True) + assert consumer is not None + assert consumer.snapshot.constraint_config_error == "CONSTRAINT_CONFIG_UNSAFE" + consumer.release() + + +def test_generic_snapshot_records_oversized_constraint_config(tmp_path, monkeypatch): + # PR #1254 review 3769193867: the generic capability survives config limits. + install_fake_snapshot_materializer(monkeypatch, tmp_path) + + def oversized(*_args, **_kwargs): + raise snapshots.SourceOracleError("DIFF_SNAPSHOT_CAPACITY") + + monkeypatch.setattr(snapshots, "safe_workspace_path", oversized) + registry = snapshots.DiffSnapshotRegistry() + result = registry.create(str(tmp_path), "diff", []) + consumer, error = registry.acquire(str(result["diff_snapshot_id"]), str(tmp_path)) + + assert (result["success"], error, consumer is not None) == (True, None, True) + assert consumer is not None + assert consumer.snapshot.constraint_config_error == "CONSTRAINT_CONFIG_CAPACITY" + consumer.release() + + +def test_staged_snapshot_requires_production_git_epoch(tmp_path, monkeypatch): + install_fake_snapshot_materializer(monkeypatch, tmp_path) + identity = snapshots.RootIdentity(str(tmp_path.resolve()), 1, 2) + + def production_shape( + root, mode="diff", *, deadline=None, manifest=None, epoch_out=None + ): + return "sg_test", identity + + monkeypatch.setattr(snapshots, "oracle_generation", production_shape) + result = snapshots.DiffSnapshotRegistry().create(str(tmp_path), "staged", []) + assert result == {"success": False, "error_code": "DIFF_SNAPSHOT_GIT_ERROR"} + + +def test_snapshot_rejects_final_git_generation_drift(tmp_path, monkeypatch): + install_fake_snapshot_materializer(monkeypatch, tmp_path) + identity = snapshots.RootIdentity(str(tmp_path.resolve()), 1, 2) + calls = 0 + + def drift(root, mode="diff", *, deadline=None, manifest=None): + nonlocal calls + calls += 1 + return ("changed" if calls == 3 else "sg_test"), identity + + monkeypatch.setattr(snapshots, "oracle_generation", drift) + result = snapshots.DiffSnapshotRegistry().create(str(tmp_path), "diff", []) + assert result == {"success": False, "error_code": "DIFF_SNAPSHOT_SOURCE_CHANGED"} + + +def test_shared_generation_preserves_oracle_monkeypatch_seam(monkeypatch) -> None: + def oracle(*_args, **_kwargs): + return "generation", None + + observed = [] + + def resolve(root, deadline, *, oracle_generation): + observed.append((root, deadline, oracle_generation)) + return "shared" + + monkeypatch.setattr(snapshots, "oracle_generation", oracle) + monkeypatch.setattr(snapshots, "resolve_shared_source_generation", resolve) + + assert snapshots.shared_source_generation("/repo", 4.0) == "shared" + assert observed == [("/repo", 4.0, oracle)] + + +def test_shared_generation_uses_fresh_reusable_capability(monkeypatch): + from contextlib import contextmanager + from types import SimpleNamespace + + import tree_sitter_analyzer.index_snapshot as index_snapshot + + @contextmanager + def reusable(_root): + yield SimpleNamespace(source_generation="idxsrc-v3:fresh") + + monkeypatch.setattr(index_snapshot, "lease_reusable_snapshot", reusable) + assert ( + snapshots.shared_source_generation("/repo", float("inf")) == "idxsrc-v3:fresh" + ) + + +def test_shared_generation_falls_back_to_direct_oracle_for_incompatible_index( + monkeypatch, +): + # PR #1254 review 3766246594: source-only capture must not require graph usability. + from contextlib import contextmanager + from types import SimpleNamespace + + import tree_sitter_analyzer.index_snapshot as index_snapshot + import tree_sitter_analyzer.index_source_snapshot as source_snapshot + + @contextmanager + def none(_root): + yield None + + @contextmanager + def incompatible(_root): + yield SimpleNamespace(source_generation=None, reason="INCOMPATIBLE_SCHEMA") + + captures = [] + + def capture(root, *, deadline): + captures.append((root, deadline)) + return SimpleNamespace( + state="exact", generation="idxsrc-v3:direct", reason=None + ) + + monkeypatch.setattr(index_snapshot, "lease_reusable_snapshot", none) + monkeypatch.setattr(index_snapshot, "lease_existing_snapshot", incompatible) + monkeypatch.setattr(source_snapshot, "capture_current_source_snapshot", capture) + + result = snapshots.shared_source_generation("/repo", float("inf")) + + assert (result, captures) == ( + "idxsrc-v3:direct", + [("/repo", float("inf"))], + ) + + +def test_shared_generation_uses_existing_index_token(monkeypatch): + from contextlib import contextmanager + from types import SimpleNamespace + + import tree_sitter_analyzer.index_snapshot as index_snapshot + + @contextmanager + def none(_root): + yield None + + @contextmanager + def existing(_root): + yield SimpleNamespace(source_generation="idxsrc-v3:existing", reason=None) + + monkeypatch.setattr(index_snapshot, "lease_reusable_snapshot", none) + monkeypatch.setattr(index_snapshot, "lease_existing_snapshot", existing) + assert snapshots.shared_source_generation("/repo", float("inf")) == ( + "idxsrc-v3:existing" + ) + + +def test_shared_generation_deadline_is_exact(monkeypatch): + monkeypatch.setattr(snapshots.time, "monotonic", lambda: 2.0) + with pytest.raises(snapshots.SourceOracleError, match="DIFF_SNAPSHOT_TIMEOUT"): + snapshots.shared_source_generation("/repo", 1.0) + + +def test_staged_snapshot_preserves_live_config_metadata_when_index_matches( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tree_sitter_analyzer.source_epoch import GitEpoch + + install_fake_snapshot_materializer(monkeypatch, tmp_path) + config = tmp_path / "architectural-constraints.yml" + config.write_bytes(b"version: 1\nconstraints: []\n") + live = snapshots.safe_workspace_path( + str(tmp_path.resolve()), config.name, deadline=float("inf"), limit=1024 * 1024 + ) + epoch = GitEpoch(b"head", "sha1", (), (), (), ()) + identity = snapshots.RootIdentity(str(tmp_path.resolve()), 1, 2) + + def oracle(root, mode="diff", *, deadline=None, manifest=None, epoch_out=None): + if epoch_out is not None: + epoch_out.append(epoch) + return "sg_test", identity + + monkeypatch.setattr(snapshots, "oracle_generation", oracle) + monkeypatch.setattr( + snapshots, + "frozen_index_constraint_config", + lambda *_a, **_k: (config.name, live.data, ("index-metadata",)), + ) + registry = snapshots.DiffSnapshotRegistry() + result = registry.create(str(tmp_path), "staged", []) + consumer, error = registry.acquire(str(result["diff_snapshot_id"]), str(tmp_path)) + + assert error is None + assert consumer is not None + assert ( + consumer.snapshot.constraint_config_path, + consumer.snapshot.constraint_config_data, + consumer.snapshot.constraint_config_metadata, + consumer.snapshot.staged_config_matches_worktree, + ) == (config.name, live.data, live.metadata, True) + consumer.release() + + +def test_staged_snapshot_retains_index_config_metadata_when_worktree_differs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tree_sitter_analyzer.source_epoch import GitEpoch + + install_fake_snapshot_materializer(monkeypatch, tmp_path) + epoch = GitEpoch(b"head", "sha1", (), (), (), ()) + identity = snapshots.RootIdentity(str(tmp_path.resolve()), 1, 2) + + def oracle(root, mode="diff", *, deadline=None, manifest=None, epoch_out=None): + if epoch_out is not None: + epoch_out.append(epoch) + return "sg_test", identity + + monkeypatch.setattr(snapshots, "oracle_generation", oracle) + monkeypatch.setattr( + snapshots, + "frozen_index_constraint_config", + lambda *_a, **_k: ( + "architectural-constraints.yml", + b"version: 1\nconstraints: []\n", + ("index-metadata",), + ), + ) + registry = snapshots.DiffSnapshotRegistry() + result = registry.create(str(tmp_path), "staged", []) + consumer, error = registry.acquire(str(result["diff_snapshot_id"]), str(tmp_path)) + + assert error is None + assert consumer is not None + assert ( + consumer.snapshot.constraint_config_metadata, + consumer.snapshot.staged_config_matches_worktree, + ) == (("index-metadata",), False) + consumer.release() + + +def test_snapshot_constraint_config_directory_falls_back_to_second_candidate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + install_fake_snapshot_materializer(monkeypatch, tmp_path) + (tmp_path / "architectural-constraints.yml").mkdir() + fallback = tmp_path / ".tree-sitter-analyzer" / "constraints.yml" + fallback.parent.mkdir() + fallback.write_text("version: 1\nconstraints: []\n") + + registry = snapshots.DiffSnapshotRegistry() + created = registry.create(str(tmp_path), "diff", []) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(tmp_path)) + + assert error is None + assert consumer is not None + assert consumer.snapshot.constraint_config_path == ( + ".tree-sitter-analyzer/constraints.yml" + ) + assert consumer.snapshot.constraint_config_data == fallback.read_bytes() + consumer.release() + + +def test_acquire_rejects_elapsed_caller_deadline_and_releases_pin( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + install_fake_snapshot_materializer(monkeypatch, tmp_path) + registry = snapshots.DiffSnapshotRegistry(clock=lambda: 5.0) + created = registry.create(str(tmp_path), "diff", []) + + consumer, error = registry.acquire( + str(created["diff_snapshot_id"]), str(tmp_path), deadline=5.0 + ) + + assert (consumer, error) == (None, "DIFF_SNAPSHOT_EXPIRED") + state = registry._states[str(created["diff_snapshot_id"])] + assert state.pins == {} + + +def test_validate_publish_rejects_elapsed_caller_deadline( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + install_fake_snapshot_materializer(monkeypatch, tmp_path) + registry = snapshots.DiffSnapshotRegistry(clock=lambda: 5.0) + created = registry.create(str(tmp_path), "diff", []) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(tmp_path)) + assert error is None + assert consumer is not None + + result = registry.validate_publish(consumer, deadline=5.0) + + assert result == "DIFF_SNAPSHOT_EXPIRED" + consumer.release() + + +def test_validate_publish_bounds_oracle_by_remaining_lifetime( + tmp_path: Path, monkeypatch +) -> None: + # PR #1252 review thread 3746940417. + now = [0.0] + root = tmp_path + install_fake_snapshot_materializer(monkeypatch, root) + registry = snapshots.DiffSnapshotRegistry(clock=lambda: now[0]) + created = registry.create(str(root), "diff", []) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(root)) + assert error is None + assert consumer is not None + now[0] = snapshots.HARD_LIFETIME_SECONDS - 1.0 + deadlines: list[float] = [] + monkeypatch.setattr(snapshots.time, "monotonic", lambda: 100.0) + + def oracle_with_deadline(root, mode, *, deadline=None): + deadlines.append(deadline) + return consumer.snapshot.source_generation, consumer.snapshot.root_identity + + monkeypatch.setattr(snapshots, "oracle_generation", oracle_with_deadline) + + result = registry.validate_publish(consumer) + + assert result is None + assert deadlines == [35.0, 35.0] + consumer.release() + + +def test_staged_submodule_probe_failure_remains_constraint_scoped( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 reviews 3771670605/3772454783: optional probes reserve publish time. + from tree_sitter_analyzer.source_epoch import GitEpoch + + now = [0.0] + install_fake_snapshot_materializer(monkeypatch, tmp_path) + epoch = GitEpoch(b"head", "sha1", (), (), (), ()) + identity = snapshots.RootIdentity(str(tmp_path.resolve()), 1, 2) + deadlines = [] + + def oracle(root, mode="diff", *, deadline=None, manifest=None, epoch_out=None): + deadlines.append(deadline) + if epoch_out is not None: + epoch_out.append(epoch) + if now[0] >= deadline: + raise snapshots.SourceOracleError("DIFF_SNAPSHOT_TIMEOUT") + return "sg_test", identity + + def rejected(_root, _epoch, deadline, _limit): + now[0] = deadline + return False + + monkeypatch.setattr(snapshots.time, "monotonic", lambda: now[0]) + monkeypatch.setattr(snapshots, "oracle_generation", oracle) + monkeypatch.setattr(snapshots, "staged_sources_match_worktree", rejected) + registry = snapshots.DiffSnapshotRegistry() + + created = registry.create(str(tmp_path), "staged", []) + create_deadlines = list(deadlines) + consumer, error = registry.acquire(str(created["diff_snapshot_id"]), str(tmp_path)) + + assert (created["success"], error, consumer is not None) == (True, None, True) + assert consumer is not None + assert consumer.snapshot.staged_source_matches_worktree is False + assert create_deadlines == [35.0, 35.0, 35.0] + assert now == [17.5] + consumer.release() + + +@pytest.mark.parametrize( + "failure", + [RuntimeError("oracle failed"), KeyboardInterrupt("oracle cancelled")], +) +def test_acquire_releases_pin_after_unexpected_oracle_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: BaseException +) -> None: + # PR #1254 final zero-gate: recoverable failures cannot exhaust snapshot slots. + root, registry, created = _created(tmp_path, monkeypatch) + + def fail(*_args, **_kwargs): + raise failure + + monkeypatch.setattr(snapshots, "oracle_generation", fail) + + with pytest.raises(type(failure), match=f"^{failure}$"): + registry.acquire(str(created["diff_snapshot_id"]), str(root)) + + assert next(iter(registry._states.values())).pins == {} diff --git a/tests/unit/test_evaluator.py b/tests/unit/test_evaluator.py new file mode 100644 index 000000000..e84f7228f --- /dev/null +++ b/tests/unit/test_evaluator.py @@ -0,0 +1,38 @@ +"""Exact top-level constraint evaluator behaviors.""" + +from __future__ import annotations + + +def test_evaluate_empty_rule_set_does_not_touch_database() -> None: + from tree_sitter_analyzer.constraints.evaluator import evaluate + + class RejectDatabaseAccess: + def execute(self, *_args: object, **_kwargs: object) -> None: + raise AssertionError("empty rules must not query the database") + + assert evaluate([], RejectDatabaseAccess()) == [] + + +def test_iter_violations_discards_duplicate_persisted_identity(monkeypatch) -> None: + import tree_sitter_analyzer.constraints.evaluator as owner + from tree_sitter_analyzer.constraints.parser import compile_constraints + from tree_sitter_analyzer.constraints.schema import Constraint + + rules = compile_constraints( + [Constraint("r", "error", "forbid", "src/**", "dst/**", "boundary")] + ) + rows = [ + ("caller", "src/a.py", 7, "callee", "dst/b.py"), + ("caller", "src/a.py", 7, "callee", "dst/c.py"), + ] + monkeypatch.setattr(owner, "_build_import_index", lambda *_a, **_k: None) + monkeypatch.setattr(owner, "_build_select_query", lambda *_a: ("sql", ())) + + class Connection: + def execute(self, *_args): + return iter(rows) + + result = list(owner._iter_violations(rules, Connection(), 1)) + assert [ + (v.rule_id, v.caller_file, v.caller_line, v.callee_name) for v in result + ] == [("r", "src/a.py", 7, "callee")] diff --git a/tests/unit/test_evaluator_bounds.py b/tests/unit/test_evaluator_bounds.py new file mode 100644 index 000000000..384f1f63c --- /dev/null +++ b/tests/unit/test_evaluator_bounds.py @@ -0,0 +1,146 @@ +"""Bounded evaluator deadline and materialization contracts.""" + +from __future__ import annotations + +import sqlite3 + +import pytest + + +def test_evaluator_returns_empty_when_compiler_filters_all_rules( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tree_sitter_analyzer.constraints import Constraint + from tree_sitter_analyzer.constraints import evaluator as evaluator_module + + rule = Constraint("r", "warn", "forbid", "**", "**", "test") + monkeypatch.setattr(evaluator_module, "compile_constraints", lambda _rules: []) + + assert evaluator_module.evaluate([rule], object()) == [] + + +def test_evaluator_rejects_negative_capacity() -> None: + from tree_sitter_analyzer.constraints import Constraint, evaluate + + rule = Constraint("r", "warn", "forbid", "**", "**", "test") + conn = sqlite3.connect(":memory:") + try: + with pytest.raises(ValueError, match="^capacity must be non-negative$"): + evaluate([rule], conn, capacity=-1) + finally: + conn.close() + + +def test_evaluator_checks_callback_for_each_materialized_violation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tree_sitter_analyzer.constraints import Constraint, Violation + from tree_sitter_analyzer.constraints import evaluator as evaluator_module + + violation = Violation("r", "a.py", "a", 1, "b", "b.py", "warn", 0) + monkeypatch.setattr( + evaluator_module, + "_iter_violations", + lambda *_args, **_kwargs: iter((violation,)), + ) + callbacks: list[str] = [] + + result = evaluator_module.evaluate( + [Constraint("r", "warn", "forbid", "**", "**", "test")], + object(), + check_callback=lambda: callbacks.append("checked"), + ) + + assert result == [violation] + assert callbacks == ["checked"] + + +def test_evaluator_capacity_bounds_materialized_violations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tree_sitter_analyzer.constraints import Constraint, Violation + from tree_sitter_analyzer.constraints import evaluator as evaluator_module + + violation = Violation("r", "a.py", "a", 1, "b", "b.py", "warn", 0) + monkeypatch.setattr( + evaluator_module, + "_iter_violations", + lambda *_args, **_kwargs: iter((violation,)), + ) + + with pytest.raises(RuntimeError, match="^CONSTRAINT_EVALUATION_CAPACITY$"): + evaluator_module.evaluate( + [Constraint("r", "warn", "forbid", "**", "**", "test")], + object(), + capacity=0, + ) + + +def test_iter_violations_checks_deadline_and_filters_scope_before_rules( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tree_sitter_analyzer.constraints import Constraint + from tree_sitter_analyzer.constraints import evaluator as evaluator_module + from tree_sitter_analyzer.constraints.parser import compile_constraints + + rows = [ + ("caller", "src/a.py", 1, "missing", None), + ("caller", "src/a.py", 2, "outside", "vendor/b.py"), + ("caller", "src/a.py", 3, "inside", "lib/b.py"), + ] + + class Connection: + def execute(self, _sql: str, _params: object): + return iter(rows) + + monkeypatch.setattr(evaluator_module, "_build_import_index", lambda *_a, **_k: None) + monkeypatch.setattr( + evaluator_module, "_build_select_query", lambda *_a: ("SELECT", ()) + ) + callbacks: list[str] = [] + result = list( + evaluator_module._iter_violations( + compile_constraints( + [Constraint("rule", "warn", "forbid", "src/**", "lib/**", "boundary")] + ), + Connection(), + 7, + scope_predicate=lambda _caller, callee: callee.startswith("lib/"), + check_callback=lambda: callbacks.append("checked"), + ) + ) + + assert [(item.rule_id, item.callee_file, item.detected_at) for item in result] == [ + ("rule", "lib/b.py", 7) + ] + assert callbacks == ["checked", "checked", "checked", "checked"] + + +def test_iter_violations_accepts_optional_callbacks_and_scope( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tree_sitter_analyzer.constraints import Constraint + from tree_sitter_analyzer.constraints import evaluator as evaluator_module + from tree_sitter_analyzer.constraints.parser import compile_constraints + + class Connection: + def execute(self, _sql: str, _params: object): + return iter((("caller", "src/a.py", 4, "callee", "lib/b.py"),)) + + monkeypatch.setattr(evaluator_module, "_build_import_index", lambda *_a, **_k: None) + monkeypatch.setattr( + evaluator_module, "_build_select_query", lambda *_a: ("SELECT", ()) + ) + result = list( + evaluator_module._iter_violations( + compile_constraints( + [Constraint("rule", "warn", "forbid", "src/**", "lib/**", "boundary")] + ), + Connection(), + 9, + ) + ) + + assert [(item.rule_id, item.caller_line, item.callee_file) for item in result] == [ + ("rule", 4, "lib/b.py") + ] diff --git a/tests/unit/test_evaluator_deduplication.py b/tests/unit/test_evaluator_deduplication.py new file mode 100644 index 000000000..5d169ce08 --- /dev/null +++ b/tests/unit/test_evaluator_deduplication.py @@ -0,0 +1,439 @@ +"""RED tests for the Inhibition / Constraint DSL (Feature 3). + +The ``tree_sitter_analyzer.constraints`` package does NOT exist yet — every +test in this file is expected to fail today, most with ``ImportError`` on +the first ``from tree_sitter_analyzer.constraints import ...`` line. That +is intentional: this is the contract the implementer must satisfy in the +follow-up GREEN phase. + +What this file pins down: + +1. **Parser shape**: ``load_constraints(project_root)`` returns ``list[Constraint]`` + where each Constraint is an immutable dataclass with ``id``, ``severity``, + ``rule``, ``from_glob``, ``to_glob``, ``reason``, ``exceptions``. +2. **Parser failure modes**: + * Malformed YAML → ``ConstraintParseError`` with a line-number context + in the message (so the agent can self-correct without re-reading the + whole file). + * Unknown top-level key → ``ConstraintParseError`` naming the key. + * Unknown per-rule key → warn-and-skip the rule, do NOT crash. This is + the forward-compat seam: a newer constraints.yml that uses a key the + analyzer hasn't learned yet still loads. +3. **Glob semantics**: ``match_glob`` must handle ``**`` recursive descent + *and* must NOT match unrelated paths that share a top-level prefix. +4. **Evaluation core**: ``evaluate(constraints, db_conn)`` streams the + ``ast_call_edges`` table, returns ``list[Violation]``, and respects + ``exceptions``. +5. **Performance budget**: 50k synthetic edges × 5 rules under 500 ms. + This budget is intentional — constraint checking runs on every + ``analyze_change_impact`` invocation, so it has to be cheap. +6. **Graceful missing config**: no constraints file → empty list, not an + exception. A repo with no constraints.yml is a perfectly valid state. + +Fixtures live at ``tests/fixtures/constraints/`` and are checked in. +""" + +from __future__ import annotations + +import sqlite3 +import time +from pathlib import Path + +import pytest + +# PyYAML ships transitively via ``mcp``; skip if missing so the file +# stays importable on minimal installs (the implementation module will +# fail-loud at import-time on its own). +pytest.importorskip("yaml") + + +FIXTURES = Path(__file__).parent.parent / "fixtures" / "constraints" + + +# --------------------------------------------------------------------------- +# Helpers — kept module-local so the RED tests are completely self-contained. +# --------------------------------------------------------------------------- + + +def _stage_constraints_file(tmp_path: Path, fixture_name: str) -> Path: + """Copy a fixture into ``/architectural-constraints.yml``. + + The loader resolves config relative to ``project_root`` and prefers + the root-level file over ``.tree-sitter-analyzer/constraints.yml``, + per spec. Returning ``tmp_path`` lets each test scope its filesystem + cleanly via pytest's ``tmp_path`` fixture. + """ + src = FIXTURES / fixture_name + assert src.exists(), f"Missing fixture: {src}" + dst = tmp_path / "architectural-constraints.yml" + dst.write_bytes(src.read_bytes()) + return tmp_path + + +def _build_call_edges_db( + db_path: Path, rows: list[tuple[str, str, int, str, str, str]] +) -> None: + """Create a minimal sqlite db with the unified ``edges`` schema. + + B1.2 moved the constraint evaluator's read source from ``ast_call_edges`` + to the single ``edges`` table. The CALLS rows are written in the + production shape (node ids via ``symbol_node``, scalars in metadata JSON, + real name/file columns); the callee's resolved file lives in + ``metadata.callee_resolved_file`` so the evaluator's COALESCE-to-file_path + logic behaves exactly as it did against the legacy resolution columns. + + Each row tuple is (caller_name, caller_file, caller_line, callee_name, + callee_full, callee_file). + """ + import json as _json + + from tree_sitter_analyzer.graph.edge_store import EdgeKind, symbol_node + + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + try: + from tree_sitter_analyzer.graph.edge_store import EDGE_STORE_SCHEMA + + conn.executescript(EDGE_STORE_SCHEMA) + params = [] + for ( + caller_name, + caller_file, + caller_line, + callee_name, + _callee_full, + callee_file, + ) in rows: + source = symbol_node(caller_file, caller_name, caller_line) + target = symbol_node(callee_file or caller_file, callee_name, 0) + metadata = { + "language": "python", + "caller_name": caller_name, + "caller_line": caller_line, + "callee_name": callee_name, + "callee_full": _callee_full, + "callee_resolution": "project" if callee_file else "unknown", + "callee_resolved_file": callee_file, + } + # B1.3: resolution scalars are real columns the evaluator reads + # directly (no json_extract), so populate them alongside metadata. + params.append( + ( + source, + target, + EdgeKind.CALLS.value, + 0, + "tree-sitter", + _json.dumps(metadata, ensure_ascii=False, sort_keys=True), + caller_name, + callee_name, + caller_file, + caller_line, + _callee_full, + 0, + "python", + "project" if callee_file else "unknown", + callee_file, + ) + ) + conn.executemany( + "INSERT OR REPLACE INTO edges " + "(source_node_id, target_node_id, kind, line, provenance, metadata, " + " caller_name, callee_name, file_path, caller_line, callee_full, " + " callee_line, language, callee_resolution, callee_resolved_file) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params, + ) + conn.commit() + finally: + conn.close() + + +def _populate_ast_imports( + db_path: Path, + rows: list[tuple[str, str]], +) -> None: + """Insert rows into ``ast_imports`` in the given DB. + + Each tuple is ``(file_path, module_path)``. The table is created if + absent so this helper can be called on a DB built by + ``_build_call_edges_db`` (which only creates the ``edges`` schema). + """ + conn = sqlite3.connect(str(db_path)) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ast_imports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'python', + module_path TEXT NOT NULL, + local_name TEXT NOT NULL DEFAULT '', + is_relative INTEGER NOT NULL DEFAULT 0, + is_star INTEGER NOT NULL DEFAULT 0, + alias_of TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + conn.executemany( + "INSERT INTO ast_imports (file_path, module_path) VALUES (?, ?)", + rows, + ) + conn.commit() + finally: + conn.close() + + @pytest.mark.timeout(120) + def test_eval_perf_on_synthetic_edges_under_500ms(self, tmp_path: Path) -> None: + """50k edges × 5 rules in <500 ms (Linux/macOS) / <2000 ms (Windows). + + The budget reflects how often this runs (every + ``analyze_change_impact`` call) and the size of a moderately + large repo's call-edge table. Going over the budget means the + evaluator is fighting the agent's loop instead of helping it. + + Marked ``slow_ok`` because the synthesis itself takes longer + than the per-test 5s budget on slow runners — but the measured + eval window stays within budget regardless. + Marked ``quarantine`` + ``timeout(120)`` because Windows CI + runners are ~10x slower than Linux; the 30s default timeout kills + the 50k-row setup before evaluate() is even reached. + """ + from tree_sitter_analyzer.constraints import ( + evaluate, + load_constraints, + ) + + project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") + db_path = project / ".ast-cache" / "index.db" + + # Synthesize 50,000 edges across five layered file roots. + # Roughly 10% are intentional violations so the evaluator's + # "violation" path is exercised, not just the early-exit happy path. + rows: list[tuple[str, str, int, str, str, str]] = [] + for i in range(50_000): + if i % 10 == 0: + caller_file = f"tree_sitter_analyzer/mcp/mod_{i}.py" + callee_file = f"tree_sitter_analyzer/cli/cli_{i}.py" + else: + caller_file = f"src/pkg_{i % 50}/mod_{i}.py" + callee_file = f"src/pkg_{(i + 1) % 50}/mod_{i + 1}.py" + rows.append( + ( + f"caller_{i}", + caller_file, + i % 1000 + 1, + f"callee_{i}", + "", + callee_file, + ) + ) + _build_call_edges_db(db_path, rows) + + # Augment the dogfood file with three more rules to hit 5 total — + # done in-memory so we don't bloat the checked-in fixture. + extra_rules_yml = """ + - id: bench-rule-extra-1 + severity: warn + rule: forbid + from: "src/pkg_1/**" + to: "src/pkg_2/**" + reason: "extra" + - id: bench-rule-extra-2 + severity: warn + rule: forbid + from: "src/pkg_3/**" + to: "src/pkg_4/**" + reason: "extra" + - id: bench-rule-extra-3 + severity: info + rule: forbid + from: "src/pkg_5/**" + to: "src/pkg_6/**" + reason: "extra" +""".rstrip("\n") + cfg = project / "architectural-constraints.yml" + cfg.write_text(cfg.read_text() + "\n" + extra_rules_yml + "\n") + + constraints = load_constraints(str(project)) + assert len(constraints) == 5, ( + f"Benchmark setup expects 5 rules, got {len(constraints)}" + ) + + import sys + + if sys.gettrace() is not None: + pytest.skip( + "tracked: coverage instrumentation invalidates the 500 ms " + "wall-clock perf budget; non-coverage CI enforces it." + ) + + # Hosted Windows runners and macOS 26 ARM64 runners are materially + # slower than Linux for this SQLite-heavy benchmark. Keep the strict + # Linux budget while allowing both constrained hosted platforms enough + # headroom to preserve the regression signal without runner flakiness. + budget_ms = 2000.0 if sys.platform in {"win32", "darwin"} else 500.0 + + conn = sqlite3.connect(str(db_path)) + try: + t0 = time.monotonic() + violations = evaluate(constraints, conn) + elapsed_ms = (time.monotonic() - t0) * 1000 + finally: + conn.close() + + # Sanity: the synthesised data really did trigger violations. + assert violations, "Benchmark data should produce violations" + + assert elapsed_ms < budget_ms, ( + f"evaluate() over 50k edges × 5 rules took {elapsed_ms:.0f} ms; " + f"budget is {budget_ms:.0f} ms on {sys.platform}. See spec — " + f"constraint checking runs on every change_impact call and must stay cheap." + ) + + def test_duplicate_pk_violations_deduplicated(self, tmp_path: Path) -> None: + """evaluate() dedupes violations that share the same PK. + + Regression test for #544: when the ``edges`` table contains two rows + for the same call site (same caller_file, caller_line, callee_name) + but with different ``callee_resolved_file`` values (e.g., because the + same call was indexed twice via different resolution paths), both rows + can match the same constraint rule and produce two ``Violation`` + objects with identical ``(rule_id, caller_file, caller_line, + callee_name)`` — which is the PRIMARY KEY of + ``ast_constraint_violations``. The old code's ``executemany`` would + then crash with ``UNIQUE constraint failed``. + + Fix: evaluate() must deduplicate on PK before returning so the persist + path always receives at most one Violation per PK tuple. + + The test asserts that exactly 1 violation is returned (not 2) so the + pin is tight and drift raises the test rather than silently passing + with a loose bound. + """ + import json as _json + + from tree_sitter_analyzer.constraints import evaluate, load_constraints + from tree_sitter_analyzer.graph.edge_store import ( + EDGE_STORE_SCHEMA, + EdgeKind, + symbol_node, + ) + + project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") + db_path = project / ".ast-cache" / "index.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + + # Build two edges with identical (caller_file, caller_line, callee_name) + # but different callee_resolved_file — simulating a call site that was + # resolved to two targets by different indexing passes. + caller_file = "tree_sitter_analyzer/mcp/x.py" + caller_name = "do_thing" + caller_line = 42 + callee_name = "cli_helper" + callee_file_a = "tree_sitter_analyzer/cli/y.py" + callee_file_b = "tree_sitter_analyzer/cli/z.py" + + conn = sqlite3.connect(str(db_path)) + try: + conn.executescript(EDGE_STORE_SCHEMA) + for callee_file in (callee_file_a, callee_file_b): + source = symbol_node(caller_file, caller_name, caller_line) + target = symbol_node(callee_file, callee_name, 0) + metadata = _json.dumps( + { + "language": "python", + "caller_name": caller_name, + "caller_line": caller_line, + "callee_name": callee_name, + "callee_full": callee_name, + "callee_resolution": "project", + "callee_resolved_file": callee_file, + }, + ensure_ascii=False, + sort_keys=True, + ) + conn.execute( + "INSERT OR REPLACE INTO edges " + "(source_node_id, target_node_id, kind, line, provenance, " + " metadata, caller_name, callee_name, file_path, caller_line, " + " callee_full, callee_line, language, callee_resolution, " + " callee_resolved_file) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + source, + target, + EdgeKind.CALLS.value, + caller_line, + "tree-sitter", + metadata, + caller_name, + callee_name, + caller_file, + caller_line, + callee_name, + 0, + "python", + "project", + callee_file, + ), + ) + conn.commit() + + constraints = load_constraints(str(project)) + # Must NOT raise — two same-PK violations must be deduplicated. + violations = evaluate(constraints, conn) + finally: + conn.close() + + # Exactly 1 violation: the PK is (rule_id, caller_file, caller_line, + # callee_name). Two edges with the same call site are ONE violation, + # not two. The exact count is pinned so drift raises the test. + assert len(violations) == 1, ( + f"Expected exactly 1 violation after PK deduplication, " + f"got {len(violations)}: {violations}" + ) + v = violations[0] + assert v.rule_id == "dogfood-mcp-no-cli" + assert v.caller_file == caller_file + assert v.caller_line == caller_line + assert v.callee_name == callee_name + + def test_scope_predicate_filters_before_duplicate_pk_resolution( + self, tmp_path: Path + ) -> None: + # PR #1254 review 3765918811: an out-of-scope duplicate must not win dedup. + from tree_sitter_analyzer.constraints import Constraint, evaluate + + conn = sqlite3.connect(tmp_path / "scope.db") + try: + conn.execute( + "CREATE TABLE edges (kind TEXT, caller_name TEXT, file_path TEXT, " + "caller_line INTEGER, callee_name TEXT, callee_resolved_file TEXT)" + ) + conn.executemany( + "INSERT INTO edges VALUES ('calls', 'caller', 'outside/caller.py', " + "7, 'target', ?)", + [("targets/outside.py",), ("targets/in_scope.py",)], + ) + rule = Constraint( + id="scoped", + severity="error", + rule="forbid", + from_glob="outside/**", + to_glob="targets/**", + reason="test", + ) + + violations = evaluate( + [rule], + conn, + scope_predicate=lambda _caller, callee: callee == "targets/in_scope.py", + ) + finally: + conn.close() + + assert [violation.callee_file for violation in violations] == [ + "targets/in_scope.py" + ] diff --git a/tests/unit/test_evaluator_import_resolution.py b/tests/unit/test_evaluator_import_resolution.py new file mode 100644 index 000000000..45e71be4d --- /dev/null +++ b/tests/unit/test_evaluator_import_resolution.py @@ -0,0 +1,396 @@ +"""RED tests for the Inhibition / Constraint DSL (Feature 3). + +The ``tree_sitter_analyzer.constraints`` package does NOT exist yet — every +test in this file is expected to fail today, most with ``ImportError`` on +the first ``from tree_sitter_analyzer.constraints import ...`` line. That +is intentional: this is the contract the implementer must satisfy in the +follow-up GREEN phase. + +What this file pins down: + +1. **Parser shape**: ``load_constraints(project_root)`` returns ``list[Constraint]`` + where each Constraint is an immutable dataclass with ``id``, ``severity``, + ``rule``, ``from_glob``, ``to_glob``, ``reason``, ``exceptions``. +2. **Parser failure modes**: + * Malformed YAML → ``ConstraintParseError`` with a line-number context + in the message (so the agent can self-correct without re-reading the + whole file). + * Unknown top-level key → ``ConstraintParseError`` naming the key. + * Unknown per-rule key → warn-and-skip the rule, do NOT crash. This is + the forward-compat seam: a newer constraints.yml that uses a key the + analyzer hasn't learned yet still loads. +3. **Glob semantics**: ``match_glob`` must handle ``**`` recursive descent + *and* must NOT match unrelated paths that share a top-level prefix. +4. **Evaluation core**: ``evaluate(constraints, db_conn)`` streams the + ``ast_call_edges`` table, returns ``list[Violation]``, and respects + ``exceptions``. +5. **Performance budget**: 50k synthetic edges × 5 rules under 500 ms. + This budget is intentional — constraint checking runs on every + ``analyze_change_impact`` invocation, so it has to be cheap. +6. **Graceful missing config**: no constraints file → empty list, not an + exception. A repo with no constraints.yml is a perfectly valid state. + +Fixtures live at ``tests/fixtures/constraints/`` and are checked in. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +# PyYAML ships transitively via ``mcp``; skip if missing so the file +# stays importable on minimal installs (the implementation module will +# fail-loud at import-time on its own). +pytest.importorskip("yaml") + + +FIXTURES = Path(__file__).parent.parent / "fixtures" / "constraints" + + +# --------------------------------------------------------------------------- +# Helpers — kept module-local so the RED tests are completely self-contained. +# --------------------------------------------------------------------------- + + +def _stage_constraints_file(tmp_path: Path, fixture_name: str) -> Path: + """Copy a fixture into ``/architectural-constraints.yml``. + + The loader resolves config relative to ``project_root`` and prefers + the root-level file over ``.tree-sitter-analyzer/constraints.yml``, + per spec. Returning ``tmp_path`` lets each test scope its filesystem + cleanly via pytest's ``tmp_path`` fixture. + """ + src = FIXTURES / fixture_name + assert src.exists(), f"Missing fixture: {src}" + dst = tmp_path / "architectural-constraints.yml" + dst.write_bytes(src.read_bytes()) + return tmp_path + + +def _build_call_edges_db( + db_path: Path, rows: list[tuple[str, str, int, str, str, str]] +) -> None: + """Create a minimal sqlite db with the unified ``edges`` schema. + + B1.2 moved the constraint evaluator's read source from ``ast_call_edges`` + to the single ``edges`` table. The CALLS rows are written in the + production shape (node ids via ``symbol_node``, scalars in metadata JSON, + real name/file columns); the callee's resolved file lives in + ``metadata.callee_resolved_file`` so the evaluator's COALESCE-to-file_path + logic behaves exactly as it did against the legacy resolution columns. + + Each row tuple is (caller_name, caller_file, caller_line, callee_name, + callee_full, callee_file). + """ + import json as _json + + from tree_sitter_analyzer.graph.edge_store import EdgeKind, symbol_node + + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + try: + from tree_sitter_analyzer.graph.edge_store import EDGE_STORE_SCHEMA + + conn.executescript(EDGE_STORE_SCHEMA) + params = [] + for ( + caller_name, + caller_file, + caller_line, + callee_name, + _callee_full, + callee_file, + ) in rows: + source = symbol_node(caller_file, caller_name, caller_line) + target = symbol_node(callee_file or caller_file, callee_name, 0) + metadata = { + "language": "python", + "caller_name": caller_name, + "caller_line": caller_line, + "callee_name": callee_name, + "callee_full": _callee_full, + "callee_resolution": "project" if callee_file else "unknown", + "callee_resolved_file": callee_file, + } + # B1.3: resolution scalars are real columns the evaluator reads + # directly (no json_extract), so populate them alongside metadata. + params.append( + ( + source, + target, + EdgeKind.CALLS.value, + 0, + "tree-sitter", + _json.dumps(metadata, ensure_ascii=False, sort_keys=True), + caller_name, + callee_name, + caller_file, + caller_line, + _callee_full, + 0, + "python", + "project" if callee_file else "unknown", + callee_file, + ) + ) + conn.executemany( + "INSERT OR REPLACE INTO edges " + "(source_node_id, target_node_id, kind, line, provenance, metadata, " + " caller_name, callee_name, file_path, caller_line, callee_full, " + " callee_line, language, callee_resolution, callee_resolved_file) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params, + ) + conn.commit() + finally: + conn.close() + + +def _populate_ast_imports( + db_path: Path, + rows: list[tuple[str, str]], +) -> None: + """Insert rows into ``ast_imports`` in the given DB. + + Each tuple is ``(file_path, module_path)``. The table is created if + absent so this helper can be called on a DB built by + ``_build_call_edges_db`` (which only creates the ``edges`` schema). + """ + conn = sqlite3.connect(str(db_path)) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ast_imports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'python', + module_path TEXT NOT NULL, + local_name TEXT NOT NULL DEFAULT '', + is_relative INTEGER NOT NULL DEFAULT 0, + is_star INTEGER NOT NULL DEFAULT 0, + alias_of TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + conn.executemany( + "INSERT INTO ast_imports (file_path, module_path) VALUES (?, ?)", + rows, + ) + conn.commit() + finally: + conn.close() + + +def test_phantom_bare_name_resolution_skipped_when_no_import( + tmp_path: Path, +) -> None: + """Regression #780: bare-name callee resolved to forbidden module is + SKIPPED when the caller has no import from that module. + + Scenario mirrors the real bug: ``_hash_one_file`` in a core file + calls ``sha256_hash.update()``. The synapse resolver resolves + ``update`` to ``file_health_blocks.py`` (a forbidden mcp module) + because both define a method named ``update``. The caller imports + only ``hashlib`` — no ``mcp`` import at all. The evaluator must + NOT flag this as a constraint violation. + """ + from tree_sitter_analyzer.constraints import evaluate, load_constraints + + project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") + db_path = project / ".ast-cache" / "index.db" + + # Edge: core/_hash_one_file calls update(), resolver wrongly sets + # callee_resolved_file to an mcp module. + _build_call_edges_db( + db_path, + rows=[ + ( + "_hash_one_file", # caller_name + "tree_sitter_analyzer/core/analysis_session.py", # caller_file + 188, # caller_line + "update", # callee_name + "sha256_hash.update", # callee_full + "tree_sitter_analyzer/mcp/tools/utils/file_health_blocks.py", # callee_file (WRONG resolution) + ), + ], + ) + + # Populate ast_imports: analysis_session.py imports only hashlib, + # NOT file_health_blocks or any mcp module. + _populate_ast_imports( + db_path, + rows=[ + ("tree_sitter_analyzer/core/analysis_session.py", "hashlib"), + ("tree_sitter_analyzer/core/analysis_session.py", "json"), + ("tree_sitter_analyzer/core/analysis_session.py", "pathlib"), + ], + ) + + constraints = load_constraints(str(project)) + conn = sqlite3.connect(str(db_path)) + try: + violations = evaluate(constraints, conn) + finally: + conn.close() + + assert violations == [] + + +def test_real_violation_not_filtered_when_import_present( + tmp_path: Path, +) -> None: + """Regression #780: a genuine cross-boundary call IS flagged when the + caller actually imports from the forbidden module. + + Ensures the import-reachability guard does not over-filter real + violations — only phantom bare-name resolutions are suppressed. + """ + from tree_sitter_analyzer.constraints import evaluate, load_constraints + + project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") + db_path = project / ".ast-cache" / "index.db" + + # Edge: mcp/x.py calls cli_helper() which is genuinely in cli/y.py. + _build_call_edges_db( + db_path, + rows=[ + ( + "do_thing", # caller_name + "tree_sitter_analyzer/mcp/x.py", # caller_file + 42, # caller_line + "cli_helper", # callee_name + "cli_helper", # callee_full + "tree_sitter_analyzer/cli/y.py", # callee_file (REAL violation) + ), + ], + ) + + # mcp/x.py really does import from cli/y — this is the genuine case. + _populate_ast_imports( + db_path, + rows=[ + ("tree_sitter_analyzer/mcp/x.py", "tree_sitter_analyzer.cli.y"), + ], + ) + + constraints = load_constraints(str(project)) + conn = sqlite3.connect(str(db_path)) + try: + violations = evaluate(constraints, conn) + finally: + conn.close() + + assert len(violations) == 1, ( + f"Expected exactly 1 real violation (import IS present), " + f"got {len(violations)}: {violations}" + ) + v = violations[0] + assert v.rule_id == "dogfood-mcp-no-cli" + assert v.caller_file == "tree_sitter_analyzer/mcp/x.py" + assert v.callee_file == "tree_sitter_analyzer/cli/y.py" + + +def test_constraint_bytes_reject_non_utf8(): + from tree_sitter_analyzer.constraints.parser import ( + ConstraintParseError, + load_constraints_bytes, + ) + + with pytest.raises(ConstraintParseError, match="Could not decode"): + load_constraints_bytes(b"\xff", "constraints.yml") + + +def test_constraint_loader_wraps_read_failure(tmp_path, monkeypatch): + from tree_sitter_analyzer.constraints.parser import ( + ConstraintParseError, + load_constraints, + ) + + config = tmp_path / "architectural-constraints.yml" + config.write_text("version: 1\nconstraints: []\n") + monkeypatch.setattr( + Path, "read_bytes", lambda _self: (_ for _ in ()).throw(OSError("denied")) + ) + with pytest.raises(ConstraintParseError, match="Could not read"): + load_constraints(tmp_path) + + +def test_evaluator_callback_covers_python_import_materialization() -> None: + from tree_sitter_analyzer.constraints import Constraint, evaluate + + # PR #1254 review 3767373475: Python-side DB materialization obeys callback. + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE ast_imports(file_path TEXT, module_path TEXT)") + conn.execute("INSERT INTO ast_imports VALUES ('a.py', 'b')") + calls = [] + try: + with pytest.raises(RuntimeError, match="^deadline$"): + evaluate( + [Constraint("r", "warn", "deny", "**", "**", "test")], + conn, + check_callback=lambda: ( + calls.append("check") + or (_ for _ in ()).throw(RuntimeError("deadline")) + ), + ) + finally: + conn.close() + assert calls == ["check"] + + +def test_evaluator_bounds_python_import_materialization() -> None: + from tree_sitter_analyzer.constraints import Constraint, evaluate + + # PR #1254 review 3767373475: API capacity bounds Python-owned collections. + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE ast_imports(file_path TEXT, module_path TEXT)") + conn.executemany( + "INSERT INTO ast_imports VALUES (?, ?)", [("a.py", "b"), ("c.py", "d")] + ) + try: + with pytest.raises(RuntimeError, match="^CONSTRAINT_EVALUATION_CAPACITY$"): + evaluate( + [Constraint("r", "warn", "deny", "**", "**", "test")], conn, capacity=1 + ) + finally: + conn.close() + + +@pytest.mark.parametrize("row", [("", "missing.file"), ("caller.py", "")]) +def test_import_index_skips_incomplete_rows(row) -> None: + from tree_sitter_analyzer.constraints.evaluator_import_resolution import ( + _build_import_index, + ) + + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE ast_imports(file_path TEXT, module_path TEXT)") + conn.execute("INSERT INTO ast_imports VALUES (?, ?)", row) + try: + assert _build_import_index(conn) == {} + finally: + conn.close() + + +def test_import_index_does_not_add_empty_terminal_identifier() -> None: + from tree_sitter_analyzer.constraints.evaluator_import_resolution import ( + _build_import_index, + ) + + conn = sqlite3.connect(":memory:") + conn.execute("CREATE TABLE ast_imports(file_path TEXT, module_path TEXT)") + conn.execute("INSERT INTO ast_imports VALUES ('caller.py', '.')") + try: + assert _build_import_index(conn) == {"caller.py": {"."}} + finally: + conn.close() + + +def test_import_guard_allows_callers_without_import_evidence() -> None: + from tree_sitter_analyzer.constraints.evaluator_import_resolution import ( + _callee_is_imported, + ) + + assert _callee_is_imported("caller.py", "package/target.py", {}) is True diff --git a/tests/unit/test_evaluator_selection.py b/tests/unit/test_evaluator_selection.py new file mode 100644 index 000000000..1964722c8 --- /dev/null +++ b/tests/unit/test_evaluator_selection.py @@ -0,0 +1,415 @@ +"""RED tests for the Inhibition / Constraint DSL (Feature 3). + +The ``tree_sitter_analyzer.constraints`` package does NOT exist yet — every +test in this file is expected to fail today, most with ``ImportError`` on +the first ``from tree_sitter_analyzer.constraints import ...`` line. That +is intentional: this is the contract the implementer must satisfy in the +follow-up GREEN phase. + +What this file pins down: + +1. **Parser shape**: ``load_constraints(project_root)`` returns ``list[Constraint]`` + where each Constraint is an immutable dataclass with ``id``, ``severity``, + ``rule``, ``from_glob``, ``to_glob``, ``reason``, ``exceptions``. +2. **Parser failure modes**: + * Malformed YAML → ``ConstraintParseError`` with a line-number context + in the message (so the agent can self-correct without re-reading the + whole file). + * Unknown top-level key → ``ConstraintParseError`` naming the key. + * Unknown per-rule key → warn-and-skip the rule, do NOT crash. This is + the forward-compat seam: a newer constraints.yml that uses a key the + analyzer hasn't learned yet still loads. +3. **Glob semantics**: ``match_glob`` must handle ``**`` recursive descent + *and* must NOT match unrelated paths that share a top-level prefix. +4. **Evaluation core**: ``evaluate(constraints, db_conn)`` streams the + ``ast_call_edges`` table, returns ``list[Violation]``, and respects + ``exceptions``. +5. **Performance budget**: 50k synthetic edges × 5 rules under 500 ms. + This budget is intentional — constraint checking runs on every + ``analyze_change_impact`` invocation, so it has to be cheap. +6. **Graceful missing config**: no constraints file → empty list, not an + exception. A repo with no constraints.yml is a perfectly valid state. + +Fixtures live at ``tests/fixtures/constraints/`` and are checked in. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +# PyYAML ships transitively via ``mcp``; skip if missing so the file +# stays importable on minimal installs (the implementation module will +# fail-loud at import-time on its own). +pytest.importorskip("yaml") + + +FIXTURES = Path(__file__).parent.parent / "fixtures" / "constraints" + + +# --------------------------------------------------------------------------- +# Helpers — kept module-local so the RED tests are completely self-contained. +# --------------------------------------------------------------------------- + + +def _stage_constraints_file(tmp_path: Path, fixture_name: str) -> Path: + """Copy a fixture into ``/architectural-constraints.yml``. + + The loader resolves config relative to ``project_root`` and prefers + the root-level file over ``.tree-sitter-analyzer/constraints.yml``, + per spec. Returning ``tmp_path`` lets each test scope its filesystem + cleanly via pytest's ``tmp_path`` fixture. + """ + src = FIXTURES / fixture_name + assert src.exists(), f"Missing fixture: {src}" + dst = tmp_path / "architectural-constraints.yml" + dst.write_bytes(src.read_bytes()) + return tmp_path + + +def _build_call_edges_db( + db_path: Path, rows: list[tuple[str, str, int, str, str, str]] +) -> None: + """Create a minimal sqlite db with the unified ``edges`` schema. + + B1.2 moved the constraint evaluator's read source from ``ast_call_edges`` + to the single ``edges`` table. The CALLS rows are written in the + production shape (node ids via ``symbol_node``, scalars in metadata JSON, + real name/file columns); the callee's resolved file lives in + ``metadata.callee_resolved_file`` so the evaluator's COALESCE-to-file_path + logic behaves exactly as it did against the legacy resolution columns. + + Each row tuple is (caller_name, caller_file, caller_line, callee_name, + callee_full, callee_file). + """ + import json as _json + + from tree_sitter_analyzer.graph.edge_store import EdgeKind, symbol_node + + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + try: + from tree_sitter_analyzer.graph.edge_store import EDGE_STORE_SCHEMA + + conn.executescript(EDGE_STORE_SCHEMA) + params = [] + for ( + caller_name, + caller_file, + caller_line, + callee_name, + _callee_full, + callee_file, + ) in rows: + source = symbol_node(caller_file, caller_name, caller_line) + target = symbol_node(callee_file or caller_file, callee_name, 0) + metadata = { + "language": "python", + "caller_name": caller_name, + "caller_line": caller_line, + "callee_name": callee_name, + "callee_full": _callee_full, + "callee_resolution": "project" if callee_file else "unknown", + "callee_resolved_file": callee_file, + } + # B1.3: resolution scalars are real columns the evaluator reads + # directly (no json_extract), so populate them alongside metadata. + params.append( + ( + source, + target, + EdgeKind.CALLS.value, + 0, + "tree-sitter", + _json.dumps(metadata, ensure_ascii=False, sort_keys=True), + caller_name, + callee_name, + caller_file, + caller_line, + _callee_full, + 0, + "python", + "project" if callee_file else "unknown", + callee_file, + ) + ) + conn.executemany( + "INSERT OR REPLACE INTO edges " + "(source_node_id, target_node_id, kind, line, provenance, metadata, " + " caller_name, callee_name, file_path, caller_line, callee_full, " + " callee_line, language, callee_resolution, callee_resolved_file) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params, + ) + conn.commit() + finally: + conn.close() + + +def _populate_ast_imports( + db_path: Path, + rows: list[tuple[str, str]], +) -> None: + """Insert rows into ``ast_imports`` in the given DB. + + Each tuple is ``(file_path, module_path)``. The table is created if + absent so this helper can be called on a DB built by + ``_build_call_edges_db`` (which only creates the ``edges`` schema). + """ + conn = sqlite3.connect(str(db_path)) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ast_imports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'python', + module_path TEXT NOT NULL, + local_name TEXT NOT NULL DEFAULT '', + is_relative INTEGER NOT NULL DEFAULT 0, + is_star INTEGER NOT NULL DEFAULT 0, + alias_of TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + conn.executemany( + "INSERT INTO ast_imports (file_path, module_path) VALUES (?, ?)", + rows, + ) + conn.commit() + finally: + conn.close() + + +class TestEvaluator: + """Synthesize an ast_call_edges row and verify the evaluator's verdict.""" + + def test_violation_detected_mcp_to_cli(self, tmp_path: Path) -> None: + """A real edge that crosses a forbidden boundary → 1 error violation.""" + from tree_sitter_analyzer.constraints import ( + evaluate, + load_constraints, + ) + + # Stage constraints + db with one offending edge. + project = _stage_constraints_file(tmp_path, "dogfood_minimal.yml") + db_path = project / ".ast-cache" / "index.db" + _build_call_edges_db( + db_path, + rows=[ + ( + "do_thing", # caller_name + "tree_sitter_analyzer/mcp/x.py", # caller_file + 42, # caller_line + "cli_helper", # callee_name + "cli_helper", # callee_full + "tree_sitter_analyzer/cli/y.py", # callee_file + ), + ], + ) + + constraints = load_constraints(str(project)) + conn = sqlite3.connect(str(db_path)) + try: + violations = evaluate(constraints, conn) + finally: + conn.close() + + # Exactly one violation, with the right severity and source. + assert len(violations) == 1, ( + f"Expected exactly one violation, got {len(violations)}: {violations}" + ) + v = violations[0] + assert v.severity == "error" + assert v.rule_id == "dogfood-mcp-no-cli" + assert v.caller_file == "tree_sitter_analyzer/mcp/x.py" + assert v.callee_file == "tree_sitter_analyzer/cli/y.py" + assert v.caller_line == 42 + + def test_exception_suppresses_violation(self, tmp_path: Path) -> None: + """An edge whose caller is in ``exceptions:`` produces zero violations. + + The exception list is the only way a rule can be locally overridden + without disabling the whole rule, so this test pins down that the + match is exact (not a substring). + """ + from tree_sitter_analyzer.constraints import ( + evaluate, + load_constraints, + ) + + project = _stage_constraints_file(tmp_path, "exception_rule.yml") + db_path = project / ".ast-cache" / "index.db" + _build_call_edges_db( + db_path, + rows=[ + ( + "use_cli", + "mcp/bridge.py", # caller is explicitly excepted + 10, + "run_cli", + "run_cli", + "cli/runner.py", + ), + ], + ) + + constraints = load_constraints(str(project)) + conn = sqlite3.connect(str(db_path)) + try: + violations = evaluate(constraints, conn) + finally: + conn.close() + + assert violations == [], ( + f"Excepted caller must produce zero violations, got: {violations}" + ) + + def test_evaluate_keeps_rows_when_from_glob_has_no_literal_prefix( + self, tmp_path: Path + ) -> None: + """A leading wildcard disables SQL prefix filtering without data loss.""" + from tree_sitter_analyzer.constraints import evaluate + from tree_sitter_analyzer.constraints.schema import Constraint + + db_path = tmp_path / "index.db" + _build_call_edges_db( + db_path, + rows=[ + ( + "use_cli", + "custom/bridge.py", + 7, + "run_cli", + "run_cli", + "cli/runner.py", + ), + ], + ) + constraint = Constraint( + id="wildcard-caller", + severity="error", + rule="forbid", + from_glob="**", + to_glob="cli/**", + reason="test wildcard fallback", + ) + + conn = sqlite3.connect(str(db_path)) + try: + violations = evaluate([constraint], conn) + finally: + conn.close() + + assert len(violations) == 1 + assert violations[0].rule_id == "wildcard-caller" + + def test_select_query_keeps_callee_filter_when_callers_exceed_limit( + self, + ) -> None: + """PR #1225: an oversized caller set must not discard the callee filter.""" + from tree_sitter_analyzer.constraints.evaluator import ( + _MAX_SQL_PREFIX_FILTERS, + _build_select_query, + ) + from tree_sitter_analyzer.constraints.parser import compile_constraints + from tree_sitter_analyzer.constraints.schema import Constraint + + constraints = [ + Constraint( + id=f"rule-{index}", + severity="error", + rule="forbid", + from_glob=f"package-{index}/**", + to_glob="forbidden/**", + reason="test SQL filter bound", + ) + for index in range(_MAX_SQL_PREFIX_FILTERS + 1) + ] + + conn = sqlite3.connect(":memory:") + try: + select_sql, params = _build_select_query( + conn, + compile_constraints(constraints), + ) + finally: + conn.close() + + assert select_sql.count("instr(file_path, ?) = 1") == 0 + assert select_sql.count("callee_resolved_file") == 4 + assert params == ("forbidden/",) + + def test_select_query_keeps_caller_filter_when_callees_exceed_limit( + self, + ) -> None: + """PR #1225: an oversized callee set must not discard the caller filter.""" + from tree_sitter_analyzer.constraints.evaluator import ( + _MAX_SQL_PREFIX_FILTERS, + _build_select_query, + ) + from tree_sitter_analyzer.constraints.parser import compile_constraints + from tree_sitter_analyzer.constraints.schema import Constraint + + constraints = [ + Constraint( + id=f"rule-{index}", + severity="error", + rule="forbid", + from_glob="tree_sitter_analyzer/mcp/**", + to_glob=f"forbidden-{index}/**", + reason="test independent SQL filter bound", + ) + for index in range(_MAX_SQL_PREFIX_FILTERS + 1) + ] + + conn = sqlite3.connect(":memory:") + try: + select_sql, params = _build_select_query( + conn, + compile_constraints(constraints), + ) + finally: + conn.close() + + assert select_sql.count("instr(file_path, ?) = 1") == 1 + assert select_sql.count("callee_resolved_file") == 2 + assert params == ("tree_sitter_analyzer/mcp/",) + + def test_select_query_falls_back_when_both_prefix_sets_exceed_limit( + self, + ) -> None: + """PR #1225: two oversized prefix sets retain the unfiltered fallback.""" + from tree_sitter_analyzer.constraints.evaluator import ( + _MAX_SQL_PREFIX_FILTERS, + _build_select_query, + ) + from tree_sitter_analyzer.constraints.parser import compile_constraints + from tree_sitter_analyzer.constraints.schema import Constraint + + constraints = [ + Constraint( + id=f"rule-{index}", + severity="error", + rule="forbid", + from_glob=f"package-{index}/**", + to_glob=f"forbidden-{index}/**", + reason="test SQL filter fallback", + ) + for index in range(_MAX_SQL_PREFIX_FILTERS + 1) + ] + + conn = sqlite3.connect(":memory:") + try: + select_sql, params = _build_select_query( + conn, + compile_constraints(constraints), + ) + finally: + conn.close() + + assert select_sql.endswith("FROM edges WHERE kind = 'calls'") + assert params == () diff --git a/tests/unit/test_git_subprocess.py b/tests/unit/test_git_subprocess.py index 9db9f99a8..779fc9ba5 100644 --- a/tests/unit/test_git_subprocess.py +++ b/tests/unit/test_git_subprocess.py @@ -9,7 +9,6 @@ import pytest import tree_sitter_analyzer.diff_snapshot_epoch as epoch_module -import tree_sitter_analyzer.diff_snapshot_registry as snapshots import tree_sitter_analyzer.frozen_git_index as frozen_index import tree_sitter_analyzer.git_subprocess as bounded import tree_sitter_analyzer.source_oracle_git as oracle @@ -226,9 +225,14 @@ def test_snapshot_disables_external_fsmonitor_hook(tmp_path: Path) -> None: _git(root, "config", "core.fsmonitor", str(hook)) (root / "old.py").write_text("value = 2\n") - result = snapshots.DiffSnapshotRegistry().create(str(root), "diff", []) + status = oracle.git_output( + str(root), + ["status", "--porcelain"], + deadline=time.monotonic() + 5.0, + limit=4096, + ) - assert result["success"] is True + assert status == b" M old.py\n?? hostile-fsmonitor\n" assert marker.exists() is False diff --git a/tests/unit/test_incremental_sync.py b/tests/unit/test_incremental_sync.py index 12bf40f11..961d8791f 100644 --- a/tests/unit/test_incremental_sync.py +++ b/tests/unit/test_incremental_sync.py @@ -1433,13 +1433,12 @@ def test_custom_db_deletion_does_not_mutate_project_mirror(tmp_path, monkeypatch result = IncrementalSync(cache).sync(max_files=10, candidate_snapshot=snapshot) finally: cache.close() - expected_authority = (0, "complete") if os.name == "posix" else (1, "incomplete") assert ( result.deleted_files, result.errors, result.to_dict()["completeness"], mirror.read_text(encoding="utf-8"), - ) == (1, *expected_authority, "other owner") + ) == (1, 0, "complete", "other owner") def test_preexisting_snapshot_mutation_removes_ladybug_mirror(tmp_path): diff --git a/tests/unit/test_index_snapshot_manifest.py b/tests/unit/test_index_snapshot_manifest.py index a6a0b1fcd..a8cc3958b 100644 --- a/tests/unit/test_index_snapshot_manifest.py +++ b/tests/unit/test_index_snapshot_manifest.py @@ -363,3 +363,92 @@ def expire_in_backup_progress(): "unknown", "INDEX_SNAPSHOT_DEADLINE", ) + + +def test_manifest_empty_authority_returns_none() -> None: + import tree_sitter_analyzer.index_snapshot as snapshot + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + "CREATE TABLE ast_index_snapshot_manifest(" + "singleton, canonical_root, source_fingerprint, index_fingerprint, " + "file_count, source_scope_descriptor, manifest_version)" + ) + try: + result = snapshot._read_bounded_manifest(conn, float("inf")) + finally: + conn.close() + + assert result is None + + +def test_manifest_boundary_delegates_connection_and_deadline(monkeypatch) -> None: + from tree_sitter_analyzer import index_snapshot, index_snapshot_manifest + + connection = object() + expected = object() + observed = [] + + def read_bounded_manifest(received_connection, received_deadline): + observed.append((received_connection, received_deadline)) + return expected + + monkeypatch.setattr(index_snapshot, "_read_bounded_manifest", read_bounded_manifest) + + result = index_snapshot_manifest._read_bounded_manifest( # type: ignore[arg-type] + connection, 7.5 + ) + + assert result is expected + assert observed == [(connection, 7.5)] + + +def test_manifest_writer_uses_portable_source_certifier(tmp_path, monkeypatch) -> None: + # PR #1254 review 3769193895: Windows-built indexes must stamp authority. + from types import SimpleNamespace + + import tree_sitter_analyzer.index_snapshot_schema as schema + + class PortableOS: + name = "nt" + path = schema.os.path + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.executescript( + schema.SCHEMA_V13_INDEX_SNAPSHOT + + "CREATE TABLE ast_index(file_path TEXT, content_hash TEXT, language TEXT);" + ) + expected_rows = frozenset({("sample.py", "hash", "python")}) + conn.execute("INSERT INTO ast_index VALUES ('sample.py', 'hash', 'python')") + monkeypatch.setattr(schema, "os", PortableOS()) + monkeypatch.setattr(schema, "strict_call_graph_marker", lambda _conn: True) + monkeypatch.setattr( + schema, "index_fingerprint", lambda *_args: "sha256:" + "a" * 64 + ) + monkeypatch.setattr( + schema, "source_fingerprint", lambda *_args: "sha256:" + "b" * 64 + ) + monkeypatch.setattr(schema, "recorded_source_rows", lambda _conn: expected_rows) + observed: list[tuple[str, object]] = [] + import tree_sitter_analyzer.portable_source_snapshot as portable + + monkeypatch.setattr( + portable, + "capture_portable_source_snapshot", + lambda root, scope, *, deadline: ( + observed.append((root, scope)) + or SimpleNamespace(state="exact", rows=expected_rows) + ), + ) + + schema.stamp_full_index_manifest(conn, str(tmp_path)) + + row = conn.execute( + "SELECT canonical_root, file_count, manifest_version " + "FROM ast_index_snapshot_manifest" + ).fetchone() + assert tuple(row) == (schema.os.path.realpath(str(tmp_path)), 1, 2) + assert len(observed) == 1 + conn.close() diff --git a/tests/unit/test_index_snapshot_registry_capabilities.py b/tests/unit/test_index_snapshot_registry_capabilities.py new file mode 100644 index 000000000..e9f908dbb --- /dev/null +++ b/tests/unit/test_index_snapshot_registry_capabilities.py @@ -0,0 +1,294 @@ +"""Reusable capability and deadline coverage for the index snapshot registry.""" + +from __future__ import annotations + +import os +import sqlite3 + +import pytest + + +@pytest.fixture(autouse=True) +def _close_registry(): + yield + from tree_sitter_analyzer.index_snapshot import REGISTRY + + REGISTRY.close_all() + + +def _snapshot(root): + from tree_sitter_analyzer.index_snapshot import IndexSnapshot + + return IndexSnapshot( + None, + "source", + "index", + "generation", + "complete", + None, + str(root.resolve()), + 0, + ) + + +def test_registry_publish_preserves_source_scope(tmp_path): + # PR #1254 review 3765918784: reusable snapshots need their scan scope. + from dataclasses import replace + + import tree_sitter_analyzer.index_snapshot as owner + from tree_sitter_analyzer.index_source_scope import ( + make_source_scope_descriptor, + ) + + scope = make_source_scope_descriptor(roots=("src",)) + candidate = replace(_snapshot(tmp_path), source_scope=scope) + published = owner.REGISTRY.publish(candidate, sqlite3.connect(":memory:"), 0) + + assert published.source_scope == scope + + +def test_registry_retires_logical_match_when_source_scope_changes(tmp_path): + # Final gate: a capability must retain the exact certified scope descriptor. + from dataclasses import replace + + import tree_sitter_analyzer.index_snapshot as owner + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + + first = replace( + _snapshot(tmp_path), + source_scope=make_source_scope_descriptor(roots=("src",)), + ) + published = owner.REGISTRY.publish(first, sqlite3.connect(":memory:"), 0) + second = replace(first, source_scope=make_source_scope_descriptor(roots=("lib",))) + replacement = owner.REGISTRY.publish(second, sqlite3.connect(":memory:"), 0) + + assert replacement.snapshot_id != published.snapshot_id + assert tuple(owner.REGISTRY._entries) == ( + published.snapshot_id, + replacement.snapshot_id, + ) + + +class TestReusableSnapshotLease: + def test_registry_reusable_pin_is_held_only_inside_context(self, tmp_path): + import tree_sitter_analyzer.index_snapshot as owner + + published = owner.REGISTRY.publish( + _snapshot(tmp_path), + sqlite3.connect(":memory:"), + 0, + ) + + with owner.REGISTRY.pin_reusable(str(tmp_path)) as snapshot: + assert snapshot.snapshot_id == published.snapshot_id + assert owner.REGISTRY._entries[published.snapshot_id].readers == 1 + + assert owner.REGISTRY._entries[published.snapshot_id].readers == 0 + owner.REGISTRY.close_all() + + def test_registry_reusable_pin_returns_none_without_capability(self, tmp_path): + import tree_sitter_analyzer.index_snapshot as owner + + with owner.REGISTRY.pin_reusable(str(tmp_path)) as snapshot: + assert snapshot is None + + def test_reusable_lease_rejects_capability_without_source_scope(self, tmp_path): + import tree_sitter_analyzer.index_snapshot as owner + + owner.REGISTRY.publish( + _snapshot(tmp_path), + sqlite3.connect(":memory:"), + 0, + ) + + with owner.lease_reusable_snapshot(str(tmp_path)) as snapshot: + assert snapshot is None + owner.REGISTRY.close_all() + + @pytest.mark.parametrize( + ("current_state", "current_generation", "is_reused"), + [ + ("unknown", "generation", False), + ("exact", "different", False), + ("exact", "generation", True), + ], + ) + def test_reusable_lease_requires_exact_current_generation( + self, tmp_path, monkeypatch, current_state, current_generation, is_reused + ): + from dataclasses import replace + from types import SimpleNamespace + + import tree_sitter_analyzer.index_snapshot as owner + from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + + candidate = replace( + _snapshot(tmp_path), + source_scope=make_source_scope_descriptor(roots=("src",)), + ) + published = owner.REGISTRY.publish(candidate, sqlite3.connect(":memory:"), 0) + monkeypatch.setattr( + owner, + "capture_current_source_snapshot", + lambda *_args, **_kwargs: SimpleNamespace( + state=current_state, generation=current_generation + ), + ) + + with owner.lease_reusable_snapshot(str(tmp_path)) as snapshot: + assert (snapshot is not None) is is_reused + assert owner.REGISTRY._entries[published.snapshot_id].readers == 1 + + assert owner.REGISTRY._entries[published.snapshot_id].readers == 0 + owner.REGISTRY.close_all() + + +def test_registry_acquire_deadline_fails_before_io_lock_wait(monkeypatch) -> None: + """PR #1254 final audit P1: acquisition must not wait beyond one deadline.""" + import tree_sitter_analyzer.index_snapshot as owner + + owner.REGISTRY.close_all() + snapshot = owner.IndexSnapshot( + None, + "source", + "index", + "generation", + "complete", + None, + os.path.realpath("/project"), + 1, + ) + connection = sqlite3.connect(":memory:", check_same_thread=False) + published = owner.REGISTRY.publish(snapshot, connection, 1, 50.0) + entry = owner.REGISTRY._entries[str(published.snapshot_id)] + + class RefusingLock: + def __init__(self) -> None: + self.timeouts: list[float] = [] + + def acquire(self, *, timeout: float) -> bool: + self.timeouts.append(timeout) + return False + + def release(self) -> None: + pytest.fail("unacquired lock was released") + + lock = RefusingLock() + entry.io_lock = lock + monkeypatch.setattr(owner.REGISTRY, "_clock", lambda: 7.0) + with pytest.raises(RuntimeError, match="^INDEX_SNAPSHOT_DEADLINE$"): + with owner.REGISTRY.acquire( + str(published.snapshot_id), "/project", deadline=9.5 + ): + pytest.fail("timed-out acquisition yielded") + assert lock.timeouts == [2.5] + assert entry.readers == 0 + owner.REGISTRY.close_all() + + +def test_registry_acquire_rejects_already_expired_deadline(monkeypatch) -> None: + import tree_sitter_analyzer.index_snapshot as owner + + owner.REGISTRY.close_all() + snapshot = owner.IndexSnapshot( + None, + "source", + "index", + "generation", + "complete", + None, + os.path.realpath("/project"), + 1, + ) + published = owner.REGISTRY.publish( + snapshot, sqlite3.connect(":memory:", check_same_thread=False), 1, 50.0 + ) + entry = owner.REGISTRY._entries[str(published.snapshot_id)] + monkeypatch.setattr(owner.REGISTRY, "_clock", lambda: 10.0) + + try: + with pytest.raises(RuntimeError, match="^INDEX_SNAPSHOT_DEADLINE$"): + with owner.REGISTRY.acquire( + str(published.snapshot_id), "/project", deadline=10.0 + ): + pytest.fail("expired acquisition yielded") + assert entry.readers == 0 + finally: + owner.REGISTRY.close_all() + + +def test_registry_acquire_rechecks_deadline_after_io_lock(monkeypatch) -> None: + import tree_sitter_analyzer.index_snapshot as owner + + owner.REGISTRY.close_all() + snapshot = owner.IndexSnapshot( + None, + "source", + "index", + "generation", + "complete", + None, + os.path.realpath("/project"), + 1, + ) + published = owner.REGISTRY.publish( + snapshot, sqlite3.connect(":memory:", check_same_thread=False), 1, 50.0 + ) + entry = owner.REGISTRY._entries[str(published.snapshot_id)] + + class DeadlineCrossingLock: + def __init__(self) -> None: + self.events: list[tuple[str, float] | tuple[str]] = [] + + def acquire(self, *, timeout: float) -> bool: + self.events.append(("acquire", timeout)) + return True + + def release(self) -> None: + self.events.append(("release",)) + + lock = DeadlineCrossingLock() + entry.io_lock = lock + clock_values = iter((1.0, 2.0, 3.0, 4.0)) + monkeypatch.setattr(owner.REGISTRY, "_clock", lambda: next(clock_values)) + + try: + with pytest.raises(RuntimeError, match="^INDEX_SNAPSHOT_DEADLINE$"): + with owner.REGISTRY.acquire( + str(published.snapshot_id), "/project", deadline=3.0 + ): + pytest.fail("deadline-crossing acquisition yielded") + assert lock.events == [("acquire", 1.0), ("release",)] + assert entry.readers == 0 + finally: + owner.REGISTRY.close_all() + + +def test_registry_acquire_yields_when_io_lock_precedes_deadline(monkeypatch) -> None: + import tree_sitter_analyzer.index_snapshot as owner + + owner.REGISTRY.close_all() + snapshot = owner.IndexSnapshot( + None, + "source", + "index", + "generation", + "complete", + None, + os.path.realpath("/project"), + 1, + ) + published = owner.REGISTRY.publish( + snapshot, sqlite3.connect(":memory:", check_same_thread=False), 1, 50.0 + ) + entry = owner.REGISTRY._entries[str(published.snapshot_id)] + monkeypatch.setattr(owner.REGISTRY, "_clock", lambda: 1.0) + + try: + with owner.REGISTRY.acquire( + str(published.snapshot_id), "/project", deadline=3.0 + ) as acquired: + assert acquired == (published, entry.connection) + assert entry.readers == 0 + finally: + owner.REGISTRY.close_all() diff --git a/tests/unit/test_index_status_certification.py b/tests/unit/test_index_status_certification.py index 4fc628d82..d13c720e2 100644 --- a/tests/unit/test_index_status_certification.py +++ b/tests/unit/test_index_status_certification.py @@ -135,8 +135,10 @@ def test_indexer_stamp_failure_only_records_warning(self, tmp_path, monkeypatch) [(1, 0, 0), (2, 0, 0)], ) - def test_portable_full_index_succeeds_without_manifest(self, tmp_path, monkeypatch): + def test_portable_full_index_stamps_manifest(self, tmp_path, monkeypatch): + # PR #1254 review 3769193895: the normal producer publishes on Windows. import tree_sitter_analyzer.cache.indexer as indexer + import tree_sitter_analyzer.index_snapshot_schema as schema from tree_sitter_analyzer.ast_cache import ASTCache real_os = indexer.os @@ -148,52 +150,49 @@ class PortableOS: def __getattr__(self, name): return getattr(real_os, name) + portable_os = PortableOS() + monkeypatch.setattr(indexer, "os", portable_os) + monkeypatch.setattr(schema, "os", portable_os) source = tmp_path / "sample.py" source.write_text("value = 1\n") - monkeypatch.setattr(indexer, "os", PortableOS()) cache = ASTCache(str(tmp_path)) try: - result = cache.index_project(workers=0) - count = int( + result = cache.index_project(workers=0, force=True) + row = ( cache.get_conn() - .execute("SELECT COUNT(*) FROM ast_index_snapshot_manifest") - .fetchone()[0] + .execute( + "SELECT file_count, manifest_version " + "FROM ast_index_snapshot_manifest" + ) + .fetchone() ) finally: cache.close() - assert result["errors"] == 0 - assert result["manifest_warning"] == "SOURCE_SCOPE_UNSUPPORTED" - assert count == 0 + assert (result["errors"], result.get("manifest_warning")) == (0, None) + assert tuple(row) == (1, 2) - def test_manifest_stamp_rejects_unsupported_source_scope( + def test_manifest_stamp_portable_path_reaches_call_graph_gate( self, tmp_path, monkeypatch ): + # PR #1254 review 3769193895: pathname platforms are producer-capable. import tree_sitter_analyzer.index_snapshot_schema as schema from tree_sitter_analyzer.ast_cache import ASTCache + class PortableOS: + name = "nt" + path = schema.os.path + + monkeypatch.setattr(schema, "os", PortableOS()) cache = ASTCache(str(tmp_path)) try: - - class UnsupportedOS: - name = "nt" - path = schema.os.path - - monkeypatch.setattr(schema, "os", UnsupportedOS()) with pytest.raises( - sqlite3.OperationalError, match="^SOURCE_SCOPE_UNSUPPORTED$" + sqlite3.OperationalError, match="^CALL_GRAPH_INCOMPLETE$" ): schema.stamp_full_index_manifest(cache.get_conn(), str(tmp_path)) - count = int( - cache.get_conn() - .execute("SELECT COUNT(*) FROM ast_index_snapshot_manifest") - .fetchone()[0] - ) finally: cache.close() - assert count == 0 - def test_manifest_stamp_rejects_missing_call_graph_marker(self, tmp_path): import tree_sitter_analyzer.index_snapshot_schema as schema from tree_sitter_analyzer.ast_cache import ASTCache diff --git a/tests/unit/test_portable_source_snapshot.py b/tests/unit/test_portable_source_snapshot.py new file mode 100644 index 000000000..c2c2e6a14 --- /dev/null +++ b/tests/unit/test_portable_source_snapshot.py @@ -0,0 +1,463 @@ +"""Exact behavior tests for portable source snapshot certification.""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +import tree_sitter_analyzer.portable_source_snapshot as portable +from tree_sitter_analyzer.index_source_scope import make_source_scope_descriptor + + +class _ModuleProxy: + def __init__(self, module: ModuleType, **overrides: Any) -> None: + self._module = module + self._overrides = overrides + + def __getattr__(self, name: str) -> Any: + if name in self._overrides: + return self._overrides[name] + return getattr(self._module, name) + + +def _changed_identity(info: os.stat_result) -> os.stat_result: + values, attributes = info.__reduce__()[1] + changed = list(values) + changed[stat.ST_SIZE] = info.st_size + 1 + return os.stat_result(changed, attributes) + + +def _inventory(tmp_path: Path, *, roots: tuple[str, ...] = (".",)): + scope = make_source_scope_descriptor(roots=roots) + return portable._portable_inventory(str(tmp_path), scope, float("inf")) + + +def test_scope_root_rejects_absolute_path(tmp_path: Path) -> None: + with pytest.raises(OSError, match="^source root escapes project$"): + portable._scope_root(tmp_path, "/src") + + +def test_scope_root_normalizes_backslash_components(tmp_path: Path) -> None: + result = portable._scope_root(tmp_path, r"pkg\nested") + + assert result == tmp_path / "pkg" / "nested" + + +def test_portable_inventory_rejects_non_directory_project_root(tmp_path: Path) -> None: + project = tmp_path / "project.py" + project.write_text("value = 1\n") + + rows, unsafe = _inventory(project) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_rejects_reparse_project_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(portable, "_is_reparse", lambda _info: True) + + rows, unsafe = _inventory(tmp_path) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_rejects_missing_scope_root(tmp_path: Path) -> None: + rows, unsafe = _inventory(tmp_path, roots=("missing",)) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_rejects_non_directory_scope_root(tmp_path: Path) -> None: + (tmp_path / "scope").write_text("not a directory") + + rows, unsafe = _inventory(tmp_path, roots=("scope",)) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_enforces_deadline(tmp_path: Path) -> None: + (tmp_path / "sample.py").write_text("value = 1\n") + scope = make_source_scope_descriptor() + + rows, unsafe = portable._portable_inventory(str(tmp_path), scope, -1.0) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_counts_unsupported_entries_against_budget( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "notes.txt").write_text("ignored") + monkeypatch.setattr(portable, "_SOURCE_ENTRY_BUDGET", 0) + + with pytest.raises(OverflowError): + _inventory(tmp_path) + + +def test_portable_inventory_skips_hidden_and_excluded_directories( + tmp_path: Path, +) -> None: + hidden = tmp_path / ".hidden" + hidden.mkdir() + (hidden / "hidden.py").write_text("hidden = True\n") + excluded = tmp_path / "node_modules" + excluded.mkdir() + (excluded / "dependency.py").write_text("dependency = True\n") + source = tmp_path / "pkg" + source.mkdir() + (source / "kept.py").write_text("kept = True\n") + + rows, unsafe = _inventory(tmp_path) + + assert ({row[0] for row in rows}, unsafe) == ({"pkg/kept.py"}, False) + + +def test_portable_inventory_skips_reparse_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + linked = tmp_path / "linked" + linked.mkdir() + (linked / "inside.py").write_text("inside = True\n") + linked_identity = (linked.stat().st_dev, linked.stat().st_ino) + real_is_reparse = portable._is_reparse + + def classify(info: os.stat_result) -> bool: + if (info.st_dev, info.st_ino) == linked_identity: + return True + return real_is_reparse(info) + + monkeypatch.setattr(portable, "_is_reparse", classify) + + rows, unsafe = _inventory(tmp_path) + + assert (rows, unsafe) == (frozenset(), False) + + +def test_portable_inventory_marks_supported_reparse_leaf_unsafe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "sample.py" + source.write_text("value = 1\n") + source_identity = (source.stat().st_dev, source.stat().st_ino) + real_is_reparse = portable._is_reparse + + def classify(info: os.stat_result) -> bool: + if (info.st_dev, info.st_ino) == source_identity: + return True + return real_is_reparse(info) + + monkeypatch.setattr(portable, "_is_reparse", classify) + + rows, unsafe = _inventory(tmp_path) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_ignores_unsupported_reparse_leaf( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + notes = tmp_path / "notes.txt" + notes.write_text("ignored") + notes_identity = (notes.stat().st_dev, notes.stat().st_ino) + real_is_reparse = portable._is_reparse + + def classify(info: os.stat_result) -> bool: + if (info.st_dev, info.st_ino) == notes_identity: + return True + return real_is_reparse(info) + + monkeypatch.setattr(portable, "_is_reparse", classify) + + rows, unsafe = _inventory(tmp_path) + + assert (rows, unsafe) == (frozenset(), False) + + +def test_portable_inventory_omits_effectively_excluded_source(tmp_path: Path) -> None: + source = tmp_path / "generated.py" + source.write_text("generated = True\n") + scope = make_source_scope_descriptor(exclude_patterns=("generated.py",)) + + rows, unsafe = portable._portable_inventory(str(tmp_path), scope, float("inf")) + + assert (rows, unsafe) == (frozenset(), False) + + +def test_portable_inventory_marks_supported_nonregular_leaf_unsafe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "sample.py" + source.write_text("value = 1\n") + source_identity = (source.stat().st_dev, source.stat().st_ino) + real_isreg = stat.S_ISREG + + def is_regular(mode: int) -> bool: + info = os.lstat(source) + if mode == info.st_mode and (info.st_dev, info.st_ino) == source_identity: + return False + return real_isreg(mode) + + monkeypatch.setattr( + portable, + "stat", + _ModuleProxy(stat, S_ISREG=is_regular), + ) + + rows, unsafe = _inventory(tmp_path) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_enforces_supported_file_capacity( + tmp_path: Path, +) -> None: + (tmp_path / "sample.py").write_text("value = 1\n") + scope = portable.SourceScopeDescriptor((".",), False, (), 0) + + with pytest.raises(OverflowError): + portable._portable_inventory(str(tmp_path), scope, float("inf")) + + +def test_portable_inventory_marks_unclean_source_hash_unsafe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "sample.py").write_text("value = 1\n") + observed: list[tuple[object, ...]] = [] + + def unclean_hash(*args: object): + observed.append(args) + return "marker", "", False + + monkeypatch.setattr(portable, "hash_source_at", unclean_hash) + + rows, unsafe = _inventory(tmp_path) + + assert (rows, unsafe) == ( + frozenset({("sample.py", "", "python")}), + True, + ) + assert len(observed) == 1 + assert observed[0][0] is None + assert observed[0][1] == str(tmp_path / "sample.py") + assert observed[0][5] == portable._SOURCE_BYTE_BUDGET + assert observed[0][6:] == (portable._marker, portable._same) + + +def test_portable_inventory_normalizes_scandir_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def denied(_path: os.PathLike[str] | str): + raise PermissionError("denied") + + monkeypatch.setattr(portable.os, "scandir", denied) + + rows, unsafe = _inventory(tmp_path) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_detects_directory_identity_change( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + real_lstat = os.lstat + reads = 0 + + def changing_lstat(path: os.PathLike[str] | str) -> os.stat_result: + nonlocal reads + info = real_lstat(path) + if Path(path) == tmp_path: + reads += 1 + return _changed_identity(info) if reads == 4 else info + return info + + monkeypatch.setattr(portable.os, "lstat", changing_lstat) + + rows, unsafe = _inventory(tmp_path) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_detects_scope_root_identity_change( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first = tmp_path / "first" + first.mkdir() + second = tmp_path / "second" + second.mkdir() + real_lstat = os.lstat + second_reads = 0 + + def changing_lstat(path: os.PathLike[str] | str) -> os.stat_result: + nonlocal second_reads + info = real_lstat(path) + if Path(path) == second: + second_reads += 1 + return _changed_identity(info) if second_reads == 4 else info + return info + + monkeypatch.setattr(portable.os, "lstat", changing_lstat) + + rows, unsafe = _inventory(tmp_path, roots=("first", "second")) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_retains_unsafe_state_from_earlier_scope_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first = tmp_path / "first" + first.mkdir() + second = tmp_path / "second" + second.mkdir() + real_lstat = os.lstat + first_reads = 0 + + def changing_lstat(path: os.PathLike[str] | str) -> os.stat_result: + nonlocal first_reads + info = real_lstat(path) + if Path(path) == first: + first_reads += 1 + return _changed_identity(info) if first_reads == 2 else info + return info + + monkeypatch.setattr(portable.os, "lstat", changing_lstat) + + rows, unsafe = _inventory(tmp_path, roots=("first", "second")) + + assert (rows, unsafe) == (frozenset(), True) + + +def test_portable_inventory_detects_project_identity_change( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + scope_root = tmp_path / "src" + scope_root.mkdir() + real_lstat = os.lstat + root_reads = 0 + + def changing_lstat(path: os.PathLike[str] | str) -> os.stat_result: + nonlocal root_reads + info = real_lstat(path) + if Path(path) == tmp_path: + root_reads += 1 + return _changed_identity(info) if root_reads == 2 else info + return info + + monkeypatch.setattr(portable.os, "lstat", changing_lstat) + + rows, unsafe = _inventory(tmp_path, roots=("src",)) + + assert (rows, unsafe) == (frozenset(), True) + + +@pytest.mark.parametrize( + ("failure", "reason"), + [ + (TimeoutError, "SOURCE_SCAN_DEADLINE"), + (OverflowError, "SOURCE_SCOPE_UNBOUNDED"), + (OSError, "SOURCE_SCOPE_UNREADABLE"), + ], +) +def test_capture_portable_source_snapshot_maps_inventory_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: type[Exception], + reason: str, +) -> None: + def fail(*_args: object, **_kwargs: object): + raise failure("capture failed") + + monkeypatch.setattr(portable, "_portable_inventory", fail) + + result = portable.capture_portable_source_snapshot( + str(tmp_path), make_source_scope_descriptor(), deadline=float("inf") + ) + + assert ( + result.rows, + result.fingerprint, + result.generation, + result.state, + result.reason, + ) == (frozenset(), None, None, "unknown", reason) + + +def test_capture_portable_source_snapshot_rejects_changed_inventory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first = frozenset({("first.py", "digest", "python")}) + second = frozenset({("second.py", "digest", "python")}) + inventories = iter(((first, False), (second, False))) + monkeypatch.setattr( + portable, "_portable_inventory", lambda *_args: next(inventories) + ) + + result = portable.capture_portable_source_snapshot( + str(tmp_path), make_source_scope_descriptor(), deadline=float("inf") + ) + + assert (result.rows, result.state, result.reason) == ( + first, + "unsafe", + "SOURCE_SCOPE_UNSAFE", + ) + assert result.fingerprint is not None + assert result.generation == "idxsrc-v3:" + result.fingerprint.removeprefix( + "sha256:" + ) + + +def test_portable_inventory_revalidates_queued_directory_before_scan( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # PR #1254 review 3772454780: never scan a replaced queued ancestor. + queued = tmp_path / "queued" + queued.mkdir() + real_lstat = os.lstat + real_scandir = os.scandir + queued_stats = 0 + scanned: list[Path] = [] + + def lstat(path): + nonlocal queued_stats + info = real_lstat(path) + if Path(path) == queued: + queued_stats += 1 + return info if queued_stats == 1 else _changed_identity(info) + return info + + def scandir(path): + scanned.append(Path(path)) + return real_scandir(path) + + monkeypatch.setattr(portable.os, "lstat", lstat) + monkeypatch.setattr(portable.os, "scandir", scandir) + + assert _inventory(tmp_path) == (frozenset(), True) + assert scanned == [tmp_path] + + +def test_portable_identity_includes_windows_reparse_attributes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # PR #1254 review 3772454780: Windows junction identity is authoritative. + fields = { + "st_dev": 1, + "st_ino": 2, + "st_mode": stat.S_IFDIR, + "st_size": 0, + "st_mtime_ns": 3, + "st_ctime_ns": 4, + } + before = type("Info", (), {**fields, "st_file_attributes": 0})() + after = type("Info", (), {**fields, "st_file_attributes": 0x400})() + monkeypatch.setattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400, raising=False) + + assert portable._identity(before) != portable._identity(after) + assert portable._is_reparse(after) is True diff --git a/tests/unit/test_source_epoch.py b/tests/unit/test_source_epoch.py index 679cc9795..a98af2934 100644 --- a/tests/unit/test_source_epoch.py +++ b/tests/unit/test_source_epoch.py @@ -5,6 +5,7 @@ import pytest +import tree_sitter_analyzer.diff_snapshot_registry as snapshot_registry from tests.unit._diff_snapshot_support import POSIX_SNAPSHOT_TEST, make_repo from tree_sitter_analyzer import frozen_git_settings as settings from tree_sitter_analyzer.diff_snapshot_registry import DiffSnapshotRegistry @@ -117,7 +118,9 @@ def test_ignored_directory_attributes_are_frozen_for_binary_diff( @POSIX_SNAPSHOT_TEST -def test_active_replace_ref_cannot_split_snapshot_head_evidence(tmp_path: Path) -> None: +def test_active_replace_ref_cannot_split_snapshot_head_evidence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: # PR #1252 review thread PRRT_kwDOPVL-OM6X2mXp: use the original object graph. root = make_repo(tmp_path) run = lambda *args, input_=None: subprocess.run( # noqa: E731 @@ -133,6 +136,21 @@ def test_active_replace_ref_cannot_split_snapshot_head_evidence(tmp_path: Path) run("add", "old.py") live_patch = run("diff", "--cached") result = (registry := DiffSnapshotRegistry()).create(str(root), "staged", []) + state = registry._states[str(result["diff_snapshot_id"])] + frozen_snapshot = state.snapshot + monkeypatch.setattr( + snapshot_registry, + "oracle_generation", + lambda *_args, **_kwargs: ( + frozen_snapshot.git_generation, + frozen_snapshot.root_identity, + ), + ) + monkeypatch.setattr( + snapshot_registry, + "shared_source_generation", + lambda *_args, **_kwargs: frozen_snapshot.source_generation, + ) consumer, error = registry.acquire(str(result["diff_snapshot_id"]), str(root)) assert error is None assert consumer is not None diff --git a/tree_sitter_analyzer/ast_cache.py b/tree_sitter_analyzer/ast_cache.py index 026b0cdba..553514e9a 100644 --- a/tree_sitter_analyzer/ast_cache.py +++ b/tree_sitter_analyzer/ast_cache.py @@ -117,6 +117,13 @@ def __init__(self, project_root: str, db_path: str | None = None) -> None: self.close() raise + def __del__(self) -> None: + """Release pinned cache resources when callers omit explicit cleanup.""" + try: + self.close() + except Exception: + return + def _walk_source_files(project_root: str) -> Iterator[str]: """Backward-compatible source walker re-export.""" diff --git a/tree_sitter_analyzer/ast_diff_node_budget.py b/tree_sitter_analyzer/ast_diff_node_budget.py new file mode 100644 index 000000000..53f1b0a63 --- /dev/null +++ b/tree_sitter_analyzer/ast_diff_node_budget.py @@ -0,0 +1,20 @@ +"""Bound AST diff node-body response materialization.""" + +from __future__ import annotations + +import json +from typing import Any + + +def apply_node_body_budget( + response: dict[str, Any], result: Any, byte_budget: int +) -> None: + """Replace oversized child-bearing hunks with their compact representation.""" + hunks_bytes = len(json.dumps(response.get("hunks", []))) + if hunks_bytes <= byte_budget: + return + compact_dict = result.to_dict(include_children=False, with_child_count=True) + compact_hunks_bytes = len(json.dumps(compact_dict.get("hunks", []))) + response["hunks"] = compact_dict["hunks"] + response["children_truncated"] = True + response["bytes_omitted"] = hunks_bytes - compact_hunks_bytes diff --git a/tree_sitter_analyzer/ast_diff_snapshot_consumers.py b/tree_sitter_analyzer/ast_diff_snapshot_consumers.py new file mode 100644 index 000000000..b9e09b242 --- /dev/null +++ b/tree_sitter_analyzer/ast_diff_snapshot_consumers.py @@ -0,0 +1,38 @@ +"""Validate and decode immutable files for AST diff snapshot consumers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class SnapshotSources: + old_source: str + new_source: str + + +def decode_snapshot_sources(frozen: Any) -> SnapshotSources | None: + """Return strict UTF-8 sources only for supported ordinary file records.""" + if ( + not getattr(frozen.record, "old_available", frozen.old_bytes is not None) + or not getattr(frozen.record, "new_available", frozen.new_bytes is not None) + or getattr(frozen.record, "status", None) in ("R", "C") + or getattr(frozen.record, "unsupported_kind", None) is not None + or frozen.record.binary + or any( + kind not in ("file", "missing") + for kind in ( + getattr(frozen.record, "old_kind", "file"), + getattr(frozen.record, "new_kind", "file"), + ) + ) + ): + return None + try: + return SnapshotSources( + (frozen.old_bytes or b"").decode("utf-8", "strict"), + (frozen.new_bytes or b"").decode("utf-8", "strict"), + ) + except UnicodeDecodeError: + return None diff --git a/tree_sitter_analyzer/cache/index_project_runner.py b/tree_sitter_analyzer/cache/index_project_runner.py new file mode 100644 index 000000000..ad59b7328 --- /dev/null +++ b/tree_sitter_analyzer/cache/index_project_runner.py @@ -0,0 +1,491 @@ +# mypy: disable-error-code="name-defined, no-any-return" +"""High-level project-index orchestration bound to the indexer facade globals.""" +# ruff: noqa: F821 + +from __future__ import annotations + +from typing import Any + + +def run_index_project( + cache: Any, + max_files: int = 20_000, + force: bool = False, + *, + workers: int | None = None, + resolve_only: bool = False, + include_activation: bool | None = None, + language_filter: str | None = None, + exclude_patterns: frozenset[str] | None = None, + candidate_snapshot: IndexCandidateSnapshot | None = None, + source_scope: SourceScopeDescriptor | None = None, + certify_manifest: bool = True, +) -> dict[str, Any]: + """Orchestrate a full ASTCache project index run. + + ASTCache keeps the connection/backfill helpers; this module owns the + high-level control flow so ``ast_cache.py`` stays thin. + """ + max_files = normalize_index_max_files(max_files) + activation_enabled = _project_index_activation_enabled(include_activation) + if resolve_only: + synapse = cache._run_synapse_backfill() + edge_store_refresh = cache._refresh_graph_edges_from_cache() + unresolved = cache._run_unresolved_refs_backfill() + return { + "mode_used": "resolve_only", + "resolve_only": True, + "indexed": 0, + "cached": 0, + "errors": 0, + "skipped": 0, + "incomplete_skips": 0, + "files": [], + "synapse_backfill": synapse, + "edge_store_refresh": edge_store_refresh, + "unresolved_refs_backfill": unresolved, + "activation_enabled": activation_enabled, + } + effective_exclude = ( + exclude_patterns if exclude_patterns is not None else _DEFAULT_EXCLUDE_PATTERNS + ) + owns_candidate_snapshot = False + candidate_released = False + cleanup_result: dict[str, Any] | None = None + materialized = False + if force and candidate_snapshot is None: + candidate_snapshot = build_index_candidate_snapshot( + cache.project_root, + max_files=max_files, + exclude_patterns=effective_exclude, + walk_fn=lambda root: walk_index_candidate_entries( + root, excluded_dir_names=frozenset(_EXCLUDE_DIRS) + ), + language_fn=_language_from_ext, + language_filter=language_filter, + materialize=True, + ) + owns_candidate_snapshot = True + if candidate_snapshot is not None: + validate_index_candidate_snapshot( + cache.project_root, max_files, candidate_snapshot + ) + if source_scope is None: + source_scope = make_source_scope_descriptor( + no_default_excludes=exclude_patterns is not None, + exclude_patterns=tuple(sorted(effective_exclude)) + if exclude_patterns is not None + else (), + certification_max_files=max_files, + ) + else: + source_scope = parse_source_scope_descriptor( + canonical_source_scope_descriptor(source_scope) + ) + validate_full_index_source_scope(source_scope, effective_exclude, max_files) + from ..indexing_candidate_materialization import ( + index_candidate_snapshot_is_materialized, + secure_candidate_materialization_supported, + ) + + if force: + materialized = bool( + candidate_snapshot is not None + and index_candidate_snapshot_is_materialized(candidate_snapshot) + ) + legacy_materialization = bool( + candidate_snapshot is not None + and candidate_snapshot.frozen_error == "SECURE_MATERIALIZATION_UNSUPPORTED" + and not secure_candidate_materialization_supported() + ) + snapshot_is_unsafe = bool( + candidate_snapshot is None + or candidate_snapshot.errors > 0 + or candidate_snapshot.discovery_error is not None + or candidate_snapshot.truncated_by_max_files + or not candidate_snapshot.discovery_reconciled + or (not materialized and not legacy_materialization) + ) + if snapshot_is_unsafe: + # Destructive rebuilds consume only a fully materialized frozen epoch. + changed = ( + [ + (entry, reason) + for entry in candidate_snapshot.selected_entries + if (reason := changed_since_snapshot(entry)) is not None + ] + if candidate_snapshot is not None and not materialized + else [] + ) + result = _unsafe_force_snapshot_result( + candidate_snapshot, activation_enabled, changed=changed + ) + if owns_candidate_snapshot and candidate_snapshot is not None: + from ..indexing_candidate_materialization import ( + release_index_candidate_snapshot, + ) + + release_index_candidate_snapshot(candidate_snapshot, result) + candidate_released = True + return result + + rebuild_signaled = False + root_lease_fd: int | None = None + try: + if ( + candidate_snapshot is not None + and secure_candidate_materialization_supported() + and getattr(cache, "_uses_project_mirror", True) + ): + from ..indexing_candidate_materialization import ( + index_candidate_cache_hierarchy_is_current, + open_index_candidate_snapshot_root, + ) + + root_lease_fd = open_index_candidate_snapshot_root(candidate_snapshot) + if root_lease_fd is None or not index_candidate_cache_hierarchy_is_current( + candidate_snapshot, cache, root_fd=root_lease_fd + ): + cleanup_result = _unsafe_force_snapshot_result( + candidate_snapshot, activation_enabled, changed=[] + ) + cleanup_result["mode_used"] = "full" if force else "incremental" + cleanup_result["files"] = [ + { + "file": "", + "status": "error", + "reason": "INDEX_CACHE_HIERARCHY_CHANGED", + } + ] + return cleanup_result + if force: + # #578: a full rebuild empties ast_index up front (the DELETE + # below commits), then re-populates in bounded batches over + # ~70 s. Stamp a persisted marker across that window so + # concurrent readers on other connections/processes warn + # instead of trusting the half-built table. MARK + DELETE live + # INSIDE the try so the finally clears the marker even if the + # DELETE/commit itself raises (e.g. SQLITE_FULL) — otherwise a + # failed rebuild would leave a stuck marker until TTL expiry. + conn = cache._get_conn() + # The only destructive authorization is the immutable materialized + # epoch validated above; live path replay must never gate the clear. + had_call_graph = cache.call_graph_built() + _mark_build_in_progress(conn) + rebuild_signaled = True + _clear_call_graph_built(conn) + try: + _clear_full_rebuild_rows(cache, conn) + conn.commit() + try: + _invalidate_ladybug(cache, root_lease_fd) + except Exception: + logger.debug("could not invalidate Ladybug mirror", exc_info=True) + except Exception: + conn.rollback() + if had_call_graph: + _mark_call_graph_built(conn) + raise + conn = cache._get_conn() + projection_repair = not symbol_projection_is_exact( + conn, require_fts=cache.fts5_available + ) + if projection_repair and not rebuild_signaled: + # Repair rewrites ordinary rows in committed batches just like a full + # rebuild. Publish incomplete evidence before the first batch so no + # concurrent reader can trust the old manifest/call-graph epoch. + _mark_build_in_progress(conn) + rebuild_signaled = True + _clear_call_graph_built_strict(conn) + _delete_all_rows_if_present(conn, "ast_index_snapshot_manifest") + conn.commit() + stats, candidates, count = walk_and_partition( + cache, + conn, + max_files, + force or projection_repair, + activation_enabled, + _walk_source_files, + _language_from_ext, + _AST_CACHE_EXTRACTOR_VERSION, + _make_error_entry, + language_filter, + effective_exclude, + candidate_snapshot, + ) + cleanup_result = stats + candidate_fingerprints = ( + { + entry.abs_path: cast(IndexFileFingerprint, entry.fingerprint) + for entry in candidate_snapshot.selected_entries + } + if candidate_snapshot is not None + else {} + ) + candidate_frozen_paths = ( + { + entry.abs_path: entry.frozen_path + for entry in candidate_snapshot.selected_entries + if entry.frozen_path is not None + } + if candidate_snapshot is not None + else {} + ) + candidate_frozen_identities = ( + { + entry.abs_path: entry.frozen_identity + for entry in candidate_snapshot.selected_entries + if entry.frozen_identity is not None + } + if candidate_snapshot is not None + else {} + ) + # The snapshot-wide absolute deadline protects only freeze/pre-clear + # validation. Once the destructive clear is authorized, every immutable + # worker gets its own bounded read window so a long parse/build cannot + # expire later frozen inputs before their reads begin. + frozen_read_deadline = None + workers = cache._resolve_worker_count(workers, candidates) + if workers and workers >= 2 and len(candidates) >= 2: + results = index_parallel( + cache, + candidates, + workers, + candidate_fingerprints, + candidate_frozen_paths, + candidate_frozen_identities, + frozen_read_deadline, + ) + else: + from .extraction import _worker_index_file + + results = [ + _worker_index_file( + ( + path, + cache.project_root, + language, + candidate_fingerprints.get(path), + candidate_frozen_paths.get(path), + candidate_frozen_identities.get(path), + frozen_read_deadline, + ) + ) + for path, language in candidates + ] + indexed_at = datetime.now(timezone.utc).isoformat() + from .. import ast_cache as _ast_cache_mod + + snapshot_entries = ( + {entry.rel_path: entry for entry in candidate_snapshot.selected_entries} + if candidate_snapshot is not None + else None + ) + result_guard = ( + partial( + _snapshot_result_is_stable, + entries=snapshot_entries, + stats=stats, + cache=cache, + conn=conn, + root_fd=root_lease_fd, + ) + if snapshot_entries is not None + else None + ) + batch_guard = ( + partial( + _revalidate_snapshot_batch, + cache=cache, + conn=conn, + entries=snapshot_entries, + stats=stats, + root_fd=root_lease_fd, + ) + if snapshot_entries is not None + else None + ) + _ast_cache_mod._commit_index_results( + conn, + results, + stats, + partial( + insert_index_row, + cache, + conn, + extractor_version=_AST_CACHE_EXTRACTOR_VERSION, + ), + indexed_at, + activation_enabled, + result_guard=result_guard, + batch_guard=batch_guard, + ) + if snapshot_entries is not None: + if all( + entry.frozen_path is not None for entry in snapshot_entries.values() + ): + _record_frozen_replay_mismatches(snapshot_entries, stats) + if stats.get("changed_during_run", 0) > 0: + # The frozen epoch remains internally coherent, but it no + # longer certifies the live workspace consumed by readers. + _clear_call_graph_built_strict(conn) + else: + _revalidate_committed_snapshot( + cache=cache, + conn=conn, + entries=snapshot_entries, + stats=stats, + root_fd=root_lease_fd, + ) + if projection_repair and stats["errors"] == 0: + # A partial projection cannot use the unchanged-file fast path. Every + # canonical file has now been rewritten with ordinary/FTS/activation + # rows in its writer transaction; remove only orphan derived paths. + conn.execute( + "DELETE FROM ast_symbol_rows WHERE file_path NOT IN " + "(SELECT file_path FROM ast_index)" + ) + if cache.fts5_available: + # Contentless FTS5 cannot reliably predicate-delete stale rows. + # Rebuild it from the now-exact ordinary projection instead. + conn.execute( + "INSERT INTO ast_symbols_fts(ast_symbols_fts) VALUES('delete-all')" + ) + conn.execute( + "INSERT INTO ast_symbols_fts" + "(rowid, name, kind, file_path, language) " + "SELECT id, name, kind, file_path, language " + "FROM ast_symbol_rows ORDER BY id" + ) + for table in ("ast_symbol_projection_state", "ast_symbol_activation"): + conn.execute( + f"DELETE FROM {table} WHERE file_path NOT IN " # nosec B608 + "(SELECT file_path FROM ast_index)" + ) + conn.commit() + # The operation-boundary validator detected the repair and forced + # every canonical file through the writer path. The bounded migration + # certifier publishes the projection marker only after exact payload, + # state, and FTS validation; an oversized repair remains incomplete. + from ..index_snapshot_symbols import ensure_symbol_rows_backfilled + + if not ensure_symbol_rows_backfilled( + conn, + require_fts=cache.fts5_available, + allow_incomplete=True, + ): + stats["backfill_errors"] = stats.get("backfill_errors", 0) + 1 + _clear_call_graph_built_strict(conn) + conn.execute("DELETE FROM ast_index_snapshot_manifest") + conn.commit() + frozen_epoch = bool( + candidate_snapshot is not None + and all( + entry.frozen_path is not None + for entry in candidate_snapshot.selected_entries + ) + ) + if stats["changed_during_run"] > 0 and not frozen_epoch: + _clear_call_graph_built(conn) + stats["total_files"] = count + stats["workers"] = workers + if ( + candidate_snapshot is not None + and not candidate_snapshot.truncated_by_max_files + and candidate_snapshot.errors == 0 + and ( + stats.get("changed_during_run", 0) == 0 + or all( + entry.frozen_path is not None + for entry in candidate_snapshot.selected_entries + ) + ) + ): + stats["pruned"] = _prune_to_selected_scope( + cache, conn, candidate_snapshot, root_fd=root_lease_fd + ) + run_incomplete = bool( + stats.get("incomplete_skips", 0) + or stats.get("truncated_by_max_files", False) + or stats.get("errors", 0) + or ( + candidate_snapshot is not None + and ( + candidate_snapshot.errors + or candidate_snapshot.discovery_error is not None + or candidate_snapshot.truncated_by_max_files + or not candidate_snapshot.discovery_reconciled + ) + ) + ) + if run_incomplete: + # Global certification is invalid regardless of whether this run + # happened to rewrite or prune a row. + _clear_call_graph_built_strict(conn) + conn.execute("DELETE FROM ast_index_snapshot_manifest") + conn.commit() + stats["verdict"] = "WARN" + stats["manifest_warning"] = "INDEX_RUN_INCOMPLETE" + + # A missing marker is persisted evidence that a previous backfill did + # not converge. Fully cached retries must run the complete chain again. + needs_backfill = bool( + stats["indexed"] > 0 + or stats.get("pruned", 0) > 0 + or not _call_graph_marker_is_built(conn) + ) + if needs_backfill: + _clear_call_graph_built(conn) + post_index_backfill(cache, stats, root_fd=root_lease_fd) + if stats.get("backfill_errors", 0) == 0 and _candidate_paths_are_exact( + cache, + conn, + candidate_snapshot, + stats, + max_files, + language_filter, + effective_exclude, + ): + try: + _mark_call_graph_built_strict(conn) + except sqlite3.OperationalError: + logger.warning( + "call-graph marker certification failed", exc_info=True + ) + stats["backfill_errors"] = stats.get("backfill_errors", 0) + 1 + stats["manifest_warning"] = "CALL_GRAPH_MARKER_CERTIFICATION_FAILED" + _clear_call_graph_built_strict(conn) + else: + _clear_call_graph_built_strict(conn) + if force: + stats["db_maintenance"] = ( + _ast_cache_mod._reclaim_storage_after_full_rebuild(conn, cache.db_path) + ) + if certify_manifest: + _update_authoritative_manifest( + cache, candidate_snapshot, stats, source_scope + ) + return stats + finally: + try: + if root_lease_fd is not None: + try: + os.close(root_lease_fd) + except OSError: + logger.warning("could not close project-root lease", exc_info=True) + finally: + try: + if rebuild_signaled: + _clear_build_in_progress(cache._get_conn()) + finally: + if ( + owns_candidate_snapshot + and candidate_snapshot is not None + and not candidate_released + ): + from ..indexing_candidate_materialization import ( + release_index_candidate_snapshot, + ) + + release_index_candidate_snapshot(candidate_snapshot, cleanup_result) + candidate_released = True diff --git a/tree_sitter_analyzer/cache/indexer.py b/tree_sitter_analyzer/cache/indexer.py index 99d69f719..3f6d7133a 100644 --- a/tree_sitter_analyzer/cache/indexer.py +++ b/tree_sitter_analyzer/cache/indexer.py @@ -1,9 +1,5 @@ -"""Indexing helpers for ASTCache. - -Pure functions extracted from ASTCache indexing pipeline methods to -reduce ast_cache.py line count. ASTCache keeps thin wrapper methods -that delegate here. -""" +"""Indexing helpers for ASTCache.""" +# ruff: noqa: E402, F401, I001 from __future__ import annotations @@ -15,10 +11,8 @@ from collections.abc import Iterator, Mapping from datetime import datetime, timezone from functools import partial -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast -if TYPE_CHECKING: - pass from ..constants import EXCLUDE_DIRS as _EXCLUDE_DIRS from ..index_candidate_walker import ( @@ -130,23 +124,6 @@ def _invalidate_ladybug(cache: Any, root_fd: int | None) -> bool: _warned_extensions: set[str] = set() # Extractor version constant — kept in sync with ast_cache.py. -# v3: #610 — Python module-level constants extracted as kind="constant". -# v4: #613 — Go package-level const/var specs extracted as kind="constant". -# v5: #613 — Rust const/static items extracted as kind="constant". -# v6: #614 — docstring/return_type/params serialized into symbols_json. -# v7: #624 — PHP const declarations extracted as kind="constant". -# v8: #626 — JS/TS function-local variables no longer over-captured. -# v9: #626 — Java function-local variables no longer over-captured. -# v10: #628 — C# function-local variables no longer over-captured. -# v11: #638 — call edges keep ALL same-named definition spans; calls inside -# the earlier of two same-named methods regain their enclosing caller. -# v12: #779 — walker depth cap raised 20 -> 100; bump forces re-index of files -# cached under the old cap so deeply nested symbols are no longer truncated. -# v13: #949 — bash variable_assignment indexing: skip command-prefix env vars -# (``FOO=bar make``) and unwrap subscript only for assignment targets. -# v14: #1094 / RFC-0019 — function symbols now carry the extractor's canonical -# ``complexity`` so the cache-backed heatmap matches the extractor instead -# of re-deriving the count from the per-arm ``decision_points`` sum. _AST_CACHE_EXTRACTOR_VERSION = 14 @@ -253,663 +230,31 @@ def _warn_unwired_plugin_extension(abs_path: str) -> None: _warned_extensions.add(ext) -def check_cache_or_read( - conn: sqlite3.Connection, - rel_path: str, - abs_path: str, - stat: Any, - content_hash_fn: Any, - extractor_version: int, - *, - source_code: str | None = None, -) -> dict[str, Any] | tuple[str, str]: - """Return cached-response dict or (source_code, content_hash) if stale.""" - row = conn.execute( - "SELECT content_hash, mtime_ns, file_size, extractor_version " - "FROM ast_index WHERE file_path = ?", - (rel_path,), - ).fetchone() - if row is not None and ( - row["mtime_ns"] == int(stat.st_mtime_ns) - and row["file_size"] == stat.st_size - and row["extractor_version"] >= extractor_version - ): - return {"file": rel_path, "status": "cached", "reason": "unchanged"} - if source_code is None: - try: - with open(abs_path, encoding="utf-8", errors="replace") as f: - source_code = f.read() - except OSError as e: - return {"file": rel_path, "status": "error", "reason": str(e)} - content_hash = content_hash_fn(source_code) - if ( - row is not None - and row["content_hash"] == content_hash - and row["extractor_version"] >= extractor_version - ): - conn.execute( - "UPDATE ast_index SET mtime_ns = ?, file_size = ? WHERE file_path = ?", - (int(stat.st_mtime_ns), stat.st_size, rel_path), - ) - conn.commit() - return {"file": rel_path, "status": "cached", "reason": "content unchanged"} - return source_code, content_hash - - -def parse_and_write( - cache: Any, - conn: sqlite3.Connection, - abs_path: str, - rel_path: str, - language: str, - stat: Any, - source_code: str, - content_hash: str, - extractor_version: int, - *, - source_is_frozen: bool = False, -) -> dict[str, Any]: - """Parse a file and write all cache rows. Returns result dict.""" - from .extraction import ( - _extract_call_edges, - _extract_imports, - _extract_structure, - _extract_symbols, - ) - - result = ( - cache.parser.parse_code(source_code, language, filename=abs_path) - if source_is_frozen - else cache.parser.parse_file(abs_path, language) - ) - if not result.success: - return { - "file": rel_path, - "status": "error", - "reason": result.error_message or "parse failed", - } - symbols = _extract_symbols(result.tree, source_code, language) - imports = _extract_imports(symbols) - structure = _extract_structure(symbols) - call_edges = _extract_call_edges(result.tree, source_code, language, symbols) - indexed_at = datetime.now(timezone.utc).isoformat() - conn.execute( - "INSERT OR REPLACE INTO ast_index " - "(file_path, content_hash, language, mtime_ns, file_size, " - "extractor_version, symbols_json, imports_json, structure_json, indexed_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ( - rel_path, - content_hash, - language, - int(stat.st_mtime_ns), - stat.st_size, - extractor_version, - json.dumps(symbols, ensure_ascii=False), - json.dumps(imports, ensure_ascii=False), - json.dumps(structure, ensure_ascii=False), - indexed_at, - ), - ) - from . import write as _write - - inserted = _write.write_fts5_symbols( - conn, rel_path, language, symbols, cache.fts5_available - ) - cache._write_imports_for_file(conn, rel_path, language, imports) # noqa: SLF001 - cache._write_activation_for_file(conn, rel_path, inserted) # noqa: SLF001 - # CALLS rows live in the unified ``edges`` table (B1.3 — no ast_call_edges). - # Write the edges first so synapse resolution can UPDATE them in place. - if not _write.write_graph_edges_for_file( - conn, rel_path, language, symbols, imports, call_edges - ): - conn.rollback() - return { - "file": rel_path, - "status": "error", - "reason": "graph edge write failed", - "certification_errors": 1, - } - cache._resolve_call_edges_for_file(conn, rel_path) # noqa: SLF001 - conn.commit() - return { - "file": rel_path, - "status": "indexed", - "symbols": len(symbols.get("symbols", [])), - "call_edges": len(call_edges), - "content_hash": content_hash[:16], - } - - -def walk_and_partition( - cache: Any, - conn: sqlite3.Connection, - max_files: int, - force: bool, - activation_enabled: bool, - walk_fn: Any, - language_fn: Any, - extractor_version: int, - make_error_entry: Any, - language_filter: str | None = None, - exclude_patterns: frozenset[str] | None = None, - candidate_snapshot: IndexCandidateSnapshot | None = None, -) -> tuple[dict[str, Any], list[tuple[str, str]], int]: - """Walk source files and partition into (stats, candidates, count). - - ``language_filter`` (#1018): when set, only files whose detected language - equals it are considered; non-matching files are skipped BEFORE any parse - attempt, so a Python-scoped run never tries to load an optional grammar - (e.g. Swift) and never surfaces a "grammar not installed" error. - """ - max_files = normalize_index_max_files(max_files) - candidates: list[tuple[str, str]] = [] - already_cached: list[dict[str, Any]] = [] - stats: dict[str, Any] = { - "mode_used": "full" if force else "incremental", - "indexed": 0, - "cached": 0, - "errors": 0, - "skipped": 0, - "incomplete_skips": 0, - "processed": 0, - "changed_during_run": 0, - "changed_during_run_files": [], - "files": [], - "activation_enabled": activation_enabled, - "truncated_by_max_files": False, - } - if force: - indexed_map: dict[str, tuple[Any, ...]] = {} - elif candidate_snapshot is not None: - rows = conn.execute( - "SELECT file_path, mtime_ns, file_size, extractor_version, content_hash " - "FROM ast_index" - ).fetchall() - indexed_map = { - r["file_path"]: ( - r["mtime_ns"], - r["file_size"], - r["extractor_version"], - r["content_hash"], - ) - for r in rows - } - else: - rows = conn.execute( - "SELECT file_path, mtime_ns, file_size, extractor_version FROM ast_index" - ).fetchall() - indexed_map = { - r["file_path"]: (r["mtime_ns"], r["file_size"], r["extractor_version"]) - for r in rows - } - - if candidate_snapshot is not None: - validate_index_candidate_snapshot( - cache.project_root, max_files, candidate_snapshot - ) - stats["truncated_by_max_files"] = candidate_snapshot.truncated_by_max_files - stats["snapshot_metrics"] = candidate_snapshot.metrics() - count = len(candidate_snapshot.entries) - for entry in candidate_snapshot.entries: - if entry.decision == "excluded": - stats["skipped"] += 1 - continue - if entry.decision == "skipped": - if entry.language is None: - _warn_unwired_plugin_extension(entry.abs_path) - elif language_filter is not None: - # A language-scoped run cannot certify the process-global - # call-graph marker: skipped languages and their edges are - # still part of the persisted global source inventory. - stats["incomplete_skips"] += 1 - stats["skipped"] += 1 - continue - if entry.decision == "error": - stats["errors"] += 1 - stats["files"].append( - make_error_entry(entry.rel_path, entry.reason or "stat failed") - ) - continue - - change_reason = ( - None if entry.frozen_path is not None else changed_since_snapshot(entry) - ) - if change_reason is not None: - stats["skipped"] += 1 - stats["incomplete_skips"] += 1 - stats["changed_during_run"] += 1 - stats["changed_during_run_files"].append(entry.rel_path) - stats["files"].append( - { - "file": entry.rel_path, - "status": "skipped", - "reason": change_reason, - } - ) - continue - - fingerprint = cast(IndexFileFingerprint, entry.fingerprint) - language = cast(str, entry.language) - row = indexed_map.get(entry.rel_path) - if ( - row is not None - and row[0] == fingerprint.mtime_ns - and row[1] == fingerprint.file_size - and row[2] >= extractor_version - and (not fingerprint.content_hash or row[3] == fingerprint.content_hash) - ): - already_cached.append( - { - "file": entry.rel_path, - "status": "cached", - "reason": "unchanged", - } - ) - continue - candidates.append((entry.abs_path, language)) - - stats["cached"] += len(already_cached) - stats["files"].extend(already_cached) - stats["processed"] = len(candidates) + len(already_cached) - return stats, candidates, count - - count = 0 - for abs_path in walk_fn(cache.project_root): - if count >= max_files: - stats["truncated_by_max_files"] = True - break - count += 1 - rel_path = _normalize_relative_path( - os.path.relpath(abs_path, cache.project_root) - ) - # REQ-E-016: skip files matching corpus-exclusion patterns. - if exclude_patterns: - if any(fnmatch.fnmatch(rel_path, pat) for pat in exclude_patterns): - stats["skipped"] += 1 - continue - lang = language_fn(abs_path) - if lang is None: - # REQ-E-020: emit a one-time WARNING for plugin-registered extensions - # that are not wired into the full-index path. - _warn_unwired_plugin_extension(abs_path) - stats["skipped"] += 1 - continue - if language_filter is not None and lang != language_filter: - stats["skipped"] += 1 - stats["incomplete_skips"] += 1 - continue - try: - stat = os.stat(abs_path) - except OSError as e: - stats["errors"] += 1 - stats["files"].append(make_error_entry(rel_path, str(e))) - continue - row = indexed_map.get(rel_path) - if ( - row is not None - and row[0] == int(stat.st_mtime_ns) - and row[1] == stat.st_size - and row[2] >= extractor_version - ): - already_cached.append( - {"file": rel_path, "status": "cached", "reason": "unchanged"} - ) - continue - candidates.append((abs_path, lang)) - stats["cached"] += len(already_cached) - stats["files"].extend(already_cached) - stats["processed"] = len(candidates) + len(already_cached) - return stats, candidates, count - - -def _clear_full_rebuild_rows(cache: Any, conn: sqlite3.Connection) -> None: - """Clear primary and derived index rows before a forced rebuild.""" - from .write import _clear_symbol_resolver_context - - _clear_symbol_resolver_context() - conn.execute("DELETE FROM ast_index") - if cache.fts5_available: - conn.execute( - "INSERT INTO ast_symbols_fts(ast_symbols_fts) VALUES('delete-all')" - ) - _delete_all_rows_if_present(conn, "ast_symbol_rows") - for table in ( - "ast_symbol_projection_state", - "ast_imports", - "ast_symbol_activation", - "edges", - ): - _delete_all_rows_if_present(conn, table) - - -def _delete_all_rows_if_present(conn: sqlite3.Connection, table: str) -> None: - """Delete a legacy-optional table, propagating every real database failure.""" - try: - conn.execute(f"DELETE FROM {table}") # nosec B608 - fixed table names - except sqlite3.OperationalError as exc: - if "no such table" not in str(exc).lower(): - raise - - -def insert_index_row( - cache: Any, - conn: sqlite3.Connection, - r: dict[str, Any], - indexed_at: str, - extractor_version: int, - include_activation: bool = True, -) -> None: - """Write one worker result to SQLite (main table + optional FTS5).""" - rel_path = r["rel_path"] - conn.execute( - """INSERT OR REPLACE INTO ast_index - (file_path, content_hash, language, mtime_ns, file_size, - extractor_version, symbols_json, imports_json, structure_json, - indexed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - rel_path, - r["content_hash"], - r["language"], - r["mtime_ns"], - r["file_size"], - extractor_version, - r["symbols_json"], - r["imports_json"], - r["structure_json"], - indexed_at, - ), - ) - from . import write as _write - - inserted_symbol_rows = _write.write_fts5_symbols_from_tuples( - conn, - rel_path, - r["language"], - r["symbol_rows"], - cache.fts5_available, - ) - call_edges = json.loads(r.get("call_edges_json", "[]")) - imports_list = json.loads(r.get("imports_json", "[]")) - cache._write_imports_for_file(conn, rel_path, r["language"], imports_list) # noqa: SLF001 - symbols = json.loads(r.get("symbols_json", "{}")) - # CALLS rows live in the unified ``edges`` table (B1.3 — no ast_call_edges). - # Cross-file / synapse resolution UPDATEs these rows in the post-index pass. - if not _write.write_graph_edges_for_file( - conn, rel_path, r["language"], symbols, imports_list, call_edges - ): - raise sqlite3.OperationalError("GRAPH_EDGE_WRITE_FAILED") - if include_activation: - cache._write_activation_for_file(conn, rel_path, inserted_symbol_rows) # noqa: SLF001 - else: - _clear_activation_for_file_fn(conn, rel_path) - - -def index_parallel( - cache: Any, - candidates: list[tuple[str, str]], - workers: int, - fingerprints: Mapping[str, IndexFileFingerprint] | None = None, - frozen_paths: Mapping[str, str] | None = None, - frozen_identities: Mapping[str, tuple[int, int, int]] | None = None, - frozen_deadline: float | None = None, -) -> list[dict[str, Any]]: - """Dispatch parse+extract to a spawn process pool (safe on macOS/Linux).""" - from multiprocessing import get_context - - from .extraction import _init_worker_parser, _worker_index_file - - ctx = get_context("spawn") - args_iter = [ - ( - path, - cache.project_root, - language, - fingerprints.get(path) if fingerprints is not None else None, - frozen_paths.get(path) if frozen_paths is not None else None, - frozen_identities.get(path) if frozen_identities is not None else None, - frozen_deadline, - ) - for path, language in candidates - ] - with ctx.Pool(processes=workers, initializer=_init_worker_parser) as pool: - return list(pool.imap_unordered(_worker_index_file, args_iter, chunksize=8)) - - -def _snapshot_result_change_reason( - result: dict[str, Any], - entries: dict[str, IndexSnapshotEntry], -) -> tuple[str, str | None]: - rel_path = _normalize_relative_path(str(result["rel_path"])) - entry = entries[rel_path] - if result.get("status") == "source_changed": - return rel_path, "file changed after candidate snapshot" - fingerprint = cast(IndexFileFingerprint, entry.fingerprint) - worker_fingerprint = ( - int(result.get("mtime_ns", fingerprint.mtime_ns)), - int(result.get("file_size", fingerprint.file_size)), - ) - expected_fingerprint = (fingerprint.mtime_ns, fingerprint.file_size) - return rel_path, ( - "file changed after candidate snapshot" - if worker_fingerprint != expected_fingerprint - else None - if entry.frozen_path is not None - else changed_since_snapshot(entry) - ) - - -def _record_snapshot_change( - stats: dict[str, Any], rel_path: str, change_reason: str -) -> None: - """Replace one processed result with a deterministic snapshot skip.""" - stats["skipped"] += 1 - stats["incomplete_skips"] = stats.get("incomplete_skips", 0) + 1 - stats["processed"] = max(0, int(stats["processed"]) - 1) - stats["changed_during_run"] += 1 - stats["changed_during_run_files"].append(rel_path) - stats["files"].append( - {"file": rel_path, "status": "skipped", "reason": change_reason} - ) - - -def _discard_snapshot_generation( - cache: Any, - conn: sqlite3.Connection, - rel_path: str, - *, - root_fd: int | None = None, -) -> None: - """Remove canonical rows and invalidate their derived graph projection.""" - from . import write as _write - - _write.discard_file_rows(conn, rel_path, cache.fts5_available) - try: - _invalidate_ladybug(cache, root_fd) - except Exception: - logger.debug("could not invalidate Ladybug mirror", exc_info=True) +from .indexer_io import ( + _clear_full_rebuild_rows, + _delete_all_rows_if_present, + check_cache_or_read, + index_parallel, + insert_index_row, + parse_and_write, + walk_and_partition, +) +from .indexer_snapshot import ( + _discard_snapshot_generation, + _record_frozen_replay_mismatches, + _snapshot_result_change_reason, + _revalidate_committed_snapshot, + _revalidate_snapshot_batch, + _snapshot_result_is_stable, + _unsafe_force_snapshot_result, +) def _discard_with_root_lease( - cache: Any, - conn: sqlite3.Connection, - rel_path: str, - root_fd: int | None, + cache: Any, conn: sqlite3.Connection, rel_path: str, root_fd: int | None ) -> None: - """Preserve the legacy call seam when no pinned lease is active.""" - if root_fd is None: - _discard_snapshot_generation(cache, conn, rel_path) - else: - _discard_snapshot_generation(cache, conn, rel_path, root_fd=root_fd) - - -def _snapshot_result_is_stable( - result: dict[str, Any], - entries: dict[str, IndexSnapshotEntry], - stats: dict[str, Any], - *, - cache: Any, - conn: sqlite3.Connection, - root_fd: int | None = None, -) -> bool: - """Validate one worker result immediately before its database write.""" - rel_path, change_reason = _snapshot_result_change_reason(result, entries) - if change_reason is None: - return True - - _discard_with_root_lease(cache, conn, rel_path, root_fd) - _record_snapshot_change(stats, rel_path, change_reason) - return False - - -def _revalidate_snapshot_batch( - pending_results: list[dict[str, Any]], - *, - cache: Any, - conn: sqlite3.Connection, - entries: dict[str, IndexSnapshotEntry], - stats: dict[str, Any], - root_fd: int | None = None, -) -> None: - """Discard pending generations that changed before their batch commit.""" - for result in pending_results: - rel_path, change_reason = _snapshot_result_change_reason(result, entries) - if change_reason is None: - continue - _discard_with_root_lease(cache, conn, rel_path, root_fd) - if result["status"] in ("io_error", "parse_failed"): - stats["errors"] -= 1 - else: - stats["indexed"] -= 1 - for index in range(len(stats["files"]) - 1, -1, -1): - if stats["files"][index]["file"] == rel_path: - del stats["files"][index] - break - _record_snapshot_change(stats, rel_path, change_reason) - - -def _revalidate_committed_snapshot( - *, - cache: Any, - conn: sqlite3.Connection, - entries: dict[str, IndexSnapshotEntry], - stats: dict[str, Any], - root_fd: int | None = None, -) -> None: - """Invalidate any earlier committed generation changed before backfill.""" - known_changed = set(stats["changed_during_run_files"]) - for rel_path, entry in entries.items(): - change_reason = ( - None if rel_path in known_changed else changed_since_snapshot(entry) - ) - if change_reason is None: - continue - _discard_with_root_lease(cache, conn, rel_path, root_fd) - detail_files = [detail["file"] for detail in stats["files"]] - detail_index = detail_files.index(rel_path) - detail = stats["files"].pop(detail_index) - counter = { - "error": "errors", - "indexed": "indexed", - "cached": "cached", - }[detail["status"]] - stats[counter] -= 1 - _record_snapshot_change(stats, rel_path, change_reason) - - -def _record_frozen_replay_mismatches( - entries: dict[str, IndexSnapshotEntry], stats: dict[str, Any] -) -> None: - """Report live divergence without deleting the complete frozen epoch.""" - changed = [ - (rel_path, reason) - for rel_path, entry in entries.items() - if (reason := changed_since_snapshot(entry)) is not None - ] - if not changed: - return - known = set(stats.get("changed_during_run_files", [])) - known.update(rel_path for rel_path, _reason in changed) - stats["changed_during_run_files"] = sorted(known) - stats["changed_during_run"] = len(known) - stats["live_source_replay_mismatch"] = True - stats["manifest_warning"] = "INDEX_CANDIDATE_SNAPSHOT_CHANGED" - for rel_path, reason in changed: - stats["files"].append({"file": rel_path, "status": "warning", "reason": reason}) - - -def _unsafe_force_snapshot_result( - candidate_snapshot: IndexCandidateSnapshot | None, - activation_enabled: bool, - *, - changed: list[tuple[IndexSnapshotEntry, str]] | None = None, -) -> dict[str, Any]: - """Return a terminal force result before any persistent state is touched.""" - changed = changed or [] - discovery_details = ( - [ - { - "file": entry.rel_path, - "status": "error", - "reason": entry.reason or "candidate discovery failed", - } - for entry in candidate_snapshot.entries - if entry.decision == "error" - ] - if candidate_snapshot is not None - else [] - ) - frozen_reason = ( - candidate_snapshot.frozen_error - or ( - "INDEX_CANDIDATE_FROZEN_EVIDENCE_MISSING" - if candidate_snapshot.frozen_root is None - else None - ) - if candidate_snapshot is not None - else "INDEX_CANDIDATE_FROZEN_EVIDENCE_MISSING" - ) - if frozen_reason and not changed: - discovery_details.append( - {"file": "", "status": "error", "reason": frozen_reason} - ) - changed_details = [ - {"file": entry.rel_path, "status": "error", "reason": reason} - for entry, reason in changed - ] - errors = max( - 1, - (candidate_snapshot.errors if candidate_snapshot is not None else 0) - + len(changed_details), - ) - return { - "mode_used": "full", - "verdict": "WARN", - "abort_remaining_phases": True, - "indexed": 0, - "cached": 0, - "errors": errors, - "skipped": candidate_snapshot.skipped if candidate_snapshot is not None else 0, - "incomplete_skips": ( - candidate_snapshot.skipped if candidate_snapshot is not None else 0 - ) - + len(changed_details), - "processed": 0, - "changed_during_run": len(changed_details), - "changed_during_run_files": [detail["file"] for detail in changed_details], - "files": discovery_details + changed_details, - "activation_enabled": activation_enabled, - "truncated_by_max_files": bool( - candidate_snapshot and candidate_snapshot.truncated_by_max_files - ), - "snapshot_metrics": candidate_snapshot.metrics() if candidate_snapshot else {}, - "manifest_warning": ( - "INDEX_CANDIDATE_SNAPSHOT_CHANGED" - if changed_details - else "INDEX_CANDIDATE_SNAPSHOT_INCOMPLETE" - ), - } + kwargs = {} if root_fd is None else {"root_fd": root_fd} + _discard_snapshot_generation(cache, conn, rel_path, **kwargs) def run_index_project( @@ -926,474 +271,33 @@ def run_index_project( source_scope: SourceScopeDescriptor | None = None, certify_manifest: bool = True, ) -> dict[str, Any]: - """Orchestrate a full ASTCache project index run. - - ASTCache keeps the connection/backfill helpers; this module owns the - high-level control flow so ``ast_cache.py`` stays thin. - """ - max_files = normalize_index_max_files(max_files) - activation_enabled = _project_index_activation_enabled(include_activation) - if resolve_only: - synapse = cache._run_synapse_backfill() - edge_store_refresh = cache._refresh_graph_edges_from_cache() - unresolved = cache._run_unresolved_refs_backfill() - return { - "mode_used": "resolve_only", - "resolve_only": True, - "indexed": 0, - "cached": 0, - "errors": 0, - "skipped": 0, - "incomplete_skips": 0, - "files": [], - "synapse_backfill": synapse, - "edge_store_refresh": edge_store_refresh, - "unresolved_refs_backfill": unresolved, - "activation_enabled": activation_enabled, - } - effective_exclude = ( - exclude_patterns if exclude_patterns is not None else _DEFAULT_EXCLUDE_PATTERNS + import types + from .index_project_runner import run_index_project as implementation + + bound = types.FunctionType( + implementation.__code__, + globals(), + implementation.__name__, + implementation.__defaults__, + implementation.__closure__, ) - owns_candidate_snapshot = False - candidate_released = False - cleanup_result: dict[str, Any] | None = None - materialized = False - if force and candidate_snapshot is None: - candidate_snapshot = build_index_candidate_snapshot( - cache.project_root, - max_files=max_files, - exclude_patterns=effective_exclude, - walk_fn=lambda root: walk_index_candidate_entries( - root, excluded_dir_names=frozenset(_EXCLUDE_DIRS) - ), - language_fn=_language_from_ext, - language_filter=language_filter, - materialize=True, - ) - owns_candidate_snapshot = True - if candidate_snapshot is not None: - validate_index_candidate_snapshot( - cache.project_root, max_files, candidate_snapshot - ) - if source_scope is None: - source_scope = make_source_scope_descriptor( - no_default_excludes=exclude_patterns is not None, - exclude_patterns=tuple(sorted(effective_exclude)) - if exclude_patterns is not None - else (), - certification_max_files=max_files, - ) - else: - source_scope = parse_source_scope_descriptor( - canonical_source_scope_descriptor(source_scope) - ) - validate_full_index_source_scope(source_scope, effective_exclude, max_files) - from ..indexing_candidate_materialization import ( - index_candidate_snapshot_is_materialized, - secure_candidate_materialization_supported, - ) - - if force: - materialized = bool( - candidate_snapshot is not None - and index_candidate_snapshot_is_materialized(candidate_snapshot) - ) - legacy_materialization = bool( - candidate_snapshot is not None - and candidate_snapshot.frozen_error == "SECURE_MATERIALIZATION_UNSUPPORTED" - and not secure_candidate_materialization_supported() - ) - snapshot_is_unsafe = bool( - candidate_snapshot is None - or candidate_snapshot.errors > 0 - or candidate_snapshot.discovery_error is not None - or candidate_snapshot.truncated_by_max_files - or not candidate_snapshot.discovery_reconciled - or (not materialized and not legacy_materialization) - ) - if snapshot_is_unsafe: - # Destructive rebuilds consume only a fully materialized frozen epoch. - changed = ( - [ - (entry, reason) - for entry in candidate_snapshot.selected_entries - if (reason := changed_since_snapshot(entry)) is not None - ] - if candidate_snapshot is not None and not materialized - else [] - ) - result = _unsafe_force_snapshot_result( - candidate_snapshot, activation_enabled, changed=changed - ) - if owns_candidate_snapshot and candidate_snapshot is not None: - from ..indexing_candidate_materialization import ( - release_index_candidate_snapshot, - ) - - release_index_candidate_snapshot(candidate_snapshot, result) - candidate_released = True - return result - - rebuild_signaled = False - root_lease_fd: int | None = None - try: - if ( - candidate_snapshot is not None - and secure_candidate_materialization_supported() - and getattr(cache, "_uses_project_mirror", True) - ): - from ..indexing_candidate_materialization import ( - index_candidate_cache_hierarchy_is_current, - open_index_candidate_snapshot_root, - ) - - root_lease_fd = open_index_candidate_snapshot_root(candidate_snapshot) - if root_lease_fd is None or not index_candidate_cache_hierarchy_is_current( - candidate_snapshot, cache, root_fd=root_lease_fd - ): - cleanup_result = _unsafe_force_snapshot_result( - candidate_snapshot, activation_enabled, changed=[] - ) - cleanup_result["mode_used"] = "full" if force else "incremental" - cleanup_result["files"] = [ - { - "file": "", - "status": "error", - "reason": "INDEX_CACHE_HIERARCHY_CHANGED", - } - ] - return cleanup_result - if force: - # #578: a full rebuild empties ast_index up front (the DELETE - # below commits), then re-populates in bounded batches over - # ~70 s. Stamp a persisted marker across that window so - # concurrent readers on other connections/processes warn - # instead of trusting the half-built table. MARK + DELETE live - # INSIDE the try so the finally clears the marker even if the - # DELETE/commit itself raises (e.g. SQLITE_FULL) — otherwise a - # failed rebuild would leave a stuck marker until TTL expiry. - conn = cache._get_conn() - # The only destructive authorization is the immutable materialized - # epoch validated above; live path replay must never gate the clear. - had_call_graph = cache.call_graph_built() - _mark_build_in_progress(conn) - rebuild_signaled = True - _clear_call_graph_built(conn) - try: - _clear_full_rebuild_rows(cache, conn) - conn.commit() - try: - _invalidate_ladybug(cache, root_lease_fd) - except Exception: - logger.debug("could not invalidate Ladybug mirror", exc_info=True) - except Exception: - conn.rollback() - if had_call_graph: - _mark_call_graph_built(conn) - raise - conn = cache._get_conn() - projection_repair = not symbol_projection_is_exact( - conn, require_fts=cache.fts5_available - ) - if projection_repair and not rebuild_signaled: - # Repair rewrites ordinary rows in committed batches just like a full - # rebuild. Publish incomplete evidence before the first batch so no - # concurrent reader can trust the old manifest/call-graph epoch. - _mark_build_in_progress(conn) - rebuild_signaled = True - _clear_call_graph_built_strict(conn) - _delete_all_rows_if_present(conn, "ast_index_snapshot_manifest") - conn.commit() - stats, candidates, count = walk_and_partition( + bound.__kwdefaults__ = implementation.__kwdefaults__ + return cast( + dict[str, Any], + bound( cache, - conn, max_files, - force or projection_repair, - activation_enabled, - _walk_source_files, - _language_from_ext, - _AST_CACHE_EXTRACTOR_VERSION, - _make_error_entry, - language_filter, - effective_exclude, - candidate_snapshot, - ) - cleanup_result = stats - candidate_fingerprints = ( - { - entry.abs_path: cast(IndexFileFingerprint, entry.fingerprint) - for entry in candidate_snapshot.selected_entries - } - if candidate_snapshot is not None - else {} - ) - candidate_frozen_paths = ( - { - entry.abs_path: entry.frozen_path - for entry in candidate_snapshot.selected_entries - if entry.frozen_path is not None - } - if candidate_snapshot is not None - else {} - ) - candidate_frozen_identities = ( - { - entry.abs_path: entry.frozen_identity - for entry in candidate_snapshot.selected_entries - if entry.frozen_identity is not None - } - if candidate_snapshot is not None - else {} - ) - # The snapshot-wide absolute deadline protects only freeze/pre-clear - # validation. Once the destructive clear is authorized, every immutable - # worker gets its own bounded read window so a long parse/build cannot - # expire later frozen inputs before their reads begin. - frozen_read_deadline = None - workers = cache._resolve_worker_count(workers, candidates) - if workers and workers >= 2 and len(candidates) >= 2: - results = index_parallel( - cache, - candidates, - workers, - candidate_fingerprints, - candidate_frozen_paths, - candidate_frozen_identities, - frozen_read_deadline, - ) - else: - from .extraction import _worker_index_file - - results = [ - _worker_index_file( - ( - path, - cache.project_root, - language, - candidate_fingerprints.get(path), - candidate_frozen_paths.get(path), - candidate_frozen_identities.get(path), - frozen_read_deadline, - ) - ) - for path, language in candidates - ] - indexed_at = datetime.now(timezone.utc).isoformat() - from .. import ast_cache as _ast_cache_mod - - snapshot_entries = ( - {entry.rel_path: entry for entry in candidate_snapshot.selected_entries} - if candidate_snapshot is not None - else None - ) - result_guard = ( - partial( - _snapshot_result_is_stable, - entries=snapshot_entries, - stats=stats, - cache=cache, - conn=conn, - root_fd=root_lease_fd, - ) - if snapshot_entries is not None - else None - ) - batch_guard = ( - partial( - _revalidate_snapshot_batch, - cache=cache, - conn=conn, - entries=snapshot_entries, - stats=stats, - root_fd=root_lease_fd, - ) - if snapshot_entries is not None - else None - ) - _ast_cache_mod._commit_index_results( - conn, - results, - stats, - partial( - insert_index_row, - cache, - conn, - extractor_version=_AST_CACHE_EXTRACTOR_VERSION, - ), - indexed_at, - activation_enabled, - result_guard=result_guard, - batch_guard=batch_guard, - ) - if snapshot_entries is not None: - if all( - entry.frozen_path is not None for entry in snapshot_entries.values() - ): - _record_frozen_replay_mismatches(snapshot_entries, stats) - if stats.get("changed_during_run", 0) > 0: - # The frozen epoch remains internally coherent, but it no - # longer certifies the live workspace consumed by readers. - _clear_call_graph_built_strict(conn) - else: - _revalidate_committed_snapshot( - cache=cache, - conn=conn, - entries=snapshot_entries, - stats=stats, - root_fd=root_lease_fd, - ) - if projection_repair and stats["errors"] == 0: - # A partial projection cannot use the unchanged-file fast path. Every - # canonical file has now been rewritten with ordinary/FTS/activation - # rows in its writer transaction; remove only orphan derived paths. - conn.execute( - "DELETE FROM ast_symbol_rows WHERE file_path NOT IN " - "(SELECT file_path FROM ast_index)" - ) - if cache.fts5_available: - # Contentless FTS5 cannot reliably predicate-delete stale rows. - # Rebuild it from the now-exact ordinary projection instead. - conn.execute( - "INSERT INTO ast_symbols_fts(ast_symbols_fts) VALUES('delete-all')" - ) - conn.execute( - "INSERT INTO ast_symbols_fts" - "(rowid, name, kind, file_path, language) " - "SELECT id, name, kind, file_path, language " - "FROM ast_symbol_rows ORDER BY id" - ) - for table in ("ast_symbol_projection_state", "ast_symbol_activation"): - conn.execute( - f"DELETE FROM {table} WHERE file_path NOT IN " # nosec B608 - "(SELECT file_path FROM ast_index)" - ) - conn.commit() - # The operation-boundary validator detected the repair and forced - # every canonical file through the writer path. The bounded migration - # certifier publishes the projection marker only after exact payload, - # state, and FTS validation; an oversized repair remains incomplete. - from ..index_snapshot_symbols import ensure_symbol_rows_backfilled - - if not ensure_symbol_rows_backfilled( - conn, - require_fts=cache.fts5_available, - allow_incomplete=True, - ): - stats["backfill_errors"] = stats.get("backfill_errors", 0) + 1 - _clear_call_graph_built_strict(conn) - conn.execute("DELETE FROM ast_index_snapshot_manifest") - conn.commit() - frozen_epoch = bool( - candidate_snapshot is not None - and all( - entry.frozen_path is not None - for entry in candidate_snapshot.selected_entries - ) - ) - if stats["changed_during_run"] > 0 and not frozen_epoch: - _clear_call_graph_built(conn) - stats["total_files"] = count - stats["workers"] = workers - if ( - candidate_snapshot is not None - and not candidate_snapshot.truncated_by_max_files - and candidate_snapshot.errors == 0 - and ( - stats.get("changed_during_run", 0) == 0 - or all( - entry.frozen_path is not None - for entry in candidate_snapshot.selected_entries - ) - ) - ): - stats["pruned"] = _prune_to_selected_scope( - cache, conn, candidate_snapshot, root_fd=root_lease_fd - ) - run_incomplete = bool( - stats.get("incomplete_skips", 0) - or stats.get("truncated_by_max_files", False) - or stats.get("errors", 0) - or ( - candidate_snapshot is not None - and ( - candidate_snapshot.errors - or candidate_snapshot.discovery_error is not None - or candidate_snapshot.truncated_by_max_files - or not candidate_snapshot.discovery_reconciled - ) - ) - ) - if run_incomplete: - # Global certification is invalid regardless of whether this run - # happened to rewrite or prune a row. - _clear_call_graph_built_strict(conn) - conn.execute("DELETE FROM ast_index_snapshot_manifest") - conn.commit() - stats["verdict"] = "WARN" - stats["manifest_warning"] = "INDEX_RUN_INCOMPLETE" - - # A missing marker is persisted evidence that a previous backfill did - # not converge. Fully cached retries must run the complete chain again. - needs_backfill = bool( - stats["indexed"] > 0 - or stats.get("pruned", 0) > 0 - or not _call_graph_marker_is_built(conn) - ) - if needs_backfill: - _clear_call_graph_built(conn) - post_index_backfill(cache, stats, root_fd=root_lease_fd) - if stats.get("backfill_errors", 0) == 0 and _candidate_paths_are_exact( - cache, - conn, - candidate_snapshot, - stats, - max_files, - language_filter, - effective_exclude, - ): - try: - _mark_call_graph_built_strict(conn) - except sqlite3.OperationalError: - logger.warning( - "call-graph marker certification failed", exc_info=True - ) - stats["backfill_errors"] = stats.get("backfill_errors", 0) + 1 - stats["manifest_warning"] = "CALL_GRAPH_MARKER_CERTIFICATION_FAILED" - _clear_call_graph_built_strict(conn) - else: - _clear_call_graph_built_strict(conn) - if force: - stats["db_maintenance"] = ( - _ast_cache_mod._reclaim_storage_after_full_rebuild(conn, cache.db_path) - ) - if certify_manifest: - _update_authoritative_manifest( - cache, candidate_snapshot, stats, source_scope - ) - return stats - finally: - try: - if root_lease_fd is not None: - try: - os.close(root_lease_fd) - except OSError: - logger.warning("could not close project-root lease", exc_info=True) - finally: - try: - if rebuild_signaled: - _clear_build_in_progress(cache._get_conn()) - finally: - if ( - owns_candidate_snapshot - and candidate_snapshot is not None - and not candidate_released - ): - from ..indexing_candidate_materialization import ( - release_index_candidate_snapshot, - ) - - release_index_candidate_snapshot(candidate_snapshot, cleanup_result) - candidate_released = True + force, + workers=workers, + resolve_only=resolve_only, + include_activation=include_activation, + language_filter=language_filter, + exclude_patterns=exclude_patterns, + candidate_snapshot=candidate_snapshot, + source_scope=source_scope, + certify_manifest=certify_manifest, + ), + ) def _call_graph_marker_is_built(conn: sqlite3.Connection) -> bool: @@ -1482,10 +386,8 @@ def _update_authoritative_manifest( if candidate_snapshot is not None else set() ) - source_certification_supported = os.name == "posix" and os.path.exists("/dev/fd") exact_paths = bool( - source_certification_supported - and candidate_snapshot is not None + candidate_snapshot is not None and candidate_snapshot.limited == 0 and candidate_snapshot.errors == 0 and stats.get("errors", 0) == 0 @@ -1520,9 +422,7 @@ def _update_authoritative_manifest( stats["scope_complete"] = False stats["verdict"] = "WARN" return - if not source_certification_supported: - stats["manifest_warning"] = "SOURCE_SCOPE_UNSUPPORTED" - elif exact_paths and not _call_graph_marker_is_built(conn): + if exact_paths and not _call_graph_marker_is_built(conn): stats["manifest_warning"] = "CALL_GRAPH_INCOMPLETE" # Do not delete a manifest epoch this operation did not publish. Status # compares source/index/marker fingerprints and classifies it as stale. diff --git a/tree_sitter_analyzer/cache/indexer_io.py b/tree_sitter_analyzer/cache/indexer_io.py new file mode 100644 index 000000000..cf38f0758 --- /dev/null +++ b/tree_sitter_analyzer/cache/indexer_io.py @@ -0,0 +1,453 @@ +"""Focused cache I/O and partition helpers for project indexing.""" + +from __future__ import annotations + +import fnmatch +import json +import os +import sqlite3 +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + pass + +from ..indexing_limits import normalize_index_max_files +from ..indexing_snapshot import ( + IndexCandidateSnapshot, + IndexFileFingerprint, + changed_since_snapshot, + validate_index_candidate_snapshot, +) +from .indexer import ( + _normalize_relative_path, + _warn_unwired_plugin_extension, +) +from .schema import ( + clear_activation_for_file as _clear_activation_for_file_fn, +) + + +def check_cache_or_read( + conn: sqlite3.Connection, + rel_path: str, + abs_path: str, + stat: Any, + content_hash_fn: Any, + extractor_version: int, + *, + source_code: str | None = None, +) -> dict[str, Any] | tuple[str, str]: + """Return cached-response dict or (source_code, content_hash) if stale.""" + row = conn.execute( + "SELECT content_hash, mtime_ns, file_size, extractor_version " + "FROM ast_index WHERE file_path = ?", + (rel_path,), + ).fetchone() + if row is not None and ( + row["mtime_ns"] == int(stat.st_mtime_ns) + and row["file_size"] == stat.st_size + and row["extractor_version"] >= extractor_version + ): + return {"file": rel_path, "status": "cached", "reason": "unchanged"} + if source_code is None: + try: + with open(abs_path, encoding="utf-8", errors="replace") as f: + source_code = f.read() + except OSError as e: + return {"file": rel_path, "status": "error", "reason": str(e)} + content_hash = content_hash_fn(source_code) + if ( + row is not None + and row["content_hash"] == content_hash + and row["extractor_version"] >= extractor_version + ): + conn.execute( + "UPDATE ast_index SET mtime_ns = ?, file_size = ? WHERE file_path = ?", + (int(stat.st_mtime_ns), stat.st_size, rel_path), + ) + conn.commit() + return {"file": rel_path, "status": "cached", "reason": "content unchanged"} + return source_code, content_hash + + +def parse_and_write( + cache: Any, + conn: sqlite3.Connection, + abs_path: str, + rel_path: str, + language: str, + stat: Any, + source_code: str, + content_hash: str, + extractor_version: int, + *, + source_is_frozen: bool = False, +) -> dict[str, Any]: + """Parse a file and write all cache rows. Returns result dict.""" + from .extraction import ( + _extract_call_edges, + _extract_imports, + _extract_structure, + _extract_symbols, + ) + + result = ( + cache.parser.parse_code(source_code, language, filename=abs_path) + if source_is_frozen + else cache.parser.parse_file(abs_path, language) + ) + if not result.success: + return { + "file": rel_path, + "status": "error", + "reason": result.error_message or "parse failed", + } + symbols = _extract_symbols(result.tree, source_code, language) + imports = _extract_imports(symbols) + structure = _extract_structure(symbols) + call_edges = _extract_call_edges(result.tree, source_code, language, symbols) + indexed_at = datetime.now(timezone.utc).isoformat() + conn.execute( + "INSERT OR REPLACE INTO ast_index " + "(file_path, content_hash, language, mtime_ns, file_size, " + "extractor_version, symbols_json, imports_json, structure_json, indexed_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + rel_path, + content_hash, + language, + int(stat.st_mtime_ns), + stat.st_size, + extractor_version, + json.dumps(symbols, ensure_ascii=False), + json.dumps(imports, ensure_ascii=False), + json.dumps(structure, ensure_ascii=False), + indexed_at, + ), + ) + from . import write as _write + + inserted = _write.write_fts5_symbols( + conn, rel_path, language, symbols, cache.fts5_available + ) + cache._write_imports_for_file(conn, rel_path, language, imports) # noqa: SLF001 + cache._write_activation_for_file(conn, rel_path, inserted) # noqa: SLF001 + # CALLS rows live in the unified ``edges`` table (B1.3 — no ast_call_edges). + # Write the edges first so synapse resolution can UPDATE them in place. + if not _write.write_graph_edges_for_file( + conn, rel_path, language, symbols, imports, call_edges + ): + conn.rollback() + return { + "file": rel_path, + "status": "error", + "reason": "graph edge write failed", + "certification_errors": 1, + } + cache._resolve_call_edges_for_file(conn, rel_path) # noqa: SLF001 + conn.commit() + return { + "file": rel_path, + "status": "indexed", + "symbols": len(symbols.get("symbols", [])), + "call_edges": len(call_edges), + "content_hash": content_hash[:16], + } + + +def walk_and_partition( + cache: Any, + conn: sqlite3.Connection, + max_files: int, + force: bool, + activation_enabled: bool, + walk_fn: Any, + language_fn: Any, + extractor_version: int, + make_error_entry: Any, + language_filter: str | None = None, + exclude_patterns: frozenset[str] | None = None, + candidate_snapshot: IndexCandidateSnapshot | None = None, +) -> tuple[dict[str, Any], list[tuple[str, str]], int]: + """Walk source files and partition into (stats, candidates, count). + + ``language_filter`` (#1018): when set, only files whose detected language + equals it are considered; non-matching files are skipped BEFORE any parse + attempt, so a Python-scoped run never tries to load an optional grammar + (e.g. Swift) and never surfaces a "grammar not installed" error. + """ + max_files = normalize_index_max_files(max_files) + candidates: list[tuple[str, str]] = [] + already_cached: list[dict[str, Any]] = [] + stats: dict[str, Any] = { + "mode_used": "full" if force else "incremental", + "indexed": 0, + "cached": 0, + "errors": 0, + "skipped": 0, + "incomplete_skips": 0, + "processed": 0, + "changed_during_run": 0, + "changed_during_run_files": [], + "files": [], + "activation_enabled": activation_enabled, + "truncated_by_max_files": False, + } + if force: + indexed_map: dict[str, tuple[Any, ...]] = {} + elif candidate_snapshot is not None: + rows = conn.execute( + "SELECT file_path, mtime_ns, file_size, extractor_version, content_hash " + "FROM ast_index" + ).fetchall() + indexed_map = { + r["file_path"]: ( + r["mtime_ns"], + r["file_size"], + r["extractor_version"], + r["content_hash"], + ) + for r in rows + } + else: + rows = conn.execute( + "SELECT file_path, mtime_ns, file_size, extractor_version FROM ast_index" + ).fetchall() + indexed_map = { + r["file_path"]: (r["mtime_ns"], r["file_size"], r["extractor_version"]) + for r in rows + } + + if candidate_snapshot is not None: + validate_index_candidate_snapshot( + cache.project_root, max_files, candidate_snapshot + ) + stats["truncated_by_max_files"] = candidate_snapshot.truncated_by_max_files + stats["snapshot_metrics"] = candidate_snapshot.metrics() + count = len(candidate_snapshot.entries) + for entry in candidate_snapshot.entries: + if entry.decision == "excluded": + stats["skipped"] += 1 + continue + if entry.decision == "skipped": + if entry.language is None: + _warn_unwired_plugin_extension(entry.abs_path) + elif language_filter is not None: + # A language-scoped run cannot certify the process-global + # call-graph marker: skipped languages and their edges are + # still part of the persisted global source inventory. + stats["incomplete_skips"] += 1 + stats["skipped"] += 1 + continue + if entry.decision == "error": + stats["errors"] += 1 + stats["files"].append( + make_error_entry(entry.rel_path, entry.reason or "stat failed") + ) + continue + + change_reason = ( + None if entry.frozen_path is not None else changed_since_snapshot(entry) + ) + if change_reason is not None: + stats["skipped"] += 1 + stats["incomplete_skips"] += 1 + stats["changed_during_run"] += 1 + stats["changed_during_run_files"].append(entry.rel_path) + stats["files"].append( + { + "file": entry.rel_path, + "status": "skipped", + "reason": change_reason, + } + ) + continue + + fingerprint = cast(IndexFileFingerprint, entry.fingerprint) + language = cast(str, entry.language) + row = indexed_map.get(entry.rel_path) + if ( + row is not None + and row[0] == fingerprint.mtime_ns + and row[1] == fingerprint.file_size + and row[2] >= extractor_version + and (not fingerprint.content_hash or row[3] == fingerprint.content_hash) + ): + already_cached.append( + { + "file": entry.rel_path, + "status": "cached", + "reason": "unchanged", + } + ) + continue + candidates.append((entry.abs_path, language)) + + stats["cached"] += len(already_cached) + stats["files"].extend(already_cached) + stats["processed"] = len(candidates) + len(already_cached) + return stats, candidates, count + + count = 0 + for abs_path in walk_fn(cache.project_root): + if count >= max_files: + stats["truncated_by_max_files"] = True + break + count += 1 + rel_path = _normalize_relative_path( + os.path.relpath(abs_path, cache.project_root) + ) + # REQ-E-016: skip files matching corpus-exclusion patterns. + if exclude_patterns: + if any(fnmatch.fnmatch(rel_path, pat) for pat in exclude_patterns): + stats["skipped"] += 1 + continue + lang = language_fn(abs_path) + if lang is None: + # REQ-E-020: emit a one-time WARNING for plugin-registered extensions + # that are not wired into the full-index path. + _warn_unwired_plugin_extension(abs_path) + stats["skipped"] += 1 + continue + if language_filter is not None and lang != language_filter: + stats["skipped"] += 1 + stats["incomplete_skips"] += 1 + continue + try: + stat = os.stat(abs_path) + except OSError as e: + stats["errors"] += 1 + stats["files"].append(make_error_entry(rel_path, str(e))) + continue + row = indexed_map.get(rel_path) + if ( + row is not None + and row[0] == int(stat.st_mtime_ns) + and row[1] == stat.st_size + and row[2] >= extractor_version + ): + already_cached.append( + {"file": rel_path, "status": "cached", "reason": "unchanged"} + ) + continue + candidates.append((abs_path, lang)) + stats["cached"] += len(already_cached) + stats["files"].extend(already_cached) + stats["processed"] = len(candidates) + len(already_cached) + return stats, candidates, count + + +def _clear_full_rebuild_rows(cache: Any, conn: sqlite3.Connection) -> None: + """Clear primary and derived index rows before a forced rebuild.""" + from .write import _clear_symbol_resolver_context + + _clear_symbol_resolver_context() + conn.execute("DELETE FROM ast_index") + if cache.fts5_available: + conn.execute( + "INSERT INTO ast_symbols_fts(ast_symbols_fts) VALUES('delete-all')" + ) + _delete_all_rows_if_present(conn, "ast_symbol_rows") + for table in ( + "ast_symbol_projection_state", + "ast_imports", + "ast_symbol_activation", + "edges", + ): + _delete_all_rows_if_present(conn, table) + + +def _delete_all_rows_if_present(conn: sqlite3.Connection, table: str) -> None: + """Delete a legacy-optional table, propagating every real database failure.""" + try: + conn.execute(f"DELETE FROM {table}") # nosec B608 - fixed table names + except sqlite3.OperationalError as exc: + if "no such table" not in str(exc).lower(): + raise + + +def insert_index_row( + cache: Any, + conn: sqlite3.Connection, + r: dict[str, Any], + indexed_at: str, + extractor_version: int, + include_activation: bool = True, +) -> None: + """Write one worker result to SQLite (main table + optional FTS5).""" + rel_path = r["rel_path"] + conn.execute( + """INSERT OR REPLACE INTO ast_index + (file_path, content_hash, language, mtime_ns, file_size, + extractor_version, symbols_json, imports_json, structure_json, + indexed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + rel_path, + r["content_hash"], + r["language"], + r["mtime_ns"], + r["file_size"], + extractor_version, + r["symbols_json"], + r["imports_json"], + r["structure_json"], + indexed_at, + ), + ) + from . import write as _write + + inserted_symbol_rows = _write.write_fts5_symbols_from_tuples( + conn, + rel_path, + r["language"], + r["symbol_rows"], + cache.fts5_available, + ) + call_edges = json.loads(r.get("call_edges_json", "[]")) + imports_list = json.loads(r.get("imports_json", "[]")) + cache._write_imports_for_file(conn, rel_path, r["language"], imports_list) # noqa: SLF001 + symbols = json.loads(r.get("symbols_json", "{}")) + # CALLS rows live in the unified ``edges`` table (B1.3 — no ast_call_edges). + # Cross-file / synapse resolution UPDATEs these rows in the post-index pass. + if not _write.write_graph_edges_for_file( + conn, rel_path, r["language"], symbols, imports_list, call_edges + ): + raise sqlite3.OperationalError("GRAPH_EDGE_WRITE_FAILED") + if include_activation: + cache._write_activation_for_file(conn, rel_path, inserted_symbol_rows) # noqa: SLF001 + else: + _clear_activation_for_file_fn(conn, rel_path) + + +def index_parallel( + cache: Any, + candidates: list[tuple[str, str]], + workers: int, + fingerprints: Mapping[str, IndexFileFingerprint] | None = None, + frozen_paths: Mapping[str, str] | None = None, + frozen_identities: Mapping[str, tuple[int, int, int]] | None = None, + frozen_deadline: float | None = None, +) -> list[dict[str, Any]]: + """Dispatch parse+extract to a spawn process pool (safe on macOS/Linux).""" + from multiprocessing import get_context + + from .extraction import _init_worker_parser, _worker_index_file + + ctx = get_context("spawn") + args_iter = [ + ( + path, + cache.project_root, + language, + fingerprints.get(path) if fingerprints is not None else None, + frozen_paths.get(path) if frozen_paths is not None else None, + frozen_identities.get(path) if frozen_identities is not None else None, + frozen_deadline, + ) + for path, language in candidates + ] + with ctx.Pool(processes=workers, initializer=_init_worker_parser) as pool: + return list(pool.imap_unordered(_worker_index_file, args_iter, chunksize=8)) diff --git a/tree_sitter_analyzer/cache/indexer_snapshot.py b/tree_sitter_analyzer/cache/indexer_snapshot.py new file mode 100644 index 000000000..8b12ea2a0 --- /dev/null +++ b/tree_sitter_analyzer/cache/indexer_snapshot.py @@ -0,0 +1,255 @@ +"""Frozen candidate revalidation helpers for project indexing.""" + +from __future__ import annotations + +import logging +import sqlite3 +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + pass + +from ..indexing_snapshot import ( + IndexCandidateSnapshot, + IndexFileFingerprint, + IndexSnapshotEntry, + changed_since_snapshot, +) +from .indexer import _invalidate_ladybug, _normalize_relative_path + +logger = logging.getLogger(__name__) + + +def _snapshot_result_change_reason( + result: dict[str, Any], + entries: dict[str, IndexSnapshotEntry], +) -> tuple[str, str | None]: + rel_path = _normalize_relative_path(str(result["rel_path"])) + entry = entries[rel_path] + if result.get("status") == "source_changed": + return rel_path, "file changed after candidate snapshot" + fingerprint = cast(IndexFileFingerprint, entry.fingerprint) + worker_fingerprint = ( + int(result.get("mtime_ns", fingerprint.mtime_ns)), + int(result.get("file_size", fingerprint.file_size)), + ) + expected_fingerprint = (fingerprint.mtime_ns, fingerprint.file_size) + return rel_path, ( + "file changed after candidate snapshot" + if worker_fingerprint != expected_fingerprint + else None + if entry.frozen_path is not None + else changed_since_snapshot(entry) + ) + + +def _record_snapshot_change( + stats: dict[str, Any], rel_path: str, change_reason: str +) -> None: + """Replace one processed result with a deterministic snapshot skip.""" + stats["skipped"] += 1 + stats["incomplete_skips"] = stats.get("incomplete_skips", 0) + 1 + stats["processed"] = max(0, int(stats["processed"]) - 1) + stats["changed_during_run"] += 1 + stats["changed_during_run_files"].append(rel_path) + stats["files"].append( + {"file": rel_path, "status": "skipped", "reason": change_reason} + ) + + +def _discard_snapshot_generation( + cache: Any, + conn: sqlite3.Connection, + rel_path: str, + *, + root_fd: int | None = None, +) -> None: + """Remove canonical rows and invalidate their derived graph projection.""" + from . import write as _write + + _write.discard_file_rows(conn, rel_path, cache.fts5_available) + try: + _invalidate_ladybug(cache, root_fd) + except Exception: + logger.debug("could not invalidate Ladybug mirror", exc_info=True) + + +def _discard_with_root_lease( + cache: Any, + conn: sqlite3.Connection, + rel_path: str, + root_fd: int | None, +) -> None: + """Preserve the legacy call seam when no pinned lease is active.""" + if root_fd is None: + _discard_snapshot_generation(cache, conn, rel_path) + else: + _discard_snapshot_generation(cache, conn, rel_path, root_fd=root_fd) + + +def _snapshot_result_is_stable( + result: dict[str, Any], + entries: dict[str, IndexSnapshotEntry], + stats: dict[str, Any], + *, + cache: Any, + conn: sqlite3.Connection, + root_fd: int | None = None, +) -> bool: + """Validate one worker result immediately before its database write.""" + rel_path, change_reason = _snapshot_result_change_reason(result, entries) + if change_reason is None: + return True + + _discard_with_root_lease(cache, conn, rel_path, root_fd) + _record_snapshot_change(stats, rel_path, change_reason) + return False + + +def _revalidate_snapshot_batch( + pending_results: list[dict[str, Any]], + *, + cache: Any, + conn: sqlite3.Connection, + entries: dict[str, IndexSnapshotEntry], + stats: dict[str, Any], + root_fd: int | None = None, +) -> None: + """Discard pending generations that changed before their batch commit.""" + for result in pending_results: + rel_path, change_reason = _snapshot_result_change_reason(result, entries) + if change_reason is None: + continue + _discard_with_root_lease(cache, conn, rel_path, root_fd) + if result["status"] in ("io_error", "parse_failed"): + stats["errors"] -= 1 + else: + stats["indexed"] -= 1 + for index in range(len(stats["files"]) - 1, -1, -1): + if stats["files"][index]["file"] == rel_path: + del stats["files"][index] + break + _record_snapshot_change(stats, rel_path, change_reason) + + +def _revalidate_committed_snapshot( + *, + cache: Any, + conn: sqlite3.Connection, + entries: dict[str, IndexSnapshotEntry], + stats: dict[str, Any], + root_fd: int | None = None, +) -> None: + """Invalidate any earlier committed generation changed before backfill.""" + known_changed = set(stats["changed_during_run_files"]) + for rel_path, entry in entries.items(): + change_reason = ( + None if rel_path in known_changed else changed_since_snapshot(entry) + ) + if change_reason is None: + continue + _discard_with_root_lease(cache, conn, rel_path, root_fd) + detail_files = [detail["file"] for detail in stats["files"]] + detail_index = detail_files.index(rel_path) + detail = stats["files"].pop(detail_index) + counter = { + "error": "errors", + "indexed": "indexed", + "cached": "cached", + }[detail["status"]] + stats[counter] -= 1 + _record_snapshot_change(stats, rel_path, change_reason) + + +def _record_frozen_replay_mismatches( + entries: dict[str, IndexSnapshotEntry], stats: dict[str, Any] +) -> None: + """Report live divergence without deleting the complete frozen epoch.""" + changed = [ + (rel_path, reason) + for rel_path, entry in entries.items() + if (reason := changed_since_snapshot(entry)) is not None + ] + if not changed: + return + known = set(stats.get("changed_during_run_files", [])) + known.update(rel_path for rel_path, _reason in changed) + stats["changed_during_run_files"] = sorted(known) + stats["changed_during_run"] = len(known) + stats["live_source_replay_mismatch"] = True + stats["manifest_warning"] = "INDEX_CANDIDATE_SNAPSHOT_CHANGED" + for rel_path, reason in changed: + stats["files"].append({"file": rel_path, "status": "warning", "reason": reason}) + + +def _unsafe_force_snapshot_result( + candidate_snapshot: IndexCandidateSnapshot | None, + activation_enabled: bool, + *, + changed: list[tuple[IndexSnapshotEntry, str]] | None = None, +) -> dict[str, Any]: + """Return a terminal force result before any persistent state is touched.""" + changed = changed or [] + discovery_details = ( + [ + { + "file": entry.rel_path, + "status": "error", + "reason": entry.reason or "candidate discovery failed", + } + for entry in candidate_snapshot.entries + if entry.decision == "error" + ] + if candidate_snapshot is not None + else [] + ) + frozen_reason = ( + candidate_snapshot.frozen_error + or ( + "INDEX_CANDIDATE_FROZEN_EVIDENCE_MISSING" + if candidate_snapshot.frozen_root is None + else None + ) + if candidate_snapshot is not None + else "INDEX_CANDIDATE_FROZEN_EVIDENCE_MISSING" + ) + if frozen_reason and not changed: + discovery_details.append( + {"file": "", "status": "error", "reason": frozen_reason} + ) + changed_details = [ + {"file": entry.rel_path, "status": "error", "reason": reason} + for entry, reason in changed + ] + errors = max( + 1, + (candidate_snapshot.errors if candidate_snapshot is not None else 0) + + len(changed_details), + ) + return { + "mode_used": "full", + "verdict": "WARN", + "abort_remaining_phases": True, + "indexed": 0, + "cached": 0, + "errors": errors, + "skipped": candidate_snapshot.skipped if candidate_snapshot is not None else 0, + "incomplete_skips": ( + candidate_snapshot.skipped if candidate_snapshot is not None else 0 + ) + + len(changed_details), + "processed": 0, + "changed_during_run": len(changed_details), + "changed_during_run_files": [detail["file"] for detail in changed_details], + "files": discovery_details + changed_details, + "activation_enabled": activation_enabled, + "truncated_by_max_files": bool( + candidate_snapshot and candidate_snapshot.truncated_by_max_files + ), + "snapshot_metrics": candidate_snapshot.metrics() if candidate_snapshot else {}, + "manifest_warning": ( + "INDEX_CANDIDATE_SNAPSHOT_CHANGED" + if changed_details + else "INDEX_CANDIDATE_SNAPSHOT_INCOMPLETE" + ), + } diff --git a/tree_sitter_analyzer/cli/argument_groups/_mcp.py b/tree_sitter_analyzer/cli/argument_groups/_mcp.py index c5dc76b18..4585f6e74 100644 --- a/tree_sitter_analyzer/cli/argument_groups/_mcp.py +++ b/tree_sitter_analyzer/cli/argument_groups/_mcp.py @@ -276,6 +276,15 @@ def _add_mcp_constraints_options(parser: argparse.ArgumentParser) -> None: "(safe_to_edit, change_impact)" ), ) + parser.add_argument( + "--constraints-read-only", + action="store_true", + default=False, + help=( + "Evaluate constraints without creating or updating the AST cache " + "or persisted violation rows" + ), + ) parser.add_argument( "--severity-min", choices=["error", "warn", "info"], diff --git a/tree_sitter_analyzer/cli/commands/constraint_check_command.py b/tree_sitter_analyzer/cli/commands/constraint_check_command.py index 3a929907e..4277dd2e6 100644 --- a/tree_sitter_analyzer/cli/commands/constraint_check_command.py +++ b/tree_sitter_analyzer/cli/commands/constraint_check_command.py @@ -22,12 +22,23 @@ from typing import Any from ...constraints import evaluate, load_constraints -from ...constraints.parser import ConstraintParseError, _compile_glob +from ...constraints.parser import ( + ConstraintParseError, + _compile_glob, + load_constraints_bytes, +) from ...mcp.tools.constraint_check_tool import ConstraintCheckTool +from .constraint_check_execution import ( + ExplicitConfigEvidence, + explicit_config_evidence, +) +from .constraint_check_persistence import run_and_persist _SEVERITY_ORDER: dict[str, int] = {"info": 0, "warn": 1, "error": 2} _BLOCKING_SEVERITIES: frozenset[str] = frozenset({"error"}) _WARNING_SEVERITIES: frozenset[str] = frozenset({"warn"}) +_EXPLICIT_CONFIG_BYTE_LIMIT = 1024 * 1024 +_EXPLICIT_CONFIG_READ_SECONDS = 10.0 # Exit-code contract for CI/CD wrappers. UNSAFE blocks (exit 1) so a # `--check-constraints` step in a Makefile/Husky hook fails the pipeline @@ -57,6 +68,7 @@ def run_check_constraints(args: Any, project_root: str) -> int: severity_min = getattr(args, "severity_min", None) or "warn" path_filter = getattr(args, "constraint_path_filter", "") or "" constraint_file = getattr(args, "constraint_file", None) + read_only = bool(getattr(args, "constraints_read_only", False)) if constraint_file: # CLI-only path: explicit constraint file, evaluate directly so @@ -68,6 +80,7 @@ def run_check_constraints(args: Any, project_root: str) -> int: severity_min=severity_min, path_filter=path_filter, output_format=output_format, + persist=not read_only, ) else: # Default path: delegate to the MCP tool so CLI and MCP share a @@ -78,6 +91,7 @@ def run_check_constraints(args: Any, project_root: str) -> int: severity_min=severity_min, path_filter=path_filter, output_format=output_format, + persist=not read_only, ) ) @@ -90,16 +104,33 @@ def _run_tool( severity_min: str, path_filter: str, output_format: str, + persist: bool = True, ) -> Any: """Await the MCP ConstraintCheckTool with CLI-supplied arguments.""" tool = ConstraintCheckTool(project_root=project_root) - return tool.execute( - { - "path_filter": path_filter, - "severity_min": severity_min, - "output_format": output_format, - } - ) + tool_arguments: dict[str, Any] = { + "path_filter": path_filter, + "severity_min": severity_min, + "output_format": output_format, + } + if not persist: + tool_arguments["persist"] = False + return tool.execute(tool_arguments) + + +def _explicit_config_evidence( + config_path: Path, deadline: float +) -> ExplicitConfigEvidence: + return explicit_config_evidence(config_path, deadline) + + +def _explicit_config_changed( + config_path: Path, before: ExplicitConfigEvidence, deadline: float +) -> bool: + try: + return _explicit_config_evidence(config_path, deadline) != before + except (OSError, RuntimeError): + return True def _evaluate_with_explicit_file( @@ -109,6 +140,7 @@ def _evaluate_with_explicit_file( severity_min: str, path_filter: str, output_format: str, + persist: bool = True, ) -> dict[str, Any]: """Evaluate against an explicit constraint file (CLI-only override). @@ -123,16 +155,35 @@ def _evaluate_with_explicit_file( f"constraint file not found: {constraint_file}", output_format ) - # ``load_constraints`` discovers the file under project_root, so we - # temporarily point it at the YAML's parent — keeping the parse path - # identical to the default flow. + config_deadline = time.monotonic() + _EXPLICIT_CONFIG_READ_SECONDS + config_before: ExplicitConfigEvidence | None = None try: - constraints = _load_explicit(config_path) - except ConstraintParseError as exc: + if not persist: + config_before = _explicit_config_evidence(config_path, config_deadline) + constraints = load_constraints_bytes(config_before[0], config_path) + else: + constraints = _load_explicit(config_path) + except (ConstraintParseError, OSError, RuntimeError) as exc: return _failure_envelope(f"constraint parse error: {exc}", output_format) + if not constraints and not persist: + assert config_before is not None + if _explicit_config_changed(config_path, config_before, config_deadline): + return _config_changed_envelope(0, output_format) + return _format_response( + { + "success": True, + "verdict": "SAFE", + "violations": [], + "rule_count": 0, + "evaluated_edge_count": 0, + "constraint_file": str(config_path), + }, + output_format, + ) + db_path = Path(project_root) / ".ast-cache" / "index.db" - if not db_path.is_file(): + if persist and not db_path.is_file(): return _format_response( { "success": True, @@ -149,12 +200,55 @@ def _evaluate_with_explicit_file( ) min_severity_rank = _SEVERITY_ORDER.get(severity_min, 1) - violations, edge_count = _run_and_persist(db_path, constraints) - filtered = _filter_violations( - violations, - path_filter=path_filter, - min_severity_rank=min_severity_rank, - ) + try: + if persist: + violations, edge_count = _run_and_persist( + db_path, constraints, persist=True + ) + filtered = _filter_violations( + violations, + path_filter=path_filter, + min_severity_rank=min_severity_rank, + ) + else: + filtered, edge_count = ConstraintCheckTool( + project_root=project_root + )._run_read_only( + db_path, + constraints, + path_filter=path_filter, + min_severity_rank=min_severity_rank, + evaluator=evaluate, + deadline=config_deadline, + ) + except ( + sqlite3.DatabaseError, + OSError, + RuntimeError, + ValueError, + TypeError, + AttributeError, + ) as exc: + error_code = ( + "CONSTRAINT_EVALUATION_CAPACITY" + if str(exc) == "CONSTRAINT_EVALUATION_CAPACITY" + else "CONSTRAINT_INDEX_UNKNOWN" + ) + return _format_response( + { + "success": False, + "verdict": "ERROR", + "error_code": error_code, + "error": str(exc), + "violations": [], + "rule_count": len(constraints), + }, + output_format, + ) + if not persist: + assert config_before is not None + if _explicit_config_changed(config_path, config_before, config_deadline): + return _config_changed_envelope(len(constraints), output_format) verdict = _compute_verdict(filtered) return _format_response( { @@ -195,53 +289,16 @@ def _load_explicit(config_path: Path) -> list[Any]: def _run_and_persist( db_path: Path, constraints: list[Any], + *, + persist: bool = True, ) -> tuple[list[Any], int]: - """Run evaluator + persist violations (mirrors the MCP tool's path).""" - conn = sqlite3.connect(str(db_path)) - try: - conn.execute(_violations_ddl()) - try: - edge_count = int( - conn.execute( - "SELECT COUNT(*) FROM edges WHERE kind = 'calls'" - ).fetchone()[0] - ) - except sqlite3.OperationalError: - edge_count = 0 - if edge_count == 0: - return [], 0 - try: - violations = evaluate(constraints, conn) - except Exception: # noqa: BLE001 — degrade rather than crash CLI - return [], edge_count - - conn.execute("DELETE FROM ast_constraint_violations") - now = int(time.time()) - conn.executemany( - """ - INSERT OR IGNORE INTO ast_constraint_violations - (rule_id, caller_file, caller_name, caller_line, - callee_name, callee_file, severity, detected_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - [ - ( - v.rule_id, - v.caller_file, - v.caller_name, - v.caller_line, - v.callee_name, - v.callee_file, - v.severity, - v.detected_at or now, - ) - for v in violations - ], - ) - conn.commit() - return violations, edge_count - finally: - conn.close() + return run_and_persist( + db_path, + constraints, + persist=persist, + evaluator=evaluate, + violations_ddl=_violations_ddl, + ) def _filter_violations( @@ -309,6 +366,18 @@ def _format_response(payload: dict[str, Any], output_format: str) -> dict[str, A return apply_toon_format_to_response(payload, output_format) +def _config_changed_envelope(rule_count: int, output_format: str) -> dict[str, Any]: + payload = { + "success": False, + "verdict": "ERROR", + "violations": [], + "error_code": "CONSTRAINT_CONFIG_CHANGED", + "error": "CONSTRAINT_CONFIG_CHANGED", + "rule_count": rule_count, + } + return _format_response(payload, output_format) + + def _failure_envelope(message: str, output_format: str) -> dict[str, Any]: """Build a CAUTION-verdict failure response that obeys the envelope.""" return _format_response( diff --git a/tree_sitter_analyzer/cli/commands/constraint_check_execution.py b/tree_sitter_analyzer/cli/commands/constraint_check_execution.py new file mode 100644 index 000000000..cfe3b7700 --- /dev/null +++ b/tree_sitter_analyzer/cli/commands/constraint_check_execution.py @@ -0,0 +1,56 @@ +"""Bounded explicit-file evidence for CLI constraint execution.""" + +from __future__ import annotations + +import os +import stat +import time +from pathlib import Path + +EXPLICIT_CONFIG_BYTE_LIMIT = 1024 * 1024 +ExplicitConfigIdentity = tuple[int, int, int, int, int, int, int] +ExplicitConfigEvidence = tuple[bytes, ExplicitConfigIdentity] + + +def _identity(info: os.stat_result) -> ExplicitConfigIdentity: + """Project mutation identity without read-induced access timestamps.""" + return ( + int(info.st_dev), + int(info.st_ino), + int(info.st_mode), + int(info.st_size), + int(info.st_mtime_ns), + int(info.st_ctime_ns), + int(getattr(info, "st_file_attributes", 0)), + ) + + +def explicit_config_evidence( + config_path: Path, deadline: float +) -> ExplicitConfigEvidence: + """Read stable explicit configuration bytes under a one-MiB deadline budget.""" + before = config_path.stat(follow_symlinks=False) + before_identity = _identity(before) + if not stat.S_ISREG(before.st_mode): + raise OSError("constraint file is not a regular file") + data = bytearray() + with config_path.open("rb", buffering=0) as stream: + opened_identity = _identity(os.fstat(stream.fileno())) + if opened_identity != before_identity: + raise OSError("constraint file changed during read") + while True: + if time.monotonic() >= deadline: + raise RuntimeError("CONSTRAINT_CONFIG_DEADLINE") + chunk = stream.read( + min(64 * 1024, EXPLICIT_CONFIG_BYTE_LIMIT - len(data) + 1) + ) + if not chunk: + break + data.extend(chunk) + if len(data) > EXPLICIT_CONFIG_BYTE_LIMIT: + raise RuntimeError("CONSTRAINT_CONFIG_CAPACITY") + if _identity(os.fstat(stream.fileno())) != opened_identity: + raise OSError("constraint file changed during read") + if _identity(config_path.stat(follow_symlinks=False)) != opened_identity: + raise OSError("constraint file changed during read") + return bytes(data), opened_identity diff --git a/tree_sitter_analyzer/cli/commands/constraint_check_persistence.py b/tree_sitter_analyzer/cli/commands/constraint_check_persistence.py new file mode 100644 index 000000000..8e5356da5 --- /dev/null +++ b/tree_sitter_analyzer/cli/commands/constraint_check_persistence.py @@ -0,0 +1,74 @@ +"""SQLite write-through for the CLI architectural-constraint command.""" + +from __future__ import annotations + +import sqlite3 +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + + +def run_and_persist( + db_path: Path, + constraints: list[Any], + *, + persist: bool, + evaluator: Callable[[list[Any], sqlite3.Connection], list[Any]], + violations_ddl: Callable[[], str], +) -> tuple[list[Any], int]: + """Evaluate one index and atomically replace persisted violations.""" + target = str(db_path) if persist else f"{db_path.resolve().as_uri()}?mode=ro" + conn = sqlite3.connect(target, uri=not persist) + try: + if persist: + conn.execute(violations_ddl()) + try: + edge_count = int( + conn.execute( + "SELECT COUNT(*) FROM edges WHERE kind = 'calls'" + ).fetchone()[0] + ) + except sqlite3.OperationalError: + if not persist: + raise + edge_count = 0 + if edge_count == 0: + return [], 0 + try: + violations = evaluator(constraints, conn) + except RuntimeError as exc: + if not persist or str(exc) == "CONSTRAINT_EVALUATION_CAPACITY": + raise + return [], edge_count + except Exception: + if not persist: + raise + return [], edge_count + if not persist: + return violations, edge_count + conn.execute("DELETE FROM ast_constraint_violations") + now = int(time.time()) + conn.executemany( + """INSERT OR IGNORE INTO ast_constraint_violations + (rule_id, caller_file, caller_name, caller_line, + callee_name, callee_file, severity, detected_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + [ + ( + v.rule_id, + v.caller_file, + v.caller_name, + v.caller_line, + v.callee_name, + v.callee_file, + v.severity, + v.detected_at or now, + ) + for v in violations + ], + ) + conn.commit() + return violations, edge_count + finally: + conn.close() diff --git a/tree_sitter_analyzer/constraints/evaluator.py b/tree_sitter_analyzer/constraints/evaluator.py index 0d83938fe..fe0003a98 100644 --- a/tree_sitter_analyzer/constraints/evaluator.py +++ b/tree_sitter_analyzer/constraints/evaluator.py @@ -34,19 +34,35 @@ import logging import sqlite3 import time -from collections.abc import Iterator - +from collections.abc import Callable, Iterator + +from .evaluator_bounds import MAX_MATERIALIZED_ITEMS, materialize_bounded +from .evaluator_deduplication import claim_violation +from .evaluator_import_resolution import ( + _build_import_index, + _callee_is_imported, +) +from .evaluator_selection import ( + _MAX_SQL_PREFIX_FILTERS as _MAX_SQL_PREFIX_FILTERS, +) +from .evaluator_selection import ( + _build_select_query, +) from .parser import _CompiledConstraint, compile_constraints from .schema import Constraint, Violation logger = logging.getLogger(__name__) -_MAX_SQL_PREFIX_FILTERS = 256 +_MAX_MATERIALIZED_ITEMS = MAX_MATERIALIZED_ITEMS def evaluate( constraints: list[Constraint], db_conn: sqlite3.Connection, + *, + scope_predicate: Callable[[str, str], bool] | None = None, + check_callback: Callable[[], None] | None = None, + capacity: int = _MAX_MATERIALIZED_ITEMS, ) -> list[Violation]: """Evaluate constraints against the unified ``edges`` table (CALLS rows). @@ -70,13 +86,28 @@ def evaluate( if not compiled: return [] detected_at = int(time.time()) - return list(_iter_violations(compiled, db_conn, detected_at)) + return materialize_bounded( + _iter_violations( + compiled, + db_conn, + detected_at, + scope_predicate=scope_predicate, + check_callback=check_callback, + capacity=capacity, + ), + capacity, + check_callback, + ) def _iter_violations( compiled: list[_CompiledConstraint], db_conn: sqlite3.Connection, detected_at: int, + *, + scope_predicate: Callable[[str, str], bool] | None = None, + check_callback: Callable[[], None] | None = None, + capacity: int = _MAX_MATERIALIZED_ITEMS, ) -> Iterator[Violation]: """Stream edges from the DB and yield matching violations. @@ -98,16 +129,29 @@ def _iter_violations( but deterministic and the PK is the same violation regardless). """ seen: set[tuple[str, str, int, str]] = set() - import_index = _build_import_index(db_conn) + import_index = _build_import_index( + db_conn, check_callback=check_callback, capacity=capacity + ) select_sql, select_params = _build_select_query(db_conn, compiled) cursor = db_conn.execute(select_sql, select_params) for row in cursor: + if check_callback is not None: + check_callback() caller_name, caller_file, caller_line, callee_name, callee_file = row if not callee_file: # Unresolved cross-file call — MVP skips it to avoid noisy # false positives on dynamic / external symbols. continue + if scope_predicate is not None and not scope_predicate( + caller_file, callee_file + ): + # Scope is part of edge eligibility. Applying it before PK + # deduplication prevents an out-of-scope resolution candidate from + # hiding an in-scope candidate for the same logical call site. + continue for cc in compiled: + if check_callback is not None: + check_callback() if cc.from_prefix and not caller_file.startswith(cc.from_prefix): continue if cc.from_re.fullmatch(caller_file) is None: @@ -127,15 +171,14 @@ def _iter_violations( and not _callee_is_imported(caller_file, callee_file, import_index) ): continue - pk = ( + if not claim_violation( + seen, cc.constraint.id, caller_file, int(caller_line or 0), callee_name or "", - ) - if pk in seen: + ): continue - seen.add(pk) yield Violation( rule_id=cc.constraint.id, caller_file=caller_file, @@ -158,131 +201,3 @@ def _is_excepted(caller_file: str, compiled: _CompiledConstraint) -> bool: if exc_re.fullmatch(caller_file) is not None: return True return False - - -def _build_import_index( - db_conn: sqlite3.Connection, -) -> dict[str, set[str]] | None: - """Build a lookup of {file_path: set(module_path_suffixes)} from ast_imports. - - Returns ``None`` when the ``ast_imports`` table is absent (e.g. in - test fixtures that only populate the ``edges`` table) so callers can - skip the import-reachability guard and fall back to pre-guard behaviour. - - The set stored per file is the union of the raw module_path and its - terminal component (the basename after the last ``.`` or ``/``). - This handles both absolute imports (``tree_sitter_analyzer.mcp.x``) - and relative imports (``.x``) with a single membership test. - """ - try: - cursor = db_conn.execute("SELECT file_path, module_path FROM ast_imports") - except sqlite3.OperationalError: - # Table absent (test fixture, fresh DB) — degrade gracefully. - return None - - index: dict[str, set[str]] = {} - for file_path, module_path in cursor: - if not file_path or not module_path: - continue - entry = index.setdefault(file_path, set()) - # Store full module_path (handles absolute imports). - entry.add(module_path) - # Also store the terminal component so relative imports like - # '.file_health_blocks' and absolute ones both match via the - # basename 'file_health_blocks'. - terminal = module_path.lstrip(".").rsplit(".", 1)[-1] - if terminal: - entry.add(terminal) - return index - - -def _callee_is_imported( - caller_file: str, - callee_file: str, - import_index: dict[str, set[str]], -) -> bool: - """Return True when the caller's import set covers the callee's module. - - Converts ``callee_file`` (a relative project path like - ``tree_sitter_analyzer/mcp/tools/utils/file_health_blocks.py``) to: - - * A full dotted module path: ``tree_sitter_analyzer.mcp.tools.utils.file_health_blocks`` - * A terminal component: ``file_health_blocks`` - - Then checks whether any entry in the caller's import set matches - either form — covering both absolute and relative imports. - - Returns ``True`` (caller imports callee) when the import_index has no - entry for the caller, so that files not recorded in ast_imports (e.g. - languages not yet extracted) do not produce false negatives. - """ - caller_imports = import_index.get(caller_file) - if caller_imports is None: - # No import data for caller → assume reachable to avoid false negatives. - return True - - # Derive module identifiers from the callee's file path. - without_ext = callee_file.removesuffix(".py") - full_module = without_ext.replace("/", ".") - terminal = without_ext.rsplit("/", 1)[-1] - - return full_module in caller_imports or terminal in caller_imports - - -def _build_select_query( - db_conn: sqlite3.Connection, - compiled: list[_CompiledConstraint], -) -> tuple[str, tuple[str, ...]]: - """Build the parameterized SELECT over the unified ``edges`` table. - - CALLS edges now live in ``edges`` with every resolution scalar promoted to - a real column (B1.3). The callee file prefers ``callee_resolved_file`` and - falls back to the caller's ``file_path`` when the call was never cross-file - resolved — preserving the legacy ``CASE WHEN callee_resolved_file != ''`` - behaviour. - - Rules with literal caller or callee prefixes cannot match rows outside - those prefixes. Push both necessary conditions into SQLite so the Python - hot loop only sees plausible candidates. ``instr`` is case-sensitive and - treats glob-special characters literally, preserving the regex matcher's - path semantics. - - If any rule has no literal prefix, the query must retain every CALLS row - because that rule may match anywhere. The ``db_conn`` argument is retained - for signature compatibility. - """ - callee_expr = ( - "CASE WHEN callee_resolved_file != '' " - "THEN callee_resolved_file " - "ELSE file_path END" - ) - select_sql = ( - "SELECT caller_name, file_path AS caller_file, " - "caller_line, callee_name, " - f"{callee_expr} AS callee_file " # nosec B608 — callee_expr is constructed from internal constants only - "FROM edges WHERE kind = 'calls'" - ) - from_prefixes = tuple(dict.fromkeys(cc.from_prefix for cc in compiled)) - to_prefixes = tuple(dict.fromkeys(cc.to_prefix for cc in compiled)) - filters: list[str] = [] - params: list[str] = [] - if ( - from_prefixes - and "" not in from_prefixes - and len(from_prefixes) <= _MAX_SQL_PREFIX_FILTERS - ): - filters.append(" OR ".join("instr(file_path, ?) = 1" for _ in from_prefixes)) - params.extend(from_prefixes) - if ( - to_prefixes - and "" not in to_prefixes - and len(to_prefixes) <= _MAX_SQL_PREFIX_FILTERS - ): - filters.append(" OR ".join(f"instr({callee_expr}, ?) = 1" for _ in to_prefixes)) - params.extend(to_prefixes) - if not filters: - return select_sql, () - return ( - f"{select_sql} AND " + " AND ".join(f"({item})" for item in filters), - tuple(params), - ) diff --git a/tree_sitter_analyzer/constraints/evaluator_bounds.py b/tree_sitter_analyzer/constraints/evaluator_bounds.py new file mode 100644 index 000000000..0af952ba7 --- /dev/null +++ b/tree_sitter_analyzer/constraints/evaluator_bounds.py @@ -0,0 +1,25 @@ +"""Materialization bounds for constraint evaluation.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import TypeVar + +_T = TypeVar("_T") +MAX_MATERIALIZED_ITEMS = 10_000 + + +def materialize_bounded( + items: Iterable[_T], capacity: int, check_callback: Callable[[], None] | None +) -> list[_T]: + """Materialize items without exceeding the caller-owned response capacity.""" + if capacity < 0: + raise ValueError("capacity must be non-negative") + result: list[_T] = [] + for item in items: + if check_callback is not None: + check_callback() + if len(result) >= capacity: + raise RuntimeError("CONSTRAINT_EVALUATION_CAPACITY") + result.append(item) + return result diff --git a/tree_sitter_analyzer/constraints/evaluator_deduplication.py b/tree_sitter_analyzer/constraints/evaluator_deduplication.py new file mode 100644 index 000000000..a5a226d78 --- /dev/null +++ b/tree_sitter_analyzer/constraints/evaluator_deduplication.py @@ -0,0 +1,18 @@ +"""Primary-key deduplication for streamed constraint violations.""" + +from __future__ import annotations + + +def claim_violation( + seen: set[tuple[str, str, int, str]], + rule_id: str, + caller_file: str, + caller_line: int, + callee_name: str, +) -> bool: + """Claim one persisted violation identity, returning false for duplicates.""" + key = (rule_id, caller_file, caller_line, callee_name) + if key in seen: + return False + seen.add(key) + return True diff --git a/tree_sitter_analyzer/constraints/evaluator_import_resolution.py b/tree_sitter_analyzer/constraints/evaluator_import_resolution.py new file mode 100644 index 000000000..ff60ac054 --- /dev/null +++ b/tree_sitter_analyzer/constraints/evaluator_import_resolution.py @@ -0,0 +1,86 @@ +"""Import-reachability evidence for constraint evaluation.""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Callable + +_MAX_MATERIALIZED_ITEMS = 10_000 + + +def _build_import_index( + db_conn: sqlite3.Connection, + *, + check_callback: Callable[[], None] | None = None, + capacity: int = _MAX_MATERIALIZED_ITEMS, +) -> dict[str, set[str]] | None: + """Build a lookup of {file_path: set(module_path_suffixes)} from ast_imports. + + Returns ``None`` when the ``ast_imports`` table is absent (e.g. in + test fixtures that only populate the ``edges`` table) so callers can + skip the import-reachability guard and fall back to pre-guard behaviour. + + The set stored per file is the union of the raw module_path and its + terminal component (the basename after the last ``.`` or ``/``). + This handles both absolute imports (``tree_sitter_analyzer.mcp.x``) + and relative imports (``.x``) with a single membership test. + """ + try: + cursor = db_conn.execute("SELECT file_path, module_path FROM ast_imports") + except sqlite3.OperationalError: + # Table absent (test fixture, fresh DB) — degrade gracefully. + return None + + index: dict[str, set[str]] = {} + materialized = 0 + for file_path, module_path in cursor: + if check_callback is not None: + check_callback() + if not file_path or not module_path: + continue + materialized += 1 + if materialized > capacity: + raise RuntimeError("CONSTRAINT_EVALUATION_CAPACITY") + entry = index.setdefault(file_path, set()) + # Store full module_path (handles absolute imports). + entry.add(module_path) + # Also store the terminal component so relative imports like + # '.file_health_blocks' and absolute ones both match via the + # basename 'file_health_blocks'. + terminal = module_path.lstrip(".").rsplit(".", 1)[-1] + if terminal: + entry.add(terminal) + return index + + +def _callee_is_imported( + caller_file: str, + callee_file: str, + import_index: dict[str, set[str]], +) -> bool: + """Return True when the caller's import set covers the callee's module. + + Converts ``callee_file`` (a relative project path like + ``tree_sitter_analyzer/mcp/tools/utils/file_health_blocks.py``) to: + + * A full dotted module path: ``tree_sitter_analyzer.mcp.tools.utils.file_health_blocks`` + * A terminal component: ``file_health_blocks`` + + Then checks whether any entry in the caller's import set matches + either form — covering both absolute and relative imports. + + Returns ``True`` (caller imports callee) when the import_index has no + entry for the caller, so that files not recorded in ast_imports (e.g. + languages not yet extracted) do not produce false negatives. + """ + caller_imports = import_index.get(caller_file) + if caller_imports is None: + # No import data for caller → assume reachable to avoid false negatives. + return True + + # Derive module identifiers from the callee's file path. + without_ext = callee_file.removesuffix(".py") + full_module = without_ext.replace("/", ".") + terminal = without_ext.rsplit("/", 1)[-1] + + return full_module in caller_imports or terminal in caller_imports diff --git a/tree_sitter_analyzer/constraints/evaluator_selection.py b/tree_sitter_analyzer/constraints/evaluator_selection.py new file mode 100644 index 000000000..e4b7c5842 --- /dev/null +++ b/tree_sitter_analyzer/constraints/evaluator_selection.py @@ -0,0 +1,68 @@ +"""Bounded SQL candidate selection for constraint evaluation.""" + +from __future__ import annotations + +import sqlite3 + +from .parser import _CompiledConstraint + +_MAX_SQL_PREFIX_FILTERS = 256 + + +def _build_select_query( + db_conn: sqlite3.Connection, + compiled: list[_CompiledConstraint], +) -> tuple[str, tuple[str, ...]]: + """Build the parameterized SELECT over the unified ``edges`` table. + + CALLS edges now live in ``edges`` with every resolution scalar promoted to + a real column (B1.3). The callee file prefers ``callee_resolved_file`` and + falls back to the caller's ``file_path`` when the call was never cross-file + resolved — preserving the legacy ``CASE WHEN callee_resolved_file != ''`` + behaviour. + + Rules with literal caller or callee prefixes cannot match rows outside + those prefixes. Push both necessary conditions into SQLite so the Python + hot loop only sees plausible candidates. ``instr`` is case-sensitive and + treats glob-special characters literally, preserving the regex matcher's + path semantics. + + If any rule has no literal prefix, the query must retain every CALLS row + because that rule may match anywhere. The ``db_conn`` argument is retained + for signature compatibility. + """ + callee_expr = ( + "CASE WHEN callee_resolved_file != '' " + "THEN callee_resolved_file " + "ELSE file_path END" + ) + select_sql = ( + "SELECT caller_name, file_path AS caller_file, " + "caller_line, callee_name, " + f"{callee_expr} AS callee_file " # nosec B608 — callee_expr is constructed from internal constants only + "FROM edges WHERE kind = 'calls'" + ) + from_prefixes = tuple(dict.fromkeys(cc.from_prefix for cc in compiled)) + to_prefixes = tuple(dict.fromkeys(cc.to_prefix for cc in compiled)) + filters: list[str] = [] + params: list[str] = [] + if ( + from_prefixes + and "" not in from_prefixes + and len(from_prefixes) <= _MAX_SQL_PREFIX_FILTERS + ): + filters.append(" OR ".join("instr(file_path, ?) = 1" for _ in from_prefixes)) + params.extend(from_prefixes) + if ( + to_prefixes + and "" not in to_prefixes + and len(to_prefixes) <= _MAX_SQL_PREFIX_FILTERS + ): + filters.append(" OR ".join(f"instr({callee_expr}, ?) = 1" for _ in to_prefixes)) + params.extend(to_prefixes) + if not filters: + return select_sql, () + return ( + f"{select_sql} AND " + " AND ".join(f"({item})" for item in filters), + tuple(params), + ) diff --git a/tree_sitter_analyzer/constraints/parser.py b/tree_sitter_analyzer/constraints/parser.py index e8f48a2fc..c101b3ae6 100644 --- a/tree_sitter_analyzer/constraints/parser.py +++ b/tree_sitter_analyzer/constraints/parser.py @@ -108,72 +108,67 @@ def _find_config_file(project_root: Path) -> Path | None: _ALLOWED_RULES: frozenset[str] = frozenset({"forbid"}) -def load_constraints(project_root: str | Path) -> list[Constraint]: - """Load and validate architectural-constraints from ``project_root``. - - Returns an empty list when no config file is present — a repo with - no constraints.yml is a perfectly valid state. - - Raises: - ConstraintParseError: on malformed YAML or unknown top-level - keys. Per-rule problems (unknown keys, bad severity, missing - required keys) emit a warning and skip the rule rather than - failing the whole load. - """ - root = Path(project_root) - config_path = _find_config_file(root) - if config_path is None: - return [] - +def load_constraints_bytes( + raw_bytes: bytes, config_path: str | Path +) -> list[Constraint]: + """Parse constraints from caller-owned immutable bytes.""" + path = Path(config_path) try: - raw_text = config_path.read_text(encoding="utf-8") - except OSError as exc: + raw_text = raw_bytes.decode("utf-8") + except UnicodeDecodeError as exc: raise ConstraintParseError( - f"Could not read constraints file at line 1 of {config_path}: {exc}" + f"Could not decode constraints file at line 1 of {path}: {exc}" ) from exc - try: data = yaml.safe_load(raw_text) except yaml.YAMLError as exc: - # Surface the mark/line so the agent can jump straight to the - # offending location instead of re-reading the file. line_hint = _extract_line(exc) raise ConstraintParseError( - f"Malformed YAML in {config_path} at line {line_hint}: {exc}" + f"Malformed YAML in {path} at line {line_hint}: {exc}" ) from exc if data is None: return [] - if not isinstance(data, dict): raise ConstraintParseError( - f"Top-level of {config_path} must be a mapping at line 1, " + f"Top-level of {path} must be a mapping at line 1, " f"got {type(data).__name__}" ) - - # Strict on the top level: unknown keys are fatal. for key in data: if key not in _ALLOWED_TOP_LEVEL: raise ConstraintParseError( - f"unknown top-level key: {key!r} in {config_path} at line 1. " + f"unknown top-level key: {key!r} in {path} at line 1. " f"Allowed: {sorted(_ALLOWED_TOP_LEVEL)}" ) - rules_raw = data.get("constraints") or [] if not isinstance(rules_raw, list): raise ConstraintParseError( - f"'constraints' must be a list at line 1 of {config_path}, " + f"'constraints' must be a list at line 1 of {path}, " f"got {type(rules_raw).__name__}" ) - constraints: list[Constraint] = [] for index, rule_raw in enumerate(rules_raw, start=1): - parsed = _parse_rule(rule_raw, index, config_path) + parsed = _parse_rule(rule_raw, index, path) if parsed is not None: constraints.append(parsed) return constraints +def load_constraints(project_root: str | Path) -> list[Constraint]: + """Load and validate architectural-constraints from ``project_root``.""" + root = Path(project_root) + config_path = _find_config_file(root) + if config_path is None: + return [] + try: + raw_bytes = config_path.read_bytes() + except OSError as exc: + raise ConstraintParseError( + f"Could not read constraints file at line 1 of {config_path}: {exc}" + ) from exc + return load_constraints_bytes(raw_bytes, config_path) + + def match_glob(pattern: str, path: str) -> bool: """Return True when ``path`` matches ``pattern`` under our glob model. diff --git a/tree_sitter_analyzer/diff_snapshot_capture.py b/tree_sitter_analyzer/diff_snapshot_capture.py index 7fe57c1f2..05da6acba 100644 --- a/tree_sitter_analyzer/diff_snapshot_capture.py +++ b/tree_sitter_analyzer/diff_snapshot_capture.py @@ -6,6 +6,7 @@ import os from dataclasses import dataclass, field, replace +from .diff_snapshot_constraints import _blob from .diff_snapshot_epoch import FrozenGitEnvironment from .frozen_git_index import invalidate_index_stat_cache from .git_path_codec import path_to_raw, path_to_wire, raw_to_path @@ -271,12 +272,6 @@ def _binary_paths(git: FrozenGitEnvironment, base: bytes, limit: int) -> set[byt if fields[0] == fields[1] == b"-": binary.add(path) return binary -def _blob( - git: FrozenGitEnvironment, oid: str | None, kind: str, limit: int -) -> bytes | None: - if oid is None or kind == "gitlink": - return None - return git.run(["cat-file", "blob", oid], limit=limit) def _safe_mode(safe_kind: str, metadata: tuple[bytes, ...]) -> tuple[str | None, str]: if safe_kind == "missing": return None, "missing" diff --git a/tree_sitter_analyzer/diff_snapshot_constraints.py b/tree_sitter_analyzer/diff_snapshot_constraints.py new file mode 100644 index 000000000..5f2c7b5ad --- /dev/null +++ b/tree_sitter_analyzer/diff_snapshot_constraints.py @@ -0,0 +1,232 @@ +"""Frozen-index inputs used by read-only constraint evaluation.""" + +from __future__ import annotations + +import os +from collections.abc import Callable + +from .diff_snapshot_epoch import FrozenGitEnvironment +from .frozen_git_index import frozen_index_output +from .git_path_codec import path_to_raw, raw_to_path +from .git_subprocess import run_git_bounded +from .languages.lang_extension_map import EXT_TO_LANG +from .source_oracle import SafePath, SourceOracleError +from .source_oracle_git import GitEpoch + + +def _entry_parts(entry: bytes | None) -> tuple[str | None, str | None, str]: + if entry is None: + return None, None, "missing" + fields = entry.split(b" ") + if len(fields) < 2: + raise SourceOracleError("DIFF_SNAPSHOT_GIT_ERROR") + mode = fields[0].decode("ascii", "strict") + oid_field = fields[1] if len(fields) == 3 and fields[2] == b"0" else fields[-1] + oid = oid_field.decode("ascii", "strict") + kind = "gitlink" if mode == "160000" else "symlink" if mode == "120000" else "file" + return mode, oid, kind + + +def _blob( + git: FrozenGitEnvironment, oid: str | None, kind: str, limit: int +) -> bytes | None: + if oid is None or kind == "gitlink": + return None + return git.run(["cat-file", "blob", oid], limit=limit) + + +def frozen_index_constraint_config( + root: str, epoch: GitEpoch, deadline: float, storage_limit: int +) -> tuple[str | None, bytes | None, tuple[bytes, ...]]: + """Read constraint discovery and bytes from the captured stage-zero index.""" + entries = epoch.index_map() + with FrozenGitEnvironment(root, epoch, deadline, storage_limit) as git: + for candidate in ( + "architectural-constraints.yml", + ".tree-sitter-analyzer/constraints.yml", + ): + raw = path_to_raw(candidate) + entry = entries.get(raw) + if entry is None: + continue + mode, oid, kind = _entry_parts(entry) + if mode not in ("100644", "100755") or oid is None or kind != "file": + raise SourceOracleError("CONSTRAINT_CONFIG_UNSAFE") + data = _blob(git, oid, kind, 1024 * 1024) + if data is None: + raise SourceOracleError("CONSTRAINT_CONFIG_UNSAFE") + return candidate, data, (raw + b"\0" + entry,) + return None, None, () + + +def _constraint_error(exc: SourceOracleError) -> str: + code = str(exc) + if code.startswith("CONSTRAINT_CONFIG_"): + return code + if code == "DIFF_SNAPSHOT_CAPACITY": + return "CONSTRAINT_CONFIG_CAPACITY" + return "CONSTRAINT_CONFIG_UNSAFE" + + +def live_constraint_config( + root: str, + deadline: float, + reader: Callable[..., SafePath], +) -> tuple[str | None, bytes | None, tuple[bytes, ...], str | None]: + """Capture optional constraint evidence without gating generic snapshots.""" + try: + for candidate in ( + "architectural-constraints.yml", + ".tree-sitter-analyzer/constraints.yml", + ): + probe = reader( + root, + candidate, + deadline=deadline, + limit=1024 * 1024, + allow_directory=True, + ) + if probe.kind in {"missing", "directory"}: + continue + if probe.kind != "file" or probe.data is None: + raise SourceOracleError("CONSTRAINT_CONFIG_UNSAFE") + return candidate, probe.data, probe.metadata, None + return None, None, (), None + except SourceOracleError as exc: + return None, None, (), _constraint_error(exc) + + +def staged_constraint_config( + root: str, + epoch: GitEpoch, + deadline: float, + storage_limit: int, + reader: Callable[ + ..., tuple[str | None, bytes | None, tuple[bytes, ...]] + ] = frozen_index_constraint_config, +) -> tuple[str | None, bytes | None, tuple[bytes, ...], str | None]: + """Capture optional staged constraint evidence without gating other consumers.""" + try: + path, data, metadata = reader(root, epoch, deadline, storage_limit) + return path, data, metadata, None + except SourceOracleError as exc: + return None, None, (), _constraint_error(exc) + + +def _ignored_submodule_sources( + root: str, epoch: GitEpoch, deadline: float, limit: int +) -> tuple[bytes, ...]: + """Return ignored supported leaves or uncertifiable live gitlinks.""" + gitlinks = tuple( + path + for path, entry in epoch.index_map().items() + if entry.startswith(b"160000 ") + ) + if not gitlinks: + return () + if not os.path.isfile(os.path.join(root, ".gitmodules")): + # A legacy/manually staged gitlink can still be an initialized nested + # repository. Without configuration it cannot be enumerated by Git; + # conservatively prevent a staged consumer from borrowing its live graph. + return gitlinks + script = ( + 'printf "H\\0%s\\0" "$displaypath"; ' + "git ls-files --others --ignored --exclude-standard -t -z" + ) + raw = run_git_bounded( + root, + ["submodule", "foreach", "--recursive", "--quiet", script], + deadline=deadline, + limit=limit, + ) + fields = raw.split(b"\0") + if fields and fields[-1] == b"": + fields.pop() + supported: list[bytes] = [] + visited: set[bytes] = set() + prefix: bytes | None = None + index = 0 + while index < len(fields): + field = fields[index] + if field == b"H": + index += 1 + if index >= len(fields): + raise SourceOracleError("DIFF_SNAPSHOT_GIT_ERROR") + prefix = fields[index] + visited.add(prefix) + index += 1 + continue + if prefix is None or not field.startswith(b"? "): + raise SourceOracleError("DIFF_SNAPSHOT_GIT_ERROR") + leaf = field[2:] + path = raw_to_path(prefix + b"/" + leaf) + if EXT_TO_LANG.get(os.path.splitext(path)[1].lower()) is not None: + supported.append(prefix + b"/" + leaf) + index += 1 + # A configured gitlink that foreach could not visit also cannot certify + # staged/live equivalence (for example incomplete initialization metadata). + supported.extend(path for path in gitlinks if path not in visited) + return tuple(supported) + + +def frozen_index_sources_match_worktree( + root: str, epoch: GitEpoch, deadline: float, limit: int +) -> bool: + """Return whether every supported source has the same index/worktree plane.""" + dirty_raw = frozen_index_output( + root, + epoch.index_bytes, + [ + "diff-files", + "--name-only", + "-z", + "--no-ext-diff", + "--no-textconv", + "--ignore-submodules=none", + ], + deadline=deadline, + limit=limit, + refresh=True, + clear_hints=True, + object_format=epoch.object_format, + ) + untracked_raw = frozen_index_output( + root, + epoch.index_bytes, + ["ls-files", "--others", "-z"], + deadline=deadline, + limit=limit, + object_format=epoch.object_format, + ) + paths = {path for path in dirty_raw.split(b"\0") if path} + paths.update(path for path in untracked_raw.split(b"\0") if path) + if _ignored_submodule_sources(root, epoch, deadline, limit): + return False + indexed_paths = epoch.index_map() + for raw in paths: + path = raw_to_path(raw) + if EXT_TO_LANG.get(os.path.splitext(path)[1].lower()) is not None: + return False + entry = indexed_paths.get(raw) + if entry is not None and entry.startswith(b"160000 "): + # The live full-index walker can index supported descendants of a + # submodule. Any dirty gitlink therefore diverges from stage zero + # even though Git reports only the extensionless container path. + return False + return True + + +def staged_sources_match_worktree( + root: str, + epoch: GitEpoch, + deadline: float, + limit: int, + reader: Callable[[str, GitEpoch, float, int], bool] = ( + frozen_index_sources_match_worktree + ), +) -> bool: + """Keep staged source uncertainty consumer-scoped for generic snapshots.""" + try: + return reader(root, epoch, deadline, limit) + except SourceOracleError: + return False diff --git a/tree_sitter_analyzer/diff_snapshot_leases.py b/tree_sitter_analyzer/diff_snapshot_leases.py index 4a1367142..20bdfe13c 100644 --- a/tree_sitter_analyzer/diff_snapshot_leases.py +++ b/tree_sitter_analyzer/diff_snapshot_leases.py @@ -26,6 +26,13 @@ class FrozenDiffSnapshot: assessed_scope_paths: tuple[str, ...] created_monotonic: float materialized_bytes: int + git_generation: str | None = None + constraint_config_path: str | None = None + constraint_config_data: bytes | None = None + constraint_config_metadata: tuple[bytes, ...] = () + constraint_config_error: str | None = None + staged_source_matches_worktree: bool = True + staged_config_matches_worktree: bool = True _inventory_raw_paths: tuple[bytes, ...] = () _assessed_scope_raw_paths: tuple[bytes, ...] = () diff --git a/tree_sitter_analyzer/diff_snapshot_registry.py b/tree_sitter_analyzer/diff_snapshot_registry.py index f8605e363..76c1542a0 100644 --- a/tree_sitter_analyzer/diff_snapshot_registry.py +++ b/tree_sitter_analyzer/diff_snapshot_registry.py @@ -11,9 +11,16 @@ import threading import time from collections.abc import Callable -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field +from typing import cast from .diff_snapshot_capture import _capture_payload +from .diff_snapshot_constraints import ( + frozen_index_constraint_config, + live_constraint_config, + staged_sources_match_worktree, + staged_constraint_config, +) from .diff_snapshot_expiry import SnapshotExpiryScheduler, schedule_expiry from .diff_snapshot_leases import ( FrozenDiffSnapshot, @@ -21,6 +28,12 @@ route_lease, snapshot_error, ) +from .diff_snapshot_validation import ( + acquire as acquire_snapshot, + bind_assessed_scope as bind_snapshot_scope, + validate_publish as validate_snapshot_publish, +) +from .diff_snapshot_source import resolve_shared_source_generation from .diff_snapshot_paths import ( epoch_inventory, normalize_bounded_paths, @@ -35,6 +48,7 @@ canonical_root, capture_inventory, oracle_generation, + safe_workspace_path, ) from .source_oracle_git import GitEpoch @@ -49,6 +63,15 @@ _ROUTE_LEASE_PATTERN = re.compile(r"dl_[A-Za-z0-9_-]{43}", re.ASCII) +def shared_source_generation(project_root: str, deadline: float) -> str: + """Return the P0.1 source-oracle token, preserving registry patch seams.""" + return resolve_shared_source_generation( + project_root, + deadline, + oracle_generation=oracle_generation, + ) + + @dataclass class _State: snapshot: FrozenDiffSnapshot @@ -134,6 +157,7 @@ def create( self._reservations[reservation] = ceiling try: root, identity = canonical_root(project_root) + shared_before = shared_source_generation(root, deadline) pre_manifest: dict[str, WorkspaceManifestEntry] = {} epochs: list[GitEpoch] = [] oracle_call: Callable[..., tuple[str, RootIdentity]] = oracle_generation @@ -201,14 +225,85 @@ def create( manifest=post_manifest, **oracle_budget, ) + shared_after = shared_source_generation(root, deadline) if ( - before != after + shared_before != shared_after + or before != after or identity != after_identity or pre_manifest != post_manifest ): raise SourceOracleError("DIFF_SNAPSHOT_SOURCE_CHANGED") + optional_started = time.monotonic() + optional_deadline = min( + deadline, + optional_started + max(0.0, deadline - optional_started) / 2, + ) + ( + live_config_path, + live_config_data, + live_config_metadata, + live_config_error, + ) = live_constraint_config(root, optional_deadline, safe_workspace_path) + staged_source_matches_worktree = True + staged_config_matches_worktree = True + constraint_config_error = live_config_error + if mode == "staged": + if epoch is None and "epoch_out" in oracle_params: + raise SourceOracleError("DIFF_SNAPSHOT_GIT_ERROR") + staged_epoch = cast(GitEpoch, epoch) + ( + constraint_config_path, + constraint_config_data, + constraint_config_metadata, + constraint_config_error, + ) = staged_constraint_config( + root, + staged_epoch, + optional_deadline, + ceiling, + frozen_index_constraint_config, + ) + staged_source_matches_worktree = staged_sources_match_worktree( + root, + staged_epoch, + optional_deadline, + min(16 * 1024 * 1024, ceiling), + ) + staged_config_matches_worktree = ( + constraint_config_error is None + and live_config_error is None + and constraint_config_path == live_config_path + and constraint_config_data == live_config_data + ) + if staged_config_matches_worktree: + # Preserve the worktree descriptor evidence used by the + # final publish guard; stage-zero identity is held by epoch. + constraint_config_metadata = live_config_metadata + else: + constraint_config_path = live_config_path + constraint_config_data = live_config_data + constraint_config_metadata = live_config_metadata + final_manifest: dict[str, WorkspaceManifestEntry] = {} + final_git, final_identity = oracle_call( + root, + mode, + deadline=deadline, + manifest=final_manifest, + **oracle_budget, + ) + if ( + final_git != before + or final_identity != identity + or final_manifest != pre_manifest + ): + raise SourceOracleError("DIFF_SNAPSHOT_SOURCE_CHANGED") paths = set(normalized_input) paths.update(item.record.path for item in files) + paths.update( + item.record.old_path + for item in files + if item.record.old_path is not None + ) size = ( len(patch) + sum( @@ -218,24 +313,37 @@ def create( + path_collection_storage(paths) + path_collection_storage(inventory_paths) + record_storage(files) + + len(constraint_config_data or b"") + + sum(len(item) for item in constraint_config_metadata) ) if size > ceiling: raise SourceOracleError("DIFF_SNAPSHOT_CAPACITY") sid = "ds_" + secrets.token_urlsafe(24) lease = route_lease(self._lease_key, sid) snapshot = FrozenDiffSnapshot( - sid, - before, - identity, - mode, - patch, - files, - inventory_paths, - tuple(sorted(paths, key=path_to_raw)), - started, - size, - tuple(sorted(path_to_raw(path) for path in inventory_paths)), - tuple(sorted(path_to_raw(path) for path in paths)), + snapshot_id=sid, + source_generation=shared_before, + git_generation=before, + root_identity=identity, + mode=mode, + normalized_patch=patch, + files=files, + inventory_paths=inventory_paths, + assessed_scope_paths=tuple(sorted(paths, key=path_to_raw)), + created_monotonic=started, + materialized_bytes=size, + constraint_config_path=constraint_config_path, + constraint_config_data=constraint_config_data, + constraint_config_metadata=constraint_config_metadata, + constraint_config_error=constraint_config_error, + staged_source_matches_worktree=staged_source_matches_worktree, + staged_config_matches_worktree=staged_config_matches_worktree, + _inventory_raw_paths=tuple( + sorted(path_to_raw(path) for path in inventory_paths) + ), + _assessed_scope_raw_paths=tuple( + sorted(path_to_raw(path) for path in paths) + ), ) with self._lock: self._reservations.pop(reservation, None) @@ -259,193 +367,70 @@ def create( "success": True, "diff_snapshot_id": sid, "route_lease_id": lease, - "source_generation": before, + "source_generation": shared_before, "changed_records": [x.record.to_dict() for x in files], "assessed_scope_paths": [ path_to_wire(path) for path in snapshot.assessed_scope_paths ], } except SourceOracleError as exc: - with self._lock: - self._reservations.pop(reservation, None) return snapshot_error(str(exc)) except Exception: + return snapshot_error("DIFF_SNAPSHOT_CAPTURE_ERROR") + finally: with self._lock: self._reservations.pop(reservation, None) - return snapshot_error("DIFF_SNAPSHOT_CAPTURE_ERROR") + def acquire( - self, snapshot_id: str, project_root: str | None + self, + snapshot_id: str, + project_root: str | None, + *, + deadline: float | None = None, ) -> tuple[SnapshotConsumer | None, str | None]: - try: - _, identity = canonical_root(project_root) - except SourceOracleError as exc: - return None, str(exc) - with self._lock: - self._sweep() - state = self._states.get(snapshot_id) - if state is None or state.expired or not state.lease_open: - return None, "DIFF_SNAPSHOT_EXPIRED" - if state.snapshot.root_identity != identity: - return None, "DIFF_SNAPSHOT_ROOT_MISMATCH" - pin = secrets.token_urlsafe(16) - owner = threading.get_ident() - state.pins[pin] = owner - consumer = SnapshotConsumer(self, state.snapshot, pin) - remaining = ( - state.snapshot.created_monotonic + HARD_LIFETIME_SECONDS - self._clock() - ) - if remaining <= 0: - consumer.release() - return None, "DIFF_SNAPSHOT_EXPIRED" - try: - generation, current_identity = oracle_generation( - identity.realpath, - consumer.snapshot.mode, - deadline=time.monotonic() + remaining, - ) - except SourceOracleError as exc: - consumer.release() - return None, str(exc) - with self._lock: - self._sweep() - current = self._states.get(snapshot_id) - remaining = ( - state.snapshot.created_monotonic + HARD_LIFETIME_SECONDS - self._clock() - ) - if ( - current is not state - or state.expired - or not state.lease_open - or state.pins.get(pin) != owner - or remaining <= 0 - ): - consumer.release() - return None, "DIFF_SNAPSHOT_EXPIRED" - if ( - current_identity != identity - or generation != consumer.snapshot.source_generation - ): - consumer.release() - return None, "DIFF_SNAPSHOT_SOURCE_CHANGED" - return consumer, None + return acquire_snapshot( + self, + snapshot_id, + project_root, + oracle_generation=oracle_generation, + shared_source_generation=shared_source_generation, + hard_lifetime_seconds=HARD_LIFETIME_SECONDS, + canonicalize_root=canonical_root, + deadline=deadline, + ) + def bind_assessed_scope( self, consumer: SnapshotConsumer, paths: list[str] ) -> str | None: - try: - normalized = normalize_bounded_paths( - paths, - count_limit=MAX_SCOPE_PATHS, - path_limit=MAX_PATH_BYTES, - storage_limit=MAX_SCOPE_BYTES, - ) - except SourceOracleError as exc: - return str(exc) - with self._lock: - self._sweep() - snapshot = consumer._snapshot - state = self._states.get(snapshot.snapshot_id) if snapshot else None - if ( - state is None - or consumer._released - or state.pins.get(consumer._pin) != consumer._owner - ): - return "DIFF_SNAPSHOT_EXPIRED" - if threading.get_ident() != consumer._owner: - return "DIFF_SNAPSHOT_WRONG_THREAD" - if len(state.pins) != 1: - return "DIFF_SNAPSHOT_IN_USE" - if ( - state.expired - or not state.lease_open - or self._clock() - state.snapshot.created_monotonic - >= HARD_LIFETIME_SECONDS - ): - state.expired = True - state.lease_open = False - return "DIFF_SNAPSHOT_EXPIRED" - old_paths_size = path_collection_storage( - state.snapshot.assessed_scope_paths - ) - delta = path_collection_storage(normalized) - old_paths_size - if ( - self._charged_bytes + sum(self._reservations.values()) + delta - > MAX_MATERIALIZED_BYTES - ): - return "DIFF_SNAPSHOT_CAPACITY" - updated = replace( - state.snapshot, - assessed_scope_paths=normalized, - _assessed_scope_raw_paths=tuple( - path_to_raw(path) for path in normalized - ), - materialized_bytes=state.snapshot.materialized_bytes + delta, - ) - state.snapshot = updated - consumer._snapshot = updated - self._charged_bytes += delta - return None - def validate_publish(self, consumer: SnapshotConsumer) -> str | None: - with self._lock: - self._sweep() - snapshot = consumer._snapshot - state = self._states.get(snapshot.snapshot_id) if snapshot else None - remaining = ( - HARD_LIFETIME_SECONDS - - (self._clock() - state.snapshot.created_monotonic) - if state is not None - else 0.0 - ) - if state is None or state.expired or not state.lease_open or remaining <= 0: - if state is not None: - state.expired = True - state.lease_open = False - return "DIFF_SNAPSHOT_EXPIRED" - assert snapshot is not None - try: - oracle_params = inspect.signature(oracle_generation).parameters - if "deadline" in oracle_params: - generation, identity = oracle_generation( - snapshot.root_identity.realpath, - snapshot.mode, - deadline=time.monotonic() + remaining, - ) - else: # compatibility for injected platform seams - generation, identity = oracle_generation( - snapshot.root_identity.realpath, snapshot.mode - ) - except SourceOracleError as exc: - return str(exc) - with self._lock: - state = self._states.get(snapshot.snapshot_id) - if ( - state is None - or consumer._released - or state.pins.get(consumer._pin) != consumer._owner - ): - return "DIFF_SNAPSHOT_EXPIRED" - if threading.get_ident() != consumer._owner: - return "DIFF_SNAPSHOT_WRONG_THREAD" - if ( - state.expired - or not state.lease_open - or self._clock() - state.snapshot.created_monotonic - >= HARD_LIFETIME_SECONDS - ): - state.expired = True - state.lease_open = False - return "DIFF_SNAPSHOT_EXPIRED" - if ( - state.snapshot.root_identity != snapshot.root_identity - or identity != state.snapshot.root_identity - ): - return "DIFF_SNAPSHOT_ROOT_MISMATCH" - if ( - state.snapshot.source_generation != snapshot.source_generation - or generation != state.snapshot.source_generation - ): - return "DIFF_SNAPSHOT_SOURCE_CHANGED" - return None + return bind_snapshot_scope( + self, + consumer, + paths, + scope_limits=(MAX_SCOPE_PATHS, MAX_PATH_BYTES, MAX_SCOPE_BYTES), + max_materialized_bytes=MAX_MATERIALIZED_BYTES, + hard_lifetime_seconds=HARD_LIFETIME_SECONDS, + normalize_paths=normalize_bounded_paths, + ) + + def validate_publish( + self, + consumer: SnapshotConsumer, + publish_guard: Callable[[], str | None] | None = None, + *, + deadline: float | None = None, + ) -> str | None: + return validate_snapshot_publish( + self, + consumer, + publish_guard, + oracle_generation=oracle_generation, + shared_source_generation=shared_source_generation, + hard_lifetime_seconds=HARD_LIFETIME_SECONDS, + deadline=deadline, + ) + verify = validate_publish + def _release(self, sid: str, pin: str, owner: int) -> None: with self._lock: self._sweep() diff --git a/tree_sitter_analyzer/diff_snapshot_source.py b/tree_sitter_analyzer/diff_snapshot_source.py new file mode 100644 index 000000000..aae550319 --- /dev/null +++ b/tree_sitter_analyzer/diff_snapshot_source.py @@ -0,0 +1,59 @@ +"""Resolve the source-generation token shared by diff and index snapshots.""" + +from __future__ import annotations + +import inspect +import time +from collections.abc import Callable +from typing import Any, cast + +from .source_oracle import SourceOracleError + + +def resolve_shared_source_generation( + project_root: str, + deadline: float, + *, + oracle_generation: Callable[..., tuple[str, Any]], +) -> str: + """Return the P0.1 source-oracle token, replaying its certified scope.""" + from .index_snapshot import lease_existing_snapshot, lease_reusable_snapshot + from .index_source_snapshot import capture_current_source_snapshot + + if time.monotonic() > deadline: + raise SourceOracleError("DIFF_SNAPSHOT_TIMEOUT") + # Lightweight injected registry seams predate the shared-oracle bridge. + # Production oracle_generation always exposes epoch_out. + if "epoch_out" not in inspect.signature(oracle_generation).parameters: + generation, _identity = oracle_generation( + project_root, "diff", deadline=deadline + ) + return generation + reusable = _lease_with_optional_deadline( + lease_reusable_snapshot, project_root, deadline + ) + with reusable as reusable_snapshot: + if ( + reusable_snapshot is not None + and reusable_snapshot.source_generation is not None + ): + return cast(str, reusable_snapshot.source_generation) + existing = _lease_with_optional_deadline( + lease_existing_snapshot, project_root, deadline + ) + with existing as existing_snapshot: + if existing_snapshot.source_generation is not None: + return cast(str, existing_snapshot.source_generation) + # An unusable index is not authoritative for source-only consumers. + current = capture_current_source_snapshot(project_root, deadline=deadline) + if current.state != "exact" or current.generation is None: + raise SourceOracleError(current.reason or "DIFF_SNAPSHOT_SOURCE_CHANGED") + return current.generation + + +def _lease_with_optional_deadline( + lease: Callable[..., Any], project_root: str, deadline: float +) -> Any: + if "deadline" in inspect.signature(lease).parameters: + return lease(project_root, deadline=deadline) + return lease(project_root) diff --git a/tree_sitter_analyzer/diff_snapshot_validation.py b/tree_sitter_analyzer/diff_snapshot_validation.py new file mode 100644 index 000000000..dc2fcc7b7 --- /dev/null +++ b/tree_sitter_analyzer/diff_snapshot_validation.py @@ -0,0 +1,269 @@ +"""Consumer validation operations for the process-local diff snapshot registry.""" + +from __future__ import annotations + +import inspect +import secrets +import threading +from collections.abc import Callable +from dataclasses import replace +from typing import Any + +from .diff_snapshot_leases import SnapshotConsumer +from .diff_snapshot_paths import normalize_bounded_paths, path_collection_storage +from .git_path_codec import path_to_raw +from .source_oracle import RootIdentity, SourceOracleError, canonical_root + + +def acquire( + registry: Any, + snapshot_id: str, + project_root: str | None, + *, + oracle_generation: Callable[..., tuple[str, RootIdentity]], + shared_source_generation: Callable[[str, float], str], + hard_lifetime_seconds: float, + canonicalize_root: Callable[ + [str | None], tuple[str, RootIdentity] + ] = canonical_root, + deadline: float | None = None, +) -> tuple[SnapshotConsumer | None, str | None]: + try: + _, identity = canonicalize_root(project_root) + except SourceOracleError as exc: + return None, str(exc) + with registry._lock: + registry._sweep() + state = registry._states.get(snapshot_id) + if state is None or state.expired or not state.lease_open: + return None, "DIFF_SNAPSHOT_EXPIRED" + if state.snapshot.root_identity != identity: + return None, "DIFF_SNAPSHOT_ROOT_MISMATCH" + pin = secrets.token_urlsafe(16) + owner = threading.get_ident() + state.pins[pin] = owner + consumer = SnapshotConsumer(registry, state.snapshot, pin) + remaining = ( + state.snapshot.created_monotonic + hard_lifetime_seconds - registry._clock() + ) + if remaining <= 0: + consumer.release() + return None, "DIFF_SNAPSHOT_EXPIRED" + snapshot_deadline = ( + state.snapshot.created_monotonic + hard_lifetime_seconds + if deadline is None + else min(deadline, state.snapshot.created_monotonic + hard_lifetime_seconds) + ) + if registry._clock() >= snapshot_deadline: + consumer.release() + return None, "DIFF_SNAPSHOT_EXPIRED" + try: + generation, current_identity = oracle_generation( + identity.realpath, + consumer.snapshot.mode, + deadline=snapshot_deadline, + ) + shared_generation = shared_source_generation( + identity.realpath, snapshot_deadline + ) + generation_after, identity_after = oracle_generation( + identity.realpath, + consumer.snapshot.mode, + deadline=snapshot_deadline, + ) + except SourceOracleError as exc: + consumer.release() + return None, str(exc) + except BaseException: + consumer.release() + raise + with registry._lock: + registry._sweep() + current = registry._states.get(snapshot_id) + remaining = ( + state.snapshot.created_monotonic + hard_lifetime_seconds - registry._clock() + ) + if ( + current is not state + or state.expired + or not state.lease_open + or state.pins.get(pin) != owner + or remaining <= 0 + ): + consumer.release() + return None, "DIFF_SNAPSHOT_EXPIRED" + if ( + current_identity != identity + or identity_after != identity + or generation != generation_after + or generation != consumer.snapshot.git_generation + or shared_generation != consumer.snapshot.source_generation + ): + consumer.release() + return None, "DIFF_SNAPSHOT_SOURCE_CHANGED" + return consumer, None + + +def bind_assessed_scope( + registry: Any, + consumer: SnapshotConsumer, + paths: list[str], + *, + scope_limits: tuple[int, int, int], + max_materialized_bytes: int, + hard_lifetime_seconds: float, + normalize_paths: Callable[..., tuple[str, ...]] = normalize_bounded_paths, +) -> str | None: + try: + normalized = normalize_paths( + paths, + count_limit=scope_limits[0], + path_limit=scope_limits[1], + storage_limit=scope_limits[2], + ) + except SourceOracleError as exc: + return str(exc) + with registry._lock: + registry._sweep() + snapshot = consumer._snapshot + state = registry._states.get(snapshot.snapshot_id) if snapshot else None + if ( + state is None + or consumer._released + or state.pins.get(consumer._pin) != consumer._owner + ): + return "DIFF_SNAPSHOT_EXPIRED" + if threading.get_ident() != consumer._owner: + return "DIFF_SNAPSHOT_WRONG_THREAD" + if len(state.pins) != 1: + return "DIFF_SNAPSHOT_IN_USE" + if ( + state.expired + or not state.lease_open + or registry._clock() - state.snapshot.created_monotonic + >= hard_lifetime_seconds + ): + state.expired = True + state.lease_open = False + return "DIFF_SNAPSHOT_EXPIRED" + old_paths_size = path_collection_storage(state.snapshot.assessed_scope_paths) + delta = path_collection_storage(normalized) - old_paths_size + if ( + registry._charged_bytes + sum(registry._reservations.values()) + delta + > max_materialized_bytes + ): + return "DIFF_SNAPSHOT_CAPACITY" + updated = replace( + state.snapshot, + assessed_scope_paths=normalized, + _assessed_scope_raw_paths=tuple(path_to_raw(path) for path in normalized), + materialized_bytes=state.snapshot.materialized_bytes + delta, + ) + state.snapshot = updated + consumer._snapshot = updated + registry._charged_bytes += delta + return None + + +def validate_publish( + registry: Any, + consumer: SnapshotConsumer, + publish_guard: Callable[[], str | None] | None = None, + *, + oracle_generation: Callable[..., tuple[str, RootIdentity]], + shared_source_generation: Callable[[str, float], str], + hard_lifetime_seconds: float, + deadline: float | None = None, +) -> str | None: + """Revalidate the snapshot and an optional response guard in one publish window.""" + with registry._lock: + registry._sweep() + snapshot = consumer._snapshot + state = registry._states.get(snapshot.snapshot_id) if snapshot else None + remaining = ( + hard_lifetime_seconds + - (registry._clock() - state.snapshot.created_monotonic) + if state is not None + else 0.0 + ) + if state is None or state.expired or not state.lease_open or remaining <= 0: + if state is not None: + state.expired = True + state.lease_open = False + return "DIFF_SNAPSHOT_EXPIRED" + assert snapshot is not None + if threading.get_ident() != consumer._owner: + return "DIFF_SNAPSHOT_WRONG_THREAD" + snapshot_deadline = ( + snapshot.created_monotonic + hard_lifetime_seconds + if deadline is None + else min(deadline, snapshot.created_monotonic + hard_lifetime_seconds) + ) + if registry._clock() >= snapshot_deadline: + return "DIFF_SNAPSHOT_EXPIRED" + try: + oracle_params = inspect.signature(oracle_generation).parameters + if "deadline" in oracle_params: + generation, identity = oracle_generation( + snapshot.root_identity.realpath, + snapshot.mode, + deadline=snapshot_deadline, + ) + else: # compatibility for injected platform seams + generation, identity = oracle_generation( + snapshot.root_identity.realpath, snapshot.mode + ) + shared_generation = shared_source_generation( + snapshot.root_identity.realpath, snapshot_deadline + ) + guard_error = publish_guard() if publish_guard is not None else None + shared_generation_after = shared_source_generation( + snapshot.root_identity.realpath, snapshot_deadline + ) + if "deadline" in oracle_params: + generation_after, identity_after = oracle_generation( + snapshot.root_identity.realpath, + snapshot.mode, + deadline=snapshot_deadline, + ) + else: + generation_after, identity_after = oracle_generation( + snapshot.root_identity.realpath, snapshot.mode + ) + except SourceOracleError as exc: + return str(exc) + with registry._lock: + state = registry._states.get(snapshot.snapshot_id) + if ( + state is None + or consumer._released + or state.pins.get(consumer._pin) != consumer._owner + ): + return "DIFF_SNAPSHOT_EXPIRED" + if ( + state.expired + or not state.lease_open + or registry._clock() - state.snapshot.created_monotonic + >= hard_lifetime_seconds + ): + state.expired = True + state.lease_open = False + return "DIFF_SNAPSHOT_EXPIRED" + if ( + state.snapshot.root_identity != snapshot.root_identity + or identity != state.snapshot.root_identity + or identity_after != state.snapshot.root_identity + ): + return "DIFF_SNAPSHOT_ROOT_MISMATCH" + if generation != generation_after: + return "DIFF_SNAPSHOT_SOURCE_CHANGED" + if ( + state.snapshot.source_generation != snapshot.source_generation + or generation != state.snapshot.git_generation + or shared_generation != state.snapshot.source_generation + or shared_generation_after != shared_generation + ): + return "DIFF_SNAPSHOT_SOURCE_CHANGED" + if guard_error is not None: + return guard_error + return None diff --git a/tree_sitter_analyzer/frozen_git_index.py b/tree_sitter_analyzer/frozen_git_index.py index 213e6b620..a7e69507f 100644 --- a/tree_sitter_analyzer/frozen_git_index.py +++ b/tree_sitter_analyzer/frozen_git_index.py @@ -137,7 +137,11 @@ def reconstructed_index_file( def invalidate_index_stat_cache( - index_bytes: bytes, *, object_format: str, assume_valid: bool = False + index_bytes: bytes, + *, + object_format: str, + assume_valid: bool = False, + clear_hints: bool = False, ) -> bytes: """Derive an index with forced checks, or frozen assume-valid entries.""" hash_size = 32 if object_format == "sha256" else 20 @@ -154,6 +158,7 @@ def invalidate_index_stat_cache( flags = int.from_bytes( index_bytes[offset + flags_offset : offset + flags_offset + 2], "big" ) + extended_offset = offset + flags_offset + 2 if assume_valid: result[offset + flags_offset : offset + flags_offset + 2] = ( flags | 0x8000 @@ -161,6 +166,19 @@ def invalidate_index_stat_cache( else: result[offset : offset + 24] = b"\0" * 24 result[offset + 28 : offset + 40] = b"\0" * 12 + if clear_hints: + # This comparison asks about bytes, so advisory index bits must + # not suppress examination of the worktree. + result[offset + flags_offset : offset + flags_offset + 2] = ( + flags & ~0x8000 + ).to_bytes(2, "big") + if flags & 0x4000: + extended = int.from_bytes( + index_bytes[extended_offset : extended_offset + 2], "big" + ) + result[extended_offset : extended_offset + 2] = ( + extended & ~0x4000 + ).to_bytes(2, "big") offset += flags_offset + 2 + (2 if flags & 0x4000 else 0) if version == 4: while offset < content_end and index_bytes[offset] & 0x80: @@ -185,13 +203,16 @@ def frozen_index_output( deadline: float, limit: int, refresh: bool = False, + clear_hints: bool = False, object_format: str = "sha1", input_: bytes | None = None, extra_env: dict[str, str] | None = None, ) -> bytes: """Run Git against an external mode-0600 byte-for-byte index snapshot.""" materialized = ( - invalidate_index_stat_cache(index_bytes, object_format=object_format) + invalidate_index_stat_cache( + index_bytes, object_format=object_format, clear_hints=clear_hints + ) if refresh and index_bytes else index_bytes ) diff --git a/tree_sitter_analyzer/index_snapshot.py b/tree_sitter_analyzer/index_snapshot.py index 0f8353e6d..4fc0ba8a1 100644 --- a/tree_sitter_analyzer/index_snapshot.py +++ b/tree_sitter_analyzer/index_snapshot.py @@ -10,7 +10,7 @@ import time from collections.abc import Iterator from contextlib import contextmanager -from typing import Any, cast +from typing import Any from urllib.parse import quote from .index_snapshot_capability import ( @@ -34,12 +34,9 @@ from .index_snapshot_capability import ( require_memory_temp_store as _require_memory_temp_store, ) +from .index_snapshot_manifest import _read_bounded_manifest_impl from .index_snapshot_registry import IndexSnapshot, IndexSnapshotRegistry -from .index_snapshot_schema import ( - _deadline_ordered_rows, - index_fingerprint, - validate_snapshot_schema, -) +from .index_snapshot_schema import index_fingerprint, validate_snapshot_schema from .index_snapshot_schema import ( stamp_full_index_manifest as stamp_full_index_manifest, ) @@ -126,101 +123,20 @@ def _index_fingerprint_with_deadline( def _read_bounded_manifest( connection: sqlite3.Connection, deadline: float ) -> sqlite3.Row | None: - """Preflight manifest cell sizes inside SQLite before decoding values.""" - columns = ( - "canonical_root", - "source_fingerprint", - "index_fingerprint", - "file_count", - "source_scope_descriptor", - "manifest_version", - ) - valid_singleton = "typeof(singleton) = 'integer' AND singleton = 1" - count_rows = _deadline_ordered_rows( + """Read a manifest with owner-module budgets and monkeypatch seams.""" + return _read_bounded_manifest_impl( connection, - "SELECT COUNT(*), " - f"CASE WHEN COUNT(CASE WHEN {valid_singleton} THEN 1 END) = COUNT(*) " - "THEN 1 ELSE 0 END, " - f"CASE WHEN COUNT(CASE WHEN {valid_singleton} THEN 1 END) = 1 " - "THEN 1 ELSE 0 END " - "FROM ast_index_snapshot_manifest", deadline, - ) - count_row = next(count_rows, None) - if ( - count_row is None - or len(count_row) != 3 - or not isinstance(count_row[0], int) - or next(count_rows, None) is not None - ): - raise ValueError("INDEX_MANIFEST_INVALID") - if count_row[0] == 0: - return None - if ( - count_row[0] != 1 - or type(count_row[1]) is not int - or count_row[1] != 1 - or type(count_row[2]) is not int - or count_row[2] != 1 - ): - raise ValueError("INDEX_MANIFEST_INVALID") - - length_query = ( - "SELECT " - + ", ".join(f"length(CAST({column} AS BLOB))" for column in columns) - + " FROM ast_index_snapshot_manifest WHERE singleton=1" - ) - length_rows = _deadline_ordered_rows(connection, length_query, deadline) - first_lengths = next(length_rows, None) - if first_lengths is None or next(length_rows, None) is not None: - raise ValueError("INDEX_MANIFEST_INVALID") - lengths = tuple(0 if value is None else int(value) for value in first_lengths) - per_cell = ( - _MANIFEST_TEXT_BYTE_BUDGET, - _MANIFEST_TEXT_BYTE_BUDGET, - _MANIFEST_TEXT_BYTE_BUDGET, - _MANIFEST_TEXT_BYTE_BUDGET, - _MANIFEST_SCOPE_BYTE_BUDGET, - _MANIFEST_TEXT_BYTE_BUDGET, - ) - if any( - length < 0 or length > budget - for length, budget in zip(lengths, per_cell, strict=True) - ): - raise ValueError("INDEX_MANIFEST_INVALID") - if sum(lengths) > _MANIFEST_TOTAL_BYTE_BUDGET: - raise ValueError("INDEX_MANIFEST_INVALID") - query = ( - "SELECT " - + ", ".join(columns) - + (" FROM ast_index_snapshot_manifest WHERE singleton=1") + clock=_clock, + require_budget=_require_capture_budget, + text_byte_budget=_MANIFEST_TEXT_BYTE_BUDGET, + scope_byte_budget=_MANIFEST_SCOPE_BYTE_BUDGET, + total_byte_budget=_MANIFEST_TOTAL_BYTE_BUDGET, ) - def expired() -> int: - return int(_clock() >= deadline) - - connection.set_progress_handler(expired, 1_000) - try: - _require_capture_budget(deadline) - cursor = connection.execute(query) - fetchone = getattr(cursor, "fetchone", None) - if callable(fetchone): - manifest = fetchone() - duplicate = fetchone() - else: - rows = iter(cursor) - manifest = next(rows, None) - duplicate = next(rows, None) - _require_capture_budget(deadline) - finally: - connection.set_progress_handler(None, 0) - if manifest is None or duplicate is not None: - raise ValueError("INDEX_MANIFEST_INVALID") - return cast(sqlite3.Row, manifest) - def _capture_existing_snapshot( - project_root: str, *, pin: bool = False + project_root: str, *, pin: bool = False, deadline: float | None = None ) -> IndexSnapshot: # Absence is platform-independent and publishes no file evidence. Report it # before the secure-fd capability gate so fresh Windows installs preserve the @@ -236,7 +152,7 @@ def _capture_existing_snapshot( handles: tuple[int, int, int] | None = None connection: sqlite3.Connection | None = None evidence: sqlite3.Connection | None = None - deadline = _clock() + _CAPTURE_DEADLINE_SECONDS + deadline = _clock() + _CAPTURE_DEADLINE_SECONDS if deadline is None else deadline if not _CAPTURE_LOCK.acquire(timeout=max(0.0, deadline - _clock())): return _unknown("INDEX_SNAPSHOT_DEADLINE") try: @@ -428,6 +344,7 @@ def progress(_status: int, remaining: int, total: int) -> None: count, _physical_storage_identity(evidence), projection_exact, + source_scope, ) _require_capture_budget(deadline) if not _hierarchy_matches_pinned_database(root, root_fd, cache_fd, db_fd): @@ -479,9 +396,11 @@ def read_existing_snapshot(project_root: str) -> IndexSnapshot: @contextmanager -def lease_existing_snapshot(project_root: str) -> Iterator[IndexSnapshot]: +def lease_existing_snapshot( + project_root: str, *, deadline: float | None = None +) -> Iterator[IndexSnapshot]: """Keep a successfully published capability pinned until response assembly.""" - snapshot = _capture_existing_snapshot(project_root, pin=True) + snapshot = _capture_existing_snapshot(project_root, pin=True, deadline=deadline) try: yield snapshot finally: @@ -489,6 +408,32 @@ def lease_existing_snapshot(project_root: str) -> Iterator[IndexSnapshot]: REGISTRY.release_pin(snapshot.snapshot_id) +@contextmanager +def lease_reusable_snapshot( + project_root: str, *, deadline: float | None = None +) -> Iterator[IndexSnapshot | None]: + """Pin a capability only while its source generation remains current.""" + with REGISTRY.pin_reusable(project_root) as snapshot: + if ( + snapshot is None + or snapshot.source_scope is None + or snapshot.source_generation is None + ): + yield None + return + current = capture_current_source_snapshot( + project_root, + snapshot.source_scope, + deadline=( + _clock() + _CAPTURE_DEADLINE_SECONDS if deadline is None else deadline + ), + ) + if current.state != "exact" or current.generation != snapshot.source_generation: + yield None + return + yield snapshot + + def run_graph_snapshot_read( snapshot_id: str, project_root: str, source_generation: str | None, reader: Any ) -> dict[str, Any]: @@ -535,9 +480,16 @@ def read_snapshot_stats( def acquire_index_snapshot( - snapshot_id: str, project_root: str, source_generation: str | None = None + snapshot_id: str, + project_root: str, + source_generation: str | None = None, + *, + deadline: float | None = None, ) -> Any: - return REGISTRY.acquire(snapshot_id, project_root, source_generation) + """Acquire the registry-owned private copy, optionally with an absolute deadline.""" + return REGISTRY.acquire( + snapshot_id, project_root, source_generation, deadline=deadline + ) def _unknown(reason: str) -> IndexSnapshot: diff --git a/tree_sitter_analyzer/index_snapshot_manifest.py b/tree_sitter_analyzer/index_snapshot_manifest.py index b0ad902e2..74408ed8a 100644 --- a/tree_sitter_analyzer/index_snapshot_manifest.py +++ b/tree_sitter_analyzer/index_snapshot_manifest.py @@ -1,5 +1,124 @@ """Bounded manifest reader boundary for authoritative snapshots.""" -from .index_snapshot import _read_bounded_manifest +from __future__ import annotations + +import sqlite3 +from collections.abc import Callable +from typing import cast + +from .index_snapshot_schema import _deadline_ordered_rows + + +def _read_bounded_manifest_impl( + connection: sqlite3.Connection, + deadline: float, + *, + clock: Callable[[], float], + require_budget: Callable[[float], None], + text_byte_budget: int, + scope_byte_budget: int, + total_byte_budget: int, +) -> sqlite3.Row | None: + """Preflight manifest cell sizes inside SQLite before decoding values.""" + columns = ( + "canonical_root", + "source_fingerprint", + "index_fingerprint", + "file_count", + "source_scope_descriptor", + "manifest_version", + ) + valid_singleton = "typeof(singleton) = 'integer' AND singleton = 1" + count_rows = _deadline_ordered_rows( + connection, + "SELECT COUNT(*), " + f"CASE WHEN COUNT(CASE WHEN {valid_singleton} THEN 1 END) = COUNT(*) " + "THEN 1 ELSE 0 END, " + f"CASE WHEN COUNT(CASE WHEN {valid_singleton} THEN 1 END) = 1 " + "THEN 1 ELSE 0 END " + "FROM ast_index_snapshot_manifest", + deadline, + ) + count_row = next(count_rows, None) + if ( + count_row is None + or len(count_row) != 3 + or not isinstance(count_row[0], int) + or next(count_rows, None) is not None + ): + raise ValueError("INDEX_MANIFEST_INVALID") + if count_row[0] == 0: + return None + if ( + count_row[0] != 1 + or type(count_row[1]) is not int + or count_row[1] != 1 + or type(count_row[2]) is not int + or count_row[2] != 1 + ): + raise ValueError("INDEX_MANIFEST_INVALID") + + length_query = ( + "SELECT " + + ", ".join(f"length(CAST({column} AS BLOB))" for column in columns) + + " FROM ast_index_snapshot_manifest WHERE singleton=1" + ) + length_rows = _deadline_ordered_rows(connection, length_query, deadline) + first_lengths = next(length_rows, None) + if first_lengths is None or next(length_rows, None) is not None: + raise ValueError("INDEX_MANIFEST_INVALID") + lengths = tuple(0 if value is None else int(value) for value in first_lengths) + per_cell = ( + text_byte_budget, + text_byte_budget, + text_byte_budget, + text_byte_budget, + scope_byte_budget, + text_byte_budget, + ) + if any( + length < 0 or length > budget + for length, budget in zip(lengths, per_cell, strict=True) + ): + raise ValueError("INDEX_MANIFEST_INVALID") + if sum(lengths) > total_byte_budget: + raise ValueError("INDEX_MANIFEST_INVALID") + query = ( + "SELECT " + + ", ".join(columns) + + " FROM ast_index_snapshot_manifest WHERE singleton=1" + ) + + def expired() -> int: + return int(clock() >= deadline) + + connection.set_progress_handler(expired, 1_000) + try: + require_budget(deadline) + cursor = connection.execute(query) + fetchone = getattr(cursor, "fetchone", None) + if callable(fetchone): + manifest = fetchone() + duplicate = fetchone() + else: + rows = iter(cursor) + manifest = next(rows, None) + duplicate = next(rows, None) + require_budget(deadline) + finally: + connection.set_progress_handler(None, 0) + if manifest is None or duplicate is not None: + raise ValueError("INDEX_MANIFEST_INVALID") + return cast(sqlite3.Row, manifest) + + +def _read_bounded_manifest( + connection: sqlite3.Connection, deadline: float +) -> sqlite3.Row | None: + """Preserve the established import while honoring owner-module seams.""" + from . import index_snapshot + + return index_snapshot._read_bounded_manifest(connection, deadline) + __all__ = ["_read_bounded_manifest"] diff --git a/tree_sitter_analyzer/index_snapshot_registry.py b/tree_sitter_analyzer/index_snapshot_registry.py index 13b4a5f33..ccae56734 100644 --- a/tree_sitter_analyzer/index_snapshot_registry.py +++ b/tree_sitter_analyzer/index_snapshot_registry.py @@ -11,6 +11,8 @@ from dataclasses import dataclass, field from typing import Any, Literal, cast +from .index_snapshot_registry_capabilities import acquire_io_lock, newest_reusable + def ensure_capacity( entries: dict[str, Any], @@ -53,6 +55,7 @@ def reuse_snapshot( and existing.reason == snapshot.reason and existing.file_count == snapshot.file_count and existing.symbol_projection_exact == snapshot.symbol_projection_exact + and existing.source_scope == snapshot.source_scope ) if not same_logical_identity: continue @@ -93,6 +96,7 @@ class IndexSnapshot: file_count: int physical_storage_identity: tuple[int, int, int, int, int, int] | None = None symbol_projection_exact: bool | None = None + source_scope: Any | None = None @dataclass(slots=True) @@ -179,6 +183,7 @@ def publish( snapshot.file_count, snapshot.physical_storage_identity, snapshot.symbol_projection_exact, + snapshot.source_scope, ) self._entries[snapshot_id] = _Entry( published, @@ -198,13 +203,38 @@ def release_pin(self, snapshot_id: str) -> None: entry.readers -= 1 self._purge(self._clock()) + @contextmanager + def pin_reusable(self, project_root: str) -> Iterator[IndexSnapshot | None]: + """Pin the newest live capability for ``project_root`` without recopying it.""" + canonical_root = os.path.realpath(os.path.abspath(project_root)) + with self._lock: + now = self._clock() + self._purge(now) + entry = newest_reusable(self._entries, canonical_root, now) + if entry is not None: + entry.readers += 1 + try: + yield entry.snapshot if entry is not None else None + finally: + if entry is not None: + with self._lock: + entry.readers -= 1 + self._purge(self._clock()) + @contextmanager def acquire( - self, snapshot_id: str, project_root: str, source_generation: str | None = None + self, + snapshot_id: str, + project_root: str, + source_generation: str | None = None, + *, + deadline: float | None = None, ) -> Iterator[tuple[IndexSnapshot, sqlite3.Connection]]: canonical_root = os.path.realpath(os.path.abspath(project_root)) with self._lock: now = self._clock() + if deadline is not None and now >= deadline: + raise RuntimeError("INDEX_SNAPSHOT_DEADLINE") self._purge(now) entry = self._entries.get(snapshot_id) if entry is None or entry.expires_at <= now: @@ -217,11 +247,13 @@ def acquire( ): raise ValueError("SOURCE_GENERATION_MISMATCH") entry.readers += 1 - entry.io_lock.acquire() + acquired = False try: + acquired = acquire_io_lock(entry.io_lock, deadline, self._clock) yield entry.snapshot, entry.connection finally: - entry.io_lock.release() + if acquired: + entry.io_lock.release() with self._lock: entry.readers -= 1 self._purge(self._clock()) diff --git a/tree_sitter_analyzer/index_snapshot_registry_capabilities.py b/tree_sitter_analyzer/index_snapshot_registry_capabilities.py new file mode 100644 index 000000000..de8551d31 --- /dev/null +++ b/tree_sitter_analyzer/index_snapshot_registry_capabilities.py @@ -0,0 +1,34 @@ +"""Reusable selection and deadline-aware locking for index capabilities.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +def newest_reusable( + entries: dict[str, Any], canonical_root: str, now: float +) -> Any | None: + """Select the newest unexpired capability for one canonical project root.""" + candidates = [ + entry + for entry in entries.values() + if entry.snapshot.canonical_root == canonical_root and entry.expires_at > now + ] + return max(candidates, key=lambda item: item.expires_at, default=None) + + +def acquire_io_lock( + lock: Any, deadline: float | None, clock: Callable[[], float] +) -> bool: + """Acquire one snapshot I/O lock without crossing an optional deadline.""" + if deadline is None: + lock.acquire() + return True + acquired = lock.acquire(timeout=max(0.0, deadline - clock())) + if not acquired: + raise RuntimeError("INDEX_SNAPSHOT_DEADLINE") + if clock() >= deadline: + lock.release() + raise RuntimeError("INDEX_SNAPSHOT_DEADLINE") + return True diff --git a/tree_sitter_analyzer/index_snapshot_schema.py b/tree_sitter_analyzer/index_snapshot_schema.py index c1b3861da..16f52a3c7 100644 --- a/tree_sitter_analyzer/index_snapshot_schema.py +++ b/tree_sitter_analyzer/index_snapshot_schema.py @@ -135,8 +135,6 @@ def stamp_full_index_manifest( source_scope: SourceScopeDescriptor | None = None, ) -> None: """Certify one source/index epoch while excluding every SQLite writer.""" - if os.name != "posix" or not os.path.exists("/dev/fd"): - raise sqlite3.OperationalError("SOURCE_SCOPE_UNSUPPORTED") # Finish all preceding indexing work before the certification transaction. conn.commit() transaction_started = False @@ -151,7 +149,14 @@ def stamp_full_index_manifest( source = source_fingerprint(conn, root) index = index_fingerprint(conn, root) recorded = recorded_source_rows(conn) - current = capture_current_source_snapshot(root, scope) + if os.name == "posix" and os.path.exists("/dev/fd"): + current = capture_current_source_snapshot(root, scope) + else: + from .portable_source_snapshot import capture_portable_source_snapshot + + current = capture_portable_source_snapshot( + root, scope, deadline=time.monotonic() + _FINGERPRINT_DEADLINE_SECONDS + ) if current.state != "exact" or current.rows != recorded: raise sqlite3.OperationalError("SOURCE_CHANGED") conn.execute("DELETE FROM ast_index_snapshot_manifest") diff --git a/tree_sitter_analyzer/mcp/tools/ast_diff_tool.py b/tree_sitter_analyzer/mcp/tools/ast_diff_tool.py index b2067f6ae..1f1727bf8 100644 --- a/tree_sitter_analyzer/mcp/tools/ast_diff_tool.py +++ b/tree_sitter_analyzer/mcp/tools/ast_diff_tool.py @@ -12,6 +12,8 @@ from typing import Any from ...ast_diff import ASTDiffer +from ...ast_diff_node_budget import apply_node_body_budget +from ...ast_diff_snapshot_consumers import decode_snapshot_sources from ...git_path_codec import path_to_wire from ...utils import setup_logger from ..utils.format_helper import ( @@ -281,37 +283,8 @@ async def execute(self, arguments: dict[str, Any]) -> dict[str, Any]: }, output_format, ) - if ( - not getattr( - frozen.record, "old_available", frozen.old_bytes is not None - ) - or not getattr( - frozen.record, "new_available", frozen.new_bytes is not None - ) - or getattr(frozen.record, "status", None) in ("R", "C") - or getattr(frozen.record, "unsupported_kind", None) is not None - or frozen.record.binary - or any( - kind not in ("file", "missing") - for kind in ( - getattr(frozen.record, "old_kind", "file"), - getattr(frozen.record, "new_kind", "file"), - ) - ) - ): - return apply_toon_format_to_response( - { - "success": False, - "verdict": "ERROR", - "error_code": "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT", - "error": "DIFF_SNAPSHOT_UNSUPPORTED_CONTENT", - }, - output_format, - ) - try: - old_source = (frozen.old_bytes or b"").decode("utf-8", "strict") - new_source = (frozen.new_bytes or b"").decode("utf-8", "strict") - except UnicodeDecodeError: + sources = decode_snapshot_sources(frozen) + if sources is None: return apply_toon_format_to_response( { "success": False, @@ -322,8 +295,8 @@ async def execute(self, arguments: dict[str, Any]) -> dict[str, Any]: output_format, ) result = differ.diff_strings( - old_source=old_source, - new_source=new_source, + old_source=sources.old_source, + new_source=sources.new_source, language=language, old_file=f"{snapshot_id}:old:{path_to_wire(frozen.record.path)}", new_file=f"{snapshot_id}:new:{path_to_wire(frozen.record.path)}", @@ -397,18 +370,7 @@ async def execute(self, arguments: dict[str, Any]) -> dict[str, Any]: # When the hunks serialise to more than NODE_BODIES_BUDGET bytes, # stop inlining children and set transparency flags. if include_node_bodies: - import json - - hunks_bytes = len(json.dumps(response.get("hunks", []))) - if hunks_bytes > NODE_BODIES_BUDGET: - # Rebuild without children and record how many bytes were saved - compact_dict = result.to_dict( - include_children=False, with_child_count=True - ) - compact_hunks_bytes = len(json.dumps(compact_dict.get("hunks", []))) - response["hunks"] = compact_dict["hunks"] - response["children_truncated"] = True - response["bytes_omitted"] = hunks_bytes - compact_hunks_bytes + apply_node_body_budget(response, result, NODE_BODIES_BUDGET) if consumer is not None: response["diff_snapshot_id"] = getattr( diff --git a/tree_sitter_analyzer/mcp/tools/change_impact_frozen.py b/tree_sitter_analyzer/mcp/tools/change_impact_frozen.py index 4e996bb24..8745240b0 100644 --- a/tree_sitter_analyzer/mcp/tools/change_impact_frozen.py +++ b/tree_sitter_analyzer/mcp/tools/change_impact_frozen.py @@ -148,9 +148,14 @@ def visible_sides(raw_path: bytes, raw_old_path: bytes | None) -> list[str]: scope_mode=scope_mode, ) result = apply_scope_validation(result, invalid_scope) - assessed = ( - sorted(set(changed_files).union(set(public_scope).difference(invalid_scope))) - if scope_mode == "strict" - else sorted(set(workspace_changed).union(public_scope)) + assessment_entries = scoped_entries if scope_mode == "strict" else visible_entries + assessment_paths = { + path_to_wire(raw_to_path(identity)) + for _record, raw_path, raw_old_path in assessment_entries + for identity in (raw_path, raw_old_path) + if identity is not None and not _raw_path_is_excluded(identity) + } + assessed = sorted( + assessment_paths.union(set(public_scope).difference(invalid_scope)) ) return result, records, changed_files, assessed diff --git a/tree_sitter_analyzer/mcp/tools/constraint_check_evaluation.py b/tree_sitter_analyzer/mcp/tools/constraint_check_evaluation.py new file mode 100644 index 000000000..190cfa9f4 --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_check_evaluation.py @@ -0,0 +1,112 @@ +"""Deadline- and capacity-bounded constraint connection evaluation.""" + +from __future__ import annotations + +import inspect +import sqlite3 +import time +from typing import Any + +from ...constraints import evaluate +from ...constraints.parser import _compile_glob +from ...git_path_codec import path_to_wire +from .constraint_check_live import path_is_in_scope as _path_is_in_scope + +_SEVERITY_ORDER = {"info": 0, "warn": 1, "error": 2} +_MAX_MATERIALIZED_VIOLATIONS = 10_000 + + +def evaluate_connection( + tool: Any, + conn: sqlite3.Connection, + constraints: list[Any], + *, + path_filter: str = "", + min_severity_rank: int, + scope_paths: frozenset[str] | None = None, + evaluator: Any = None, + deadline: float | None = None, + capacity: int = _MAX_MATERIALIZED_VIOLATIONS, +) -> tuple[list[dict[str, Any]], int]: + """Evaluate one caller-owned immutable index connection; fail closed.""" + evaluator = evaluate if evaluator is None else evaluator + absolute_deadline = time.monotonic() + 10.0 if deadline is None else deadline + + def interrupted() -> int: + return int(time.monotonic() >= absolute_deadline) + + def check_deadline() -> None: + if interrupted(): + raise RuntimeError("INDEX_SNAPSHOT_DEADLINE") + + check_deadline() + owns_transaction = not conn.in_transaction + conn.set_progress_handler(interrupted, 1_000) + if owns_transaction: + conn.execute("BEGIN") + try: + edge_count = tool._count_edges(conn, fail_closed=True) + evaluator_kwargs: dict[str, Any] = {} + evaluator_parameters = inspect.signature(evaluator).parameters + if "check_callback" in evaluator_parameters: + evaluator_kwargs["check_callback"] = check_deadline + if "capacity" in evaluator_parameters: + evaluator_kwargs["capacity"] = capacity + if scope_paths is not None: + + def in_scope(caller: str, callee: str) -> bool: + return _path_is_in_scope(caller, scope_paths) or _path_is_in_scope( + callee, scope_paths + ) + + evaluator_kwargs["scope_predicate"] = in_scope + violations = evaluator(constraints, conn, **evaluator_kwargs) + finally: + if owns_transaction: + conn.rollback() + conn.set_progress_handler(None, 0) + check_deadline() + path_re = _compile_glob(path_filter) if path_filter else None + rows: list[dict[str, Any]] = [] + for violation_number, violation in enumerate(violations, start=1): + check_deadline() + if violation_number > capacity: + raise RuntimeError("CONSTRAINT_EVALUATION_CAPACITY") + # Keep raw endpoints until the defensive scope check. Wire values + # beginning with git-path-b64: are escaped by path_to_wire(), so feeding + # an already-wired endpoint back into _path_is_in_scope() double-encodes it. + caller_raw = violation.caller_file + callee_raw = violation.callee_file + if scope_paths is not None and not ( + _path_is_in_scope(caller_raw, scope_paths) + or _path_is_in_scope(callee_raw, scope_paths) + ): + continue + caller = path_to_wire(caller_raw) + callee = path_to_wire(callee_raw) + if _SEVERITY_ORDER.get(violation.severity, 0) < min_severity_rank: + continue + if path_re is not None and path_re.fullmatch(caller) is None: + continue + rows.append( + { + "rule_id": violation.rule_id, + "caller_file": caller, + "caller_name": violation.caller_name, + "caller_line": violation.caller_line, + "callee_name": violation.callee_name, + "callee_file": callee, + "severity": violation.severity, + "detected_at": violation.detected_at, + } + ) + rows.sort( + key=lambda row: ( + -_SEVERITY_ORDER.get(str(row["severity"]), 0), + str(row["caller_file"]), + int(row["caller_line"]), + str(row["rule_id"]), + ) + ) + check_deadline() + return rows, edge_count diff --git a/tree_sitter_analyzer/mcp/tools/constraint_check_frozen.py b/tree_sitter_analyzer/mcp/tools/constraint_check_frozen.py new file mode 100644 index 000000000..924ca9fcf --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_check_frozen.py @@ -0,0 +1,295 @@ +"""Frozen RFC-0022 execution path for architectural constraint checks.""" + +from __future__ import annotations + +import fnmatch +import inspect +import os +import sqlite3 +from collections.abc import Callable +from typing import Any, cast + +from ... import source_oracle +from ...constants import EXCLUDE_DIRS +from ...constraints.parser import ConstraintParseError, load_constraints_bytes +from ...git_path_codec import path_to_wire +from ...index_source_scope import SourceScopeDescriptor +from ...languages.lang_extension_map import EXT_TO_LANG +from ...source_oracle import SourceOracleError +from ..utils.format_helper import apply_toon_format_to_response + +_CONFIG_CANDIDATES = ( + "architectural-constraints.yml", + ".tree-sitter-analyzer/constraints.yml", +) + + +def _config_publish_guard( + diff: Any, project_root: str, deadline: float +) -> Callable[[], str | None]: + """Build the final-publish guard for impact-owned configuration bytes.""" + + if diff.mode == "staged": + # Stage zero is the authoritative configuration plane. The registry's + # final staged generation check already protects the captured index; + # the worktree config is neither consumed nor authoritative, including + # when the captured config contains zero rules. + return lambda: None + + def validate() -> str | None: + rechecked = None + rechecked_name = None + for candidate in _CONFIG_CANDIDATES: + probe = source_oracle.safe_workspace_path( + diff.root_identity.realpath or project_root, + candidate, + deadline=deadline, + limit=1024 * 1024, + allow_directory=True, + ) + rechecked = probe + if probe.kind in {"missing", "directory"}: + continue + rechecked_name = candidate + break + selected = rechecked if rechecked_name is not None else None + rechecked_data = selected.data if selected is not None else None + rechecked_metadata = selected.metadata if selected is not None else () + if ( + rechecked is None + or diff.constraint_config_path != rechecked_name + or diff.constraint_config_data != rechecked_data + or diff.constraint_config_metadata != rechecked_metadata + ): + return "CONSTRAINT_CONFIG_CHANGED" + return None + + return validate + + +def _supported_scope_is_covered(paths: list[str], source_scope: object) -> bool: + """Return whether every graph-supported path is selected by the index scope.""" + if not isinstance(source_scope, SourceScopeDescriptor): + return False + # The evaluator selects an edge when either endpoint is changed. A scope + # that omits arbitrary roots or caller/callee candidates cannot certify the + # absence of an edge crossing into the changed endpoint. Default golden + # corpus exclusions are fixed by the discovery policy; caller-supplied + # exclusions and partial roots are not graph-authoritative here. + if source_scope.roots != (".",) or source_scope.exclude_patterns: + return False + for path in paths: + normalized = path.replace("\\", "/") if os.name == "nt" else path + if EXT_TO_LANG.get(os.path.splitext(normalized)[1].lower()) is None: + continue + if any( + fnmatch.fnmatch(normalized, pattern) + for pattern in source_scope.effective_excludes + ): + return False + path_parts = tuple(part for part in normalized.split("/") if part) + covered = False + for root in source_scope.roots: + root_parts = tuple( + part + for part in root.replace("\\", "/").split("/") + if part not in ("", ".") + ) + if path_parts[: len(root_parts)] != root_parts: + continue + descendants = path_parts[len(root_parts) : -1] + if any( + part in EXCLUDE_DIRS or part.startswith(".") for part in descendants + ): + continue + covered = True + break + if not covered: + return False + return True + + +def _snapshot_error( + tool: Any, code: str, output_format: str, detail: str | None = None +) -> dict[str, Any]: + return cast(dict[str, Any], tool._snapshot_error(code, output_format, detail)) + + +def execute_frozen(tool: Any, arguments: dict[str, Any]) -> dict[str, Any]: + """Evaluate one immutable diff/config/index capability without project writes.""" + from ...diff_snapshot_registry import HARD_LIFETIME_SECONDS + from ...diff_snapshot_registry import REGISTRY as DIFF_REGISTRY + + output_format = arguments.get("output_format", "json") + snapshot_id = str(arguments["diff_snapshot_id"]) + project_root = tool.project_root + if project_root is None: + return _snapshot_error(tool, "MISSING_PROJECT_ROOT", output_format) + acquire_deadline = None + acquire_kwargs = ( + {"deadline": acquire_deadline} + if "deadline" in inspect.signature(DIFF_REGISTRY.acquire).parameters + else {} + ) + consumer, error = DIFF_REGISTRY.acquire(snapshot_id, project_root, **acquire_kwargs) + if error: + return _snapshot_error(tool, error, output_format) + assert consumer is not None + try: + diff = consumer.snapshot + deadline = diff.created_monotonic + HARD_LIFETIME_SECONDS + raw_scope = list(diff.assessed_scope_paths) + frozen_scope = [path_to_wire(path) for path in raw_scope] + if arguments["scope_paths"] != frozen_scope: + return _snapshot_error(tool, "DIFF_SNAPSHOT_SCOPE_MISMATCH", output_format) + + config_error = getattr(diff, "constraint_config_error", None) + if config_error is not None: + return _snapshot_error(tool, config_error, output_format) + guard = _config_publish_guard(diff, project_root, deadline) + config_changed = any( + candidate in diff.assessed_scope_paths for candidate in _CONFIG_CANDIDATES + ) + evaluation_scope = None if config_changed else frozenset(frozen_scope) + config_name = diff.constraint_config_path + if config_name is None: + response: dict[str, Any] = { + "success": True, + "state": "not_applicable", + "reason": "NO_CONFIG", + "verdict": "INFO", + "violations": [], + "rule_count": 0, + "evaluated_edge_count": 0, + } + else: + try: + constraints = load_constraints_bytes( + diff.constraint_config_data or b"", config_name + ) + except ConstraintParseError as exc: + return _snapshot_error( + tool, "CONSTRAINT_CONFIG_INVALID", output_format, str(exc) + ) + if not constraints: + response = { + "success": True, + "state": "applicable", + "verdict": "SAFE", + "violations": [], + "rule_count": 0, + "evaluated_edge_count": 0, + } + elif diff.mode == "staged" and not ( + diff.staged_source_matches_worktree + and diff.staged_config_matches_worktree + ): + # Available index capabilities certify only the live source plane. + # A divergent stage-zero plane must never borrow that live graph. + return _snapshot_error( + tool, "CONSTRAINT_STAGED_INDEX_UNKNOWN", output_format + ) + else: + try: + from ...index_snapshot import ( + acquire_index_snapshot, + lease_existing_snapshot, + ) + + lease = lease_existing_snapshot( + project_root, + **( + {"deadline": deadline} + if "deadline" + in inspect.signature(lease_existing_snapshot).parameters + else {} + ), + ) + with lease as index: + if ( + index.snapshot_id is None + or index.completeness != "complete" + or index.source_generation != diff.source_generation + ): + return _snapshot_error( + tool, + index.reason or "SOURCE_GENERATION_MISMATCH", + output_format, + ) + if not _supported_scope_is_covered( + raw_scope, index.source_scope + ): + return _snapshot_error( + tool, "CONSTRAINT_INDEX_SCOPE_MISMATCH", output_format + ) + with acquire_index_snapshot( + index.snapshot_id, + project_root, + diff.source_generation, + **( + {"deadline": deadline} + if "deadline" + in inspect.signature(acquire_index_snapshot).parameters + else {} + ), + ) as (_, conn): + rows, edge_count = tool._evaluate_connection( + conn, + constraints, + min_severity_rank=tool.severity_rank( + arguments.get("severity_min", "warn") + ), + scope_paths=evaluation_scope, + deadline=deadline, + ) + response = { + "success": True, + "state": "applicable", + "verdict": tool._compute_verdict(rows), + "violations": rows, + "rule_count": len(constraints), + "evaluated_edge_count": edge_count, + "snapshot_id": index.snapshot_id, + "index_fingerprint": index.index_fingerprint, + } + except ( + sqlite3.DatabaseError, + ValueError, + TypeError, + AttributeError, + ) as exc: + return _snapshot_error( + tool, "CONSTRAINT_INDEX_UNKNOWN", output_format, str(exc) + ) + except (OSError, RuntimeError, SourceOracleError) as exc: + return _snapshot_error( + tool, "CONSTRAINT_CAPTURE_UNKNOWN", output_format, str(exc) + ) + + # The configuration guard runs inside the registry's final oracle + # before/after window, so no response is published from revalidated bytes + # followed by a separate, racy generation check. + error = DIFF_REGISTRY.validate_publish( + consumer, + guard, + **( + {"deadline": deadline} + if "deadline" + in inspect.signature(DIFF_REGISTRY.validate_publish).parameters + else {} + ), + ) + if error: + return _snapshot_error(tool, error, output_format) + response.update( + diff_snapshot_id=diff.snapshot_id, + source_generation=diff.source_generation, + assessed_scope_paths=frozen_scope, + ) + return apply_toon_format_to_response(response, output_format) + except (OSError, RuntimeError, SourceOracleError, sqlite3.DatabaseError) as exc: + return _snapshot_error( + tool, "CONSTRAINT_CAPTURE_UNKNOWN", output_format, str(exc) + ) + finally: + consumer.release() diff --git a/tree_sitter_analyzer/mcp/tools/constraint_check_live.py b/tree_sitter_analyzer/mcp/tools/constraint_check_live.py new file mode 100644 index 000000000..70032b230 --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_check_live.py @@ -0,0 +1,184 @@ +"""Live configuration and raw-path scope helpers for constraint checks.""" + +from __future__ import annotations + +import os +import stat +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from ...constraints.parser import load_constraints_bytes +from ...git_path_codec import path_to_wire +from ...source_oracle import SourceOracleError, safe_workspace_path + +_CONFIG_CANDIDATES = ( + "architectural-constraints.yml", + ".tree-sitter-analyzer/constraints.yml", +) + + +def _identity(info: os.stat_result) -> bytes: + values = ( + info.st_dev, + info.st_ino, + info.st_mode, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + getattr(info, "st_file_attributes", 0), + ) + return b",".join(str(value).encode("ascii") for value in values) + + +def _is_reparse(info: os.stat_result) -> bool: + attributes = int(getattr(info, "st_file_attributes", 0)) + reparse = int(getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + return stat.S_ISLNK(info.st_mode) or bool(reparse and attributes & reparse) + + +def _portable_probe( + project_root: str, candidate: str, deadline: float +) -> tuple[bytes | None, tuple[bytes, ...], str]: + """Read one bounded regular path while authenticating its complete chain.""" + current = Path(project_root) + metadata: list[bytes] = [] + chain: list[tuple[Path, bytes]] = [] + try: + for part in candidate.split("/")[:-1]: + try: + info = os.lstat(current) + except FileNotFoundError: + return None, tuple(metadata + [b"missing"]), "missing" + if not stat.S_ISDIR(info.st_mode) or _is_reparse(info): + raise SourceOracleError("CONSTRAINT_CONFIG_UNSAFE") + identity = _identity(info) + metadata.append(identity) + chain.append((current, identity)) + current /= part + try: + parent = os.lstat(current) + except FileNotFoundError: + return None, tuple(metadata + [b"missing"]), "missing" + if not stat.S_ISDIR(parent.st_mode) or _is_reparse(parent): + raise SourceOracleError("CONSTRAINT_CONFIG_UNSAFE") + parent_identity = _identity(parent) + metadata.append(parent_identity) + chain.append((current, parent_identity)) + leaf = current / candidate.rsplit("/", 1)[-1] + try: + before = os.lstat(leaf) + except FileNotFoundError: + return None, tuple(metadata + [b"missing"]), "missing" + metadata.append(_identity(before)) + if _is_reparse(before): + raise SourceOracleError("CONSTRAINT_CONFIG_UNSAFE") + if stat.S_ISDIR(before.st_mode): + return None, tuple(metadata), "directory" + if not stat.S_ISREG(before.st_mode): + raise SourceOracleError("CONSTRAINT_CONFIG_UNSAFE") + data = bytearray() + with leaf.open("rb", buffering=0) as stream: + opened = os.fstat(stream.fileno()) + if _identity(opened) != _identity(before): + raise SourceOracleError("CONSTRAINT_CONFIG_CHANGED") + while True: + if time.monotonic() >= deadline: + raise RuntimeError("CONSTRAINT_CONFIG_DEADLINE") + chunk = stream.read(min(64 * 1024, 1024 * 1024 - len(data) + 1)) + if not chunk: + break + data.extend(chunk) + if len(data) > 1024 * 1024: + raise SourceOracleError("CONSTRAINT_CONFIG_CAPACITY") + if _identity(os.fstat(stream.fileno())) != _identity(opened): + raise SourceOracleError("CONSTRAINT_CONFIG_CHANGED") + if _identity(os.lstat(leaf)) != _identity(opened): + raise SourceOracleError("CONSTRAINT_CONFIG_CHANGED") + if any(_identity(os.lstat(path)) != identity for path, identity in chain): + raise SourceOracleError("CONSTRAINT_CONFIG_CHANGED") + return bytes(data), tuple(metadata), "file" + except SourceOracleError: + raise + except OSError as exc: + raise SourceOracleError("CONSTRAINT_CONFIG_UNSAFE") from exc + + +def _portable_config_required() -> bool: + return os.name == "nt" + + +def live_config_snapshot( + project_root: str, deadline: float +) -> tuple[str | None, bytes | None, tuple[bytes, ...]]: + """Read discovery, bytes, and identity for one live constraints plane.""" + if _portable_config_required(): + for candidate in _CONFIG_CANDIDATES: + data, metadata, kind = _portable_probe(project_root, candidate, deadline) + if kind in {"missing", "directory"}: + continue + return candidate, data, metadata + return None, None, () + for candidate in _CONFIG_CANDIDATES: + probe = safe_workspace_path( + project_root, + candidate, + deadline=deadline, + limit=1024 * 1024, + allow_directory=True, + ) + if probe.kind in {"missing", "directory"}: + continue + if probe.kind != "file" or probe.data is None: + raise SourceOracleError("CONSTRAINT_CONFIG_UNSAFE") + return candidate, probe.data, probe.metadata + return None, None, () + + +def load_live_constraints( + project_root: str, deadline: float +) -> tuple[ + tuple[str | None, bytes | None, tuple[bytes, ...]], + list[Any], +]: + """Parse constraints from the same bounded bytes retained for revalidation.""" + snapshot = live_config_snapshot(project_root, deadline) + config_path, config_data, _metadata = snapshot + constraints = ( + load_constraints_bytes(config_data or b"", config_path or "") + if config_path is not None + else [] + ) + return snapshot, constraints + + +def config_changed_response( + project_root: str, + before: tuple[str | None, bytes | None, tuple[bytes, ...]], + deadline: float, + output_format: str, + error_response: Callable[[str, str, str | None], dict[str, Any]], + snapshot: Callable[ + [str, float], tuple[str | None, bytes | None, tuple[bytes, ...]] + ] = live_config_snapshot, +) -> dict[str, Any] | None: + """Fail closed if a read-only verdict no longer uses the live rules plane.""" + try: + if snapshot(project_root, deadline) != before: + return error_response("CONSTRAINT_CONFIG_CHANGED", output_format, None) + except (OSError, RuntimeError, SourceOracleError) as exc: + return error_response("CONSTRAINT_CONFIG_UNKNOWN", output_format, str(exc)) + return None + + +def path_is_in_scope(path: str, scope_paths: frozenset[str]) -> bool: + """Match a raw repository path against exact wire scopes or descendants.""" + wire_path = path_to_wire(path).rstrip("/") + for scope in scope_paths: + normalized = scope.rstrip("/") + if normalized in {"", "."}: + return True + if wire_path == normalized or wire_path.startswith(normalized + "/"): + return True + return False diff --git a/tree_sitter_analyzer/mcp/tools/constraint_check_persistence.py b/tree_sitter_analyzer/mcp/tools/constraint_check_persistence.py new file mode 100644 index 000000000..b97575f61 --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_check_persistence.py @@ -0,0 +1,63 @@ +"""Persistent constraint-violation row readers.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from typing import Any + +from ...constraints.parser import _compile_glob + + +def read_filtered_violations( + db_path: Path, + *, + path_filter: str, + min_severity_rank: int, + severity_order: dict[str, int], + ddl: str, +) -> list[dict[str, Any]]: + """Read persisted violations with canonical Python glob filtering.""" + conn = sqlite3.connect(str(db_path)) + try: + conn.execute(ddl) + cursor = conn.execute( + """ + SELECT rule_id, caller_file, caller_name, caller_line, + callee_name, callee_file, severity, detected_at + FROM ast_constraint_violations + ORDER BY severity DESC, caller_file, caller_line + """ + ) + path_re = _compile_glob(path_filter) if path_filter else None + results: list[dict[str, Any]] = [] + for row in cursor: + ( + rule_id, + caller_file, + caller_name, + caller_line, + callee_name, + callee_file, + severity, + detected_at, + ) = row + if severity_order.get(severity, 0) < min_severity_rank: + continue + if path_re is not None and path_re.fullmatch(caller_file) is None: + continue + results.append( + { + "rule_id": rule_id, + "caller_file": caller_file, + "caller_name": caller_name, + "caller_line": caller_line, + "callee_name": callee_name, + "callee_file": callee_file, + "severity": severity, + "detected_at": detected_at, + } + ) + return results + finally: + conn.close() diff --git a/tree_sitter_analyzer/mcp/tools/constraint_check_portable_snapshot.py b/tree_sitter_analyzer/mcp/tools/constraint_check_portable_snapshot.py new file mode 100644 index 000000000..5fe74a394 --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_check_portable_snapshot.py @@ -0,0 +1,10 @@ +"""Platform policy for portable read-only constraint snapshots.""" + +from __future__ import annotations + +import os + + +def portable_snapshot_required() -> bool: + """Return whether descriptor-path snapshot authority is unavailable.""" + return os.name != "posix" or not os.path.exists("/dev/fd") diff --git a/tree_sitter_analyzer/mcp/tools/constraint_check_read_only.py b/tree_sitter_analyzer/mcp/tools/constraint_check_read_only.py new file mode 100644 index 000000000..306077b88 --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_check_read_only.py @@ -0,0 +1,36 @@ +"""Certified ordinary read-only execution for architectural constraints.""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + + +def run_read_only( + tool: Any, + db_path: Path, + constraints: list[Any], + *, + path_filter: str, + min_severity_rank: int, + scope_paths: frozenset[str] | None = None, + evaluator: Any = None, + deadline: float | None = None, +) -> tuple[list[dict[str, Any]], int]: + """Evaluate one certified private snapshot without project writes.""" + from .constraint_index_snapshot import evaluate_ordinary_snapshot + + del db_path + absolute_deadline = time.monotonic() + 10.0 if deadline is None else deadline + if time.monotonic() >= absolute_deadline: + raise RuntimeError("INDEX_SNAPSHOT_DEADLINE") + return evaluate_ordinary_snapshot( + tool, + constraints, + path_filter=path_filter, + min_severity_rank=min_severity_rank, + scope_paths=scope_paths, + evaluator=evaluator, + deadline=absolute_deadline, + ) diff --git a/tree_sitter_analyzer/mcp/tools/constraint_check_schema.py b/tree_sitter_analyzer/mcp/tools/constraint_check_schema.py new file mode 100644 index 000000000..3176482b5 --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_check_schema.py @@ -0,0 +1,55 @@ +"""Input schema for the architectural constraint MCP tool.""" + +from __future__ import annotations + +from typing import Any + +TOOL_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "path_filter": { + "type": "string", + "default": "", + "description": ( + "Optional fnmatch-style glob applied to caller_file. " + "Use to narrow results to a queue scope, e.g. 'mcp/**'." + ), + }, + "severity_min": { + "type": "string", + "enum": ["error", "warn", "info"], + "default": "warn", + "description": ( + "Minimum severity to include in the response. " + "Default 'warn' suppresses info-level rules from agent output." + ), + }, + "persist": { + "type": "boolean", + "default": True, + "description": ( + "Write evaluated violations through to the cache. Set false for " + "RFC-0022 read-only evaluation; no database or file is created." + ), + }, + "diff_snapshot_id": { + "type": "string", + "description": "RFC-0022 frozen diff snapshot to evaluate against.", + }, + "scope_paths": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Primitive-issued assessed_scope_paths. With diff_snapshot_id the " + "list must exactly match the frozen snapshot scope." + ), + }, + "output_format": { + "type": "string", + "enum": ["json", "toon"], + "default": "json", + "description": "Response format.", + }, + }, + "additionalProperties": False, +} diff --git a/tree_sitter_analyzer/mcp/tools/constraint_check_snapshot.py b/tree_sitter_analyzer/mcp/tools/constraint_check_snapshot.py new file mode 100644 index 000000000..9697195ac --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_check_snapshot.py @@ -0,0 +1,27 @@ +"""Argument boundary for frozen constraint snapshot consumers.""" + +from __future__ import annotations + +from typing import Any + + +def validate_snapshot_arguments(arguments: dict[str, Any]) -> None: + """Reject ambiguous or writable frozen-snapshot argument combinations.""" + persist = arguments.get("persist", True) + if not isinstance(persist, bool): + raise ValueError("persist must be a boolean") + snapshot_id = arguments.get("diff_snapshot_id") + scope_paths = arguments.get("scope_paths") + if snapshot_id is not None: + if not isinstance(snapshot_id, str) or not snapshot_id: + raise ValueError("diff_snapshot_id must be a non-empty string") + if persist: + raise ValueError("diff_snapshot_id requires persist=false") + if not isinstance(scope_paths, list) or any( + not isinstance(path, str) for path in scope_paths + ): + raise ValueError("diff_snapshot_id requires scope_paths as strings") + if arguments.get("path_filter"): + raise ValueError("DIFF_SNAPSHOT_CONFLICTING_ARGUMENTS") + elif scope_paths is not None: + raise ValueError("scope_paths requires diff_snapshot_id") diff --git a/tree_sitter_analyzer/mcp/tools/constraint_check_tool.py b/tree_sitter_analyzer/mcp/tools/constraint_check_tool.py index f0a9a9e5b..9f7c72cd5 100644 --- a/tree_sitter_analyzer/mcp/tools/constraint_check_tool.py +++ b/tree_sitter_analyzer/mcp/tools/constraint_check_tool.py @@ -1,17 +1,7 @@ #!/usr/bin/env python3 -"""``check_constraints`` MCP tool — architectural-constraint DSL gate. +"""``check_constraints`` architectural-constraint DSL gate. -Reads architectural-constraints.yml from project root, evaluates rules -against the cached call-edge index, writes the result into -``ast_constraint_violations``, and returns the verdict. - -Verdict mapping (Feature 3 spec): - error severity present → UNSAFE - only warn severity → CAUTION - no violations → SAFE - -This is the ONLY tool in MVP that emits the UNSAFE verdict; safe_to_edit -and analyze_change_impact read it through the violations table. +Evaluates cached call edges and optionally persists exact violations. """ from __future__ import annotations @@ -27,50 +17,27 @@ evaluate, load_constraints, ) -from ...constraints.parser import ConstraintParseError, _compile_glob +from ...constraints.parser import ConstraintParseError +from ...source_oracle import SourceOracleError from ..utils.format_helper import apply_toon_format_to_response from .base_tool import BaseMCPTool +from .constraint_check_live import ( + config_changed_response as _config_changed_response, +) +from .constraint_check_live import live_config_snapshot as _live_config_snapshot +from .constraint_check_live import load_live_constraints, path_is_in_scope +from .constraint_check_persistence import read_filtered_violations +from .constraint_check_schema import TOOL_SCHEMA -logger = logging.getLogger(__name__) +_path_is_in_scope = path_is_in_scope -# Severities that block (verdict UNSAFE) vs warn (verdict CAUTION) vs -# silent (no verdict impact). Order in the lists determines the verdict -# escalation precedence — error > warn > info — and is asserted by tests. +logger = logging.getLogger(__name__) +# Exact verdict escalation: error > warn > info. _BLOCKING_SEVERITIES: frozenset[str] = frozenset({"error"}) _WARNING_SEVERITIES: frozenset[str] = frozenset({"warn"}) -TOOL_SCHEMA: dict[str, Any] = { - "type": "object", - "properties": { - "path_filter": { - "type": "string", - "default": "", - "description": ( - "Optional fnmatch-style glob applied to caller_file. " - "Use to narrow results to a queue scope, e.g. 'mcp/**'." - ), - }, - "severity_min": { - "type": "string", - "enum": ["error", "warn", "info"], - "default": "warn", - "description": ( - "Minimum severity to include in the response. " - "Default 'warn' suppresses info-level rules from agent output." - ), - }, - "output_format": { - "type": "string", - "enum": ["json", "toon"], - "default": "json", - "description": "Response format.", - }, - }, - "additionalProperties": False, -} - - _SEVERITY_ORDER: dict[str, int] = {"info": 0, "warn": 1, "error": 2} +_MAX_MATERIALIZED_VIOLATIONS = 10_000 class ConstraintCheckTool(BaseMCPTool): @@ -87,9 +54,12 @@ def get_tool_definition(self) -> dict[str, Any]: ), "inputSchema": self.get_tool_schema(), "annotations": { - "readOnlyHint": True, + # The legacy/default route writes the violation cache. MCP + # annotations describe the whole tool, not one argument shape; + # persist=false is the explicitly read-only sub-route. + "readOnlyHint": False, "destructiveHint": False, - "idempotentHint": True, + "idempotentHint": False, "openWorldHint": False, }, } @@ -104,6 +74,9 @@ def validate_arguments(self, arguments: dict[str, Any]) -> bool: f"severity_min must be one of {sorted(_SEVERITY_ORDER)}; " f"got {severity_min!r}" ) + from .constraint_check_snapshot import validate_snapshot_arguments + + validate_snapshot_arguments(arguments) return True async def execute(self, arguments: dict[str, Any]) -> dict[str, Any]: @@ -115,13 +88,24 @@ async def execute(self, arguments: dict[str, Any]) -> dict[str, Any]: "error": "Project root not set. Call set_project_path first.", } + if arguments.get("diff_snapshot_id") is not None: + return self._execute_frozen(arguments) + path_filter = arguments.get("path_filter", "") or "" severity_min = arguments.get("severity_min", "warn") output_format = arguments.get("output_format", "json") min_severity_rank = _SEVERITY_ORDER[severity_min] + persist = arguments.get("persist", True) + deadline = time.monotonic() + 10.0 + config_before = None try: - constraints = load_constraints(self.project_root) + if not persist: + config_before, constraints = load_live_constraints( + self.project_root, deadline + ) + else: + constraints = load_constraints(self.project_root) except ConstraintParseError as exc: return apply_toon_format_to_response( { @@ -133,12 +117,36 @@ async def execute(self, arguments: dict[str, Any]) -> dict[str, Any]: }, output_format, ) + except (OSError, RuntimeError, SourceOracleError) as exc: + return self._snapshot_error( + "CONSTRAINT_CONFIG_UNKNOWN", output_format, str(exc) + ) + + if not constraints and not persist: + assert config_before is not None + changed = _config_changed_response( + self.project_root, + config_before, + deadline, + output_format, + self._snapshot_error, + _live_config_snapshot, + ) + if changed is not None: + return changed + return apply_toon_format_to_response( + { + "success": True, + "verdict": "SAFE", + "violations": [], + "rule_count": 0, + "evaluated_edge_count": 0, + }, + output_format, + ) db_path = Path(self.project_root) / ".ast-cache" / "index.db" - if not db_path.is_file(): - # No cache yet: nothing to evaluate. Return SAFE with rule - # count so the caller can see the rules loaded — without a - # cache we can't say whether they pass or fail. + if persist and not db_path.is_file(): return apply_toon_format_to_response( { "success": True, @@ -154,19 +162,53 @@ async def execute(self, arguments: dict[str, Any]) -> dict[str, Any]: output_format, ) - # Run a fresh evaluation against the cache and write the result - # through to the violations table so downstream tools (safe_to_edit, - # change_impact) see consistent data. - violations, evaluated_edges = self._run_and_persist(db_path, constraints) + if persist: + try: + _, evaluated_edges = self._run_and_persist(db_path, constraints) + filtered_rows = self._read_filtered_violations( + db_path, + path_filter=path_filter, + min_severity_rank=min_severity_rank, + ) + except RuntimeError as exc: + if str(exc) != "CONSTRAINT_EVALUATION_CAPACITY": + raise + return self._snapshot_error( + "CONSTRAINT_EVALUATION_CAPACITY", output_format, str(exc) + ) + else: + try: + filtered_rows, evaluated_edges = self._run_read_only( + db_path, + constraints, + path_filter=path_filter, + min_severity_rank=min_severity_rank, + deadline=deadline, + ) + except ( + sqlite3.DatabaseError, + OSError, + RuntimeError, + ValueError, + TypeError, + AttributeError, + ) as exc: + return self._snapshot_error( + "CONSTRAINT_INDEX_UNKNOWN", output_format, str(exc) + ) - # Apply read-side filters (severity floor + path glob) to build - # the response payload. The persisted table is full-fidelity so - # later queries can use different filters without re-evaluating. - filtered_rows = self._read_filtered_violations( - db_path, - path_filter=path_filter, - min_severity_rank=min_severity_rank, - ) + if not persist: + assert config_before is not None + changed = _config_changed_response( + self.project_root, + config_before, + deadline, + output_format, + self._snapshot_error, + _live_config_snapshot, + ) + if changed is not None: + return changed verdict = self._compute_verdict(filtered_rows) return apply_toon_format_to_response( @@ -180,30 +222,87 @@ async def execute(self, arguments: dict[str, Any]) -> dict[str, Any]: output_format, ) - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ + def _execute_frozen(self, arguments: dict[str, Any]) -> dict[str, Any]: + """Delegate the frozen capability path to its focused production module.""" + from .constraint_check_frozen import execute_frozen + + return execute_frozen(self, arguments) + + @staticmethod + def severity_rank(severity: str) -> int: + """Return the canonical ordering used by both live and frozen paths.""" + return _SEVERITY_ORDER[severity] + + @staticmethod + def _snapshot_error( + code: str, output_format: str, detail: str | None = None + ) -> dict[str, Any]: + return apply_toon_format_to_response( + { + "success": False, + "verdict": "ERROR", + "error_code": code, + "error": detail or code, + }, + output_format, + ) + + def _run_read_only( + self, + db_path: Path, + constraints: list[Any], + *, + path_filter: str, + min_severity_rank: int, + scope_paths: frozenset[str] | None = None, + evaluator: Any = None, + deadline: float | None = None, + ) -> tuple[list[dict[str, Any]], int]: + from .constraint_check_read_only import run_read_only + + return run_read_only( + self, + db_path, + constraints, + path_filter=path_filter, + min_severity_rank=min_severity_rank, + scope_paths=scope_paths, + evaluator=evaluator, + deadline=deadline, + ) + + def _evaluate_connection( + self, + conn: sqlite3.Connection, + constraints: list[Any], + *, + path_filter: str = "", + min_severity_rank: int, + scope_paths: frozenset[str] | None = None, + evaluator: Any = None, + deadline: float | None = None, + ) -> tuple[list[dict[str, Any]], int]: + from .constraint_check_evaluation import evaluate_connection + + evaluator = evaluate if evaluator is None else evaluator + return evaluate_connection( + self, + conn, + constraints, + path_filter=path_filter, + min_severity_rank=min_severity_rank, + scope_paths=scope_paths, + evaluator=evaluator, + deadline=deadline, + capacity=_MAX_MATERIALIZED_VIOLATIONS, + ) def _run_and_persist( self, db_path: Path, constraints: list[Any], ) -> tuple[list[Violation], int]: - """Run the evaluator and write-through into ``ast_constraint_violations``. - - Returns (violations, edge_count) for diagnostics. The - ``evaluated_edge_count`` is best-effort — we count whatever the - evaluator sees, which is a useful sanity signal even if it - doesn't perfectly match the rule-count cross-product. - - Cache-then-read contract: if there are no CALLS rows in the unified - ``edges`` table we DO NOT touch the existing violations table. - That preserves rows that were seeded by another producer (the - ``analyze_change_impact`` indexer, an earlier full run, or — in - tests — directly by a fixture). Without this guard we'd wipe out - legitimate cached state every time an agent ran the tool against - a fresh repo. - """ + """Evaluate and persist, preserving cached rows when no CALLS exist.""" conn = sqlite3.connect(str(db_path)) try: conn.execute(self._violations_ddl()) @@ -215,6 +314,11 @@ def _run_and_persist( try: violations = evaluate(constraints, conn) + except RuntimeError as exc: + if str(exc) == "CONSTRAINT_EVALUATION_CAPACITY": + raise + logger.warning("constraint evaluation failed: %s", exc) + return [], edge_count except Exception as exc: # noqa: BLE001 — log + degrade logger.warning("constraint evaluation failed: %s", exc) return [], edge_count @@ -250,7 +354,7 @@ def _run_and_persist( conn.close() @staticmethod - def _count_edges(conn: sqlite3.Connection) -> int: + def _count_edges(conn: sqlite3.Connection, *, fail_closed: bool = False) -> int: """Return the CALLS row count of the unified ``edges`` table, or 0.""" try: row = conn.execute( @@ -258,8 +362,8 @@ def _count_edges(conn: sqlite3.Connection) -> int: ).fetchone() return int(row[0]) if row else 0 except sqlite3.OperationalError: - # Table missing — fresh DB or a test fixture that built - # only the violations table. + if fail_closed: + raise return 0 def _read_filtered_violations( @@ -269,57 +373,13 @@ def _read_filtered_violations( path_filter: str, min_severity_rank: int, ) -> list[dict[str, Any]]: - """Read violations from the table with severity + path filters applied. - - The path filter is applied in Python (not SQL) because SQLite's - GLOB is glob-but-not-globstar — we want our ``**`` semantics, - which means re-using the same ``_compile_glob`` the evaluator - uses. - """ - conn = sqlite3.connect(str(db_path)) - try: - conn.execute(self._violations_ddl()) - cursor = conn.execute( - """ - SELECT rule_id, caller_file, caller_name, caller_line, - callee_name, callee_file, severity, detected_at - FROM ast_constraint_violations - ORDER BY severity DESC, caller_file, caller_line - """ - ) - path_re = _compile_glob(path_filter) if path_filter else None - results: list[dict[str, Any]] = [] - for row in cursor: - ( - rule_id, - caller_file, - caller_name, - caller_line, - callee_name, - callee_file, - severity, - detected_at, - ) = row - rank = _SEVERITY_ORDER.get(severity, 0) - if rank < min_severity_rank: - continue - if path_re is not None and path_re.fullmatch(caller_file) is None: - continue - results.append( - { - "rule_id": rule_id, - "caller_file": caller_file, - "caller_name": caller_name, - "caller_line": caller_line, - "callee_name": callee_name, - "callee_file": callee_file, - "severity": severity, - "detected_at": detected_at, - } - ) - return results - finally: - conn.close() + return read_filtered_violations( + db_path, + path_filter=path_filter, + min_severity_rank=min_severity_rank, + severity_order=_SEVERITY_ORDER, + ddl=self._violations_ddl(), + ) @staticmethod def _compute_verdict(rows: list[dict[str, Any]]) -> str: @@ -334,11 +394,7 @@ def _compute_verdict(rows: list[dict[str, Any]]) -> str: @staticmethod def _violations_ddl() -> str: - """Self-healing DDL — keeps the tool usable even if the global - migration hasn't run yet (e.g. a test that builds a fresh DB). - - The DDL must stay in sync with ``ast_cache._SCHEMA_V6_VIOLATIONS``. - """ + """Return DDL kept in sync with the cache violation schema.""" return """ CREATE TABLE IF NOT EXISTS ast_constraint_violations ( rule_id TEXT NOT NULL, diff --git a/tree_sitter_analyzer/mcp/tools/constraint_index_snapshot.py b/tree_sitter_analyzer/mcp/tools/constraint_index_snapshot.py new file mode 100644 index 000000000..cea99d7cf --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_index_snapshot.py @@ -0,0 +1,453 @@ +"""Portable private SQLite snapshots for ordinary read-only constraint checks.""" + +from __future__ import annotations + +import os +import sqlite3 +import stat +import tempfile +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast + +from ...cache.build_state import build_in_progress +from ...frozen_git_index import safe_external_temp_parent +from ...index_snapshot_capability import ( + exact_call_graph_marker, + require_memory_temp_store, +) +from ...index_snapshot_schema import index_fingerprint, validate_snapshot_schema +from ...index_snapshot_symbols import has_ordinary_symbol_projection +from ...index_source_snapshot import ( + capture_current_source_snapshot, + recorded_source_rows, +) +from ...index_symbol_projection import ( + sqlite_compile_supports_fts5, + symbol_projection_is_exact, +) +from ...portable_source_snapshot import capture_portable_source_snapshot +from ...source_oracle import SourceOracleError +from .constraint_check_portable_snapshot import ( + portable_snapshot_required as _portable_snapshot_required, +) +from .constraint_index_snapshot_budget import copy_pinned_database +from .constraint_index_snapshot_faults import ( + close_optional_fd, + path_identity, + stat_identity, +) + +_MAX_BACKUP_BYTES = 510 * 1024 * 1024 + + +def _open(*args: Any, **kwargs: Any) -> int: + """Module-local open seam for exact pathname-swap fault injection.""" + return os.open(*args, **kwargs) + + +@dataclass(frozen=True, slots=True) +class OrdinaryConstraintSnapshot: + """Certification metadata paired with a caller-owned private connection.""" + + completeness: str + reason: str | None + source_scope: Any | None + source_generation: str | None = None + source_fingerprint: str | None = None + canonical_root: str | None = None + + +def _stat_identity(info: os.stat_result) -> tuple[int, int, int, int, int]: + return stat_identity(info) + + +def _identity(path: Path, *, directory: bool) -> tuple[int, int, int, int, int]: + return path_identity(path, directory=directory) + + +def _open_database_fd(db_path: Path, expected: tuple[int, int, int, int, int]) -> int: + """Open the database read-only and bind the descriptor to its lstat identity.""" + flags = ( + os.O_RDONLY + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_CLOEXEC", 0) + ) + fd = _open(db_path, flags) + try: + opened = os.fstat(fd) + if not stat.S_ISREG(opened.st_mode) or _stat_identity(opened) != expected: + raise ValueError("CONCURRENT_WRITER") + except BaseException: + os.close(fd) + raise + return fd + + +def _copy_pinned_database( + fd: int, + expected: tuple[int, int, int, int, int], + stream: Any, + *, + deadline: float, +) -> None: + copy_pinned_database( + fd, + expected, + stream, + deadline=deadline, + byte_limit=_MAX_BACKUP_BYTES, + check_deadline=_deadline, + stat_identity=_stat_identity, + ) + + +@contextmanager +def _temporary_copy( + fd: int, + expected: tuple[int, int, int, int, int], + root: str, + *, + deadline: float, +) -> Iterator[Path]: + """Stream a pinned database into a private directory outside the project.""" + try: + temp_parent = safe_external_temp_parent(root) + except SourceOracleError as exc: + raise ValueError("INDEX_TEMP_OUTSIDE_PROJECT_REQUIRED") from exc + with tempfile.TemporaryDirectory( + prefix="tsa-constraint-index-", dir=temp_parent + ) as tmp: + tmp_real = os.path.realpath(tmp) + try: + inside_project = os.path.commonpath((root, tmp_real)) == root + except ValueError: + inside_project = False + if inside_project: + raise ValueError("INDEX_TEMP_OUTSIDE_PROJECT_REQUIRED") + copy_path = Path(tmp_real) / "index.db" + with copy_path.open("xb", buffering=0) as stream: + _copy_pinned_database(fd, expected, stream, deadline=deadline) + yield copy_path + + +def _sidecar_state( + db_path: Path, +) -> tuple[tuple[str, tuple[int, int, int, int, int] | None], ...]: + states: list[tuple[str, tuple[int, int, int, int, int] | None]] = [] + for suffix in ("-wal", "-journal", "-shm"): + path = Path(str(db_path) + suffix) + try: + identity = _identity(path, directory=False) + except FileNotFoundError: + identity = None + if suffix != "-shm" and identity is not None and identity[2] != 0: + raise ValueError("CONCURRENT_WRITER") + states.append((suffix, identity)) + return tuple(states) + + +def _close_optional_fd(fd: int | None) -> None: + close_optional_fd(fd) + + +def _deadline(deadline: float) -> None: + if time.monotonic() >= deadline: + raise RuntimeError("INDEX_SNAPSHOT_DEADLINE") + + +def _capture_constraint_sources(root: str, source_scope: Any, deadline: float) -> Any: + if portable_snapshot_required(): + return capture_portable_source_snapshot(root, source_scope, deadline=deadline) + return capture_current_source_snapshot(root, source_scope, deadline=deadline) + + +def _certify_private_copy( + conn: sqlite3.Connection, root: str, *, deadline: float +) -> OrdinaryConstraintSnapshot: + """Apply the same exact-manifest authority checks to an in-memory copy.""" + from ...index_snapshot import _read_bounded_manifest, _validate_manifest_scalars + from ...index_source_snapshot import parse_source_scope_descriptor + + _deadline(deadline) + validate_snapshot_schema(conn, deadline=deadline) + if build_in_progress(conn): + raise ValueError("CONCURRENT_WRITER") + manifest = _read_bounded_manifest(conn, deadline) + if manifest is None: + return OrdinaryConstraintSnapshot( + "partial", "SOURCE_SCOPE_DESCRIPTOR_MISSING", None + ) + _validate_manifest_scalars(manifest) + try: + source_scope = parse_source_scope_descriptor( + manifest["source_scope_descriptor"] + ) + except (TypeError, ValueError): + return OrdinaryConstraintSnapshot( + "partial", "SOURCE_SCOPE_DESCRIPTOR_INVALID", None + ) + current = _capture_constraint_sources(root, source_scope, deadline) + if current.state != "exact": + return OrdinaryConstraintSnapshot( + "partial", current.reason or "SOURCE_SCOPE_UNKNOWN", source_scope + ) + recorded = recorded_source_rows(conn, deadline=deadline) + index = index_fingerprint(conn, root, deadline=deadline) + exact = ( + recorded == current.rows + and manifest["canonical_root"] == root + and manifest["source_fingerprint"] == current.fingerprint + and manifest["index_fingerprint"] == index + and manifest["file_count"] == len(recorded) + and manifest["manifest_version"] == 2 + and exact_call_graph_marker(conn, deadline=deadline) + ) + tables = { + str(row[0]) + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + fts5 = sqlite_compile_supports_fts5(conn) + projection_exact = bool( + fts5 is not None + and has_ordinary_symbol_projection(conn, tables) + and symbol_projection_is_exact(conn, deadline=deadline, require_fts=fts5) + ) + if not exact: + return OrdinaryConstraintSnapshot( + "partial", "NO_EXACT_FULL_INDEX_MANIFEST", source_scope + ) + if not projection_exact: + return OrdinaryConstraintSnapshot( + "partial", "SYMBOL_PROJECTION_INCOMPLETE", source_scope + ) + final_current = _capture_constraint_sources(root, source_scope, deadline) + if final_current.state != "exact": + return OrdinaryConstraintSnapshot( + "partial", + final_current.reason or "SOURCE_SCOPE_UNKNOWN", + source_scope, + ) + if ( + final_current.rows != current.rows + or final_current.fingerprint != current.fingerprint + ): + raise ValueError("CONCURRENT_SOURCE") + return OrdinaryConstraintSnapshot( + completeness="complete", + reason=None, + source_scope=source_scope, + source_generation=final_current.generation, + source_fingerprint=final_current.fingerprint, + canonical_root=root, + ) + + +@contextmanager +def portable_ordinary_snapshot( + project_root: str, *, deadline: float +) -> Iterator[tuple[OrdinaryConstraintSnapshot, sqlite3.Connection]]: + """Certify a stable pinned database through an outside-project private copy.""" + root = os.path.realpath(os.path.abspath(project_root)) + root_path = Path(root) + cache_path = root_path / ".ast-cache" + db_path = cache_path / "index.db" + root_before = _identity(root_path, directory=True) + try: + cache_before = _identity(cache_path, directory=True) + db_before = _identity(db_path, directory=False) + except FileNotFoundError as exc: + raise ValueError("MISSING_INDEX") from exc + sidecars_before = _sidecar_state(db_path) + _deadline(deadline) + db_fd: int | None = None + source: sqlite3.Connection | None = None + private: sqlite3.Connection | None = None + try: + db_fd = _open_database_fd(db_path, db_before) + with _temporary_copy(db_fd, db_before, root, deadline=deadline) as copy_path: + if ( + _identity(root_path, directory=True) != root_before + or _identity(cache_path, directory=True) != cache_before + or _identity(db_path, directory=False) != db_before + or _sidecar_state(db_path) != sidecars_before + ): + raise ValueError("CONCURRENT_WRITER") + # SQLite is intentionally given only the private copy's pathname, + # never the mutable project database pathname. + copy_complete = False + try: + uri = copy_path.as_uri() + "?mode=ro&immutable=1" + source = sqlite3.connect(uri, uri=True, timeout=0, isolation_level=None) + source.execute("PRAGMA query_only=ON") + source.execute("PRAGMA busy_timeout=0") + require_memory_temp_store(source) + page_size = int(source.execute("PRAGMA page_size").fetchone()[0]) + page_count = int(source.execute("PRAGMA page_count").fetchone()[0]) + if page_size * page_count > _MAX_BACKUP_BYTES: + raise RuntimeError("INDEX_BACKUP_BUDGET") + private = sqlite3.connect(":memory:") + require_memory_temp_store(private) + + def progress(_status: int, _remaining: int, total: int) -> None: + if total * page_size > _MAX_BACKUP_BYTES: + raise RuntimeError("INDEX_BACKUP_BUDGET") + _deadline(deadline) + + source.backup( + private, + pages=max(64, (512 * 1024) // page_size), + progress=progress, + sleep=0, + ) + copy_complete = True + finally: + try: + if source is not None: + source.close() + source = None + finally: + if not copy_complete and private is not None: + private.close() + private = None + + if private is None: + raise ValueError("CONSTRAINT_INDEX_UNKNOWN") + if ( + _stat_identity(os.fstat(db_fd)) != db_before + or _identity(root_path, directory=True) != root_before + or _identity(cache_path, directory=True) != cache_before + or _identity(db_path, directory=False) != db_before + or _sidecar_state(db_path) != sidecars_before + ): + raise ValueError("CONCURRENT_WRITER") + private.row_factory = sqlite3.Row + try: + snapshot = _certify_private_copy(private, root, deadline=deadline) + except sqlite3.DatabaseError as exc: + raise ValueError("CORRUPT_INDEX") from exc + if ( + _stat_identity(os.fstat(db_fd)) != db_before + or _identity(root_path, directory=True) != root_before + or _identity(cache_path, directory=True) != cache_before + or _identity(db_path, directory=False) != db_before + or _sidecar_state(db_path) != sidecars_before + ): + raise ValueError("CONCURRENT_WRITER") + private.execute("PRAGMA query_only=ON") + private.execute("BEGIN") + yield snapshot, private + finally: + try: + if source is not None: + source.close() + finally: + try: + if private is not None: + private.close() + finally: + _close_optional_fd(db_fd) + + +def ordinary_source_scope_is_full(source_scope: object) -> bool: + """Reject caller-selected exclusions and partial roots for project-wide checks.""" + from ...index_source_scope import SourceScopeDescriptor + + return ( + isinstance(source_scope, SourceScopeDescriptor) + and source_scope.roots == (".",) + and not source_scope.exclude_patterns + ) + + +def portable_snapshot_required() -> bool: + return _portable_snapshot_required() + + +def evaluate_ordinary_snapshot( + tool: Any, + constraints: list[Any], + *, + path_filter: str, + min_severity_rank: int, + scope_paths: frozenset[str] | None, + evaluator: Any, + deadline: float, +) -> tuple[list[dict[str, Any]], int]: + """Acquire the platform authority and evaluate its private connection.""" + import inspect + + project_root = tool.project_root + if project_root is None: + raise ValueError("MISSING_PROJECT_ROOT") + if portable_snapshot_required(): + authority = portable_ordinary_snapshot(project_root, deadline=deadline) + else: + from ...index_snapshot import acquire_index_snapshot, lease_existing_snapshot + + @contextmanager + def registry_authority() -> Iterator[tuple[Any, sqlite3.Connection]]: + lease_kwargs = ( + {"deadline": deadline} + if "deadline" in inspect.signature(lease_existing_snapshot).parameters + else {} + ) + with lease_existing_snapshot(project_root, **lease_kwargs) as index: + if index.snapshot_id is None or index.completeness != "complete": + raise ValueError(index.reason or "CONSTRAINT_INDEX_UNKNOWN") + acquire_kwargs = ( + {"deadline": deadline} + if "deadline" + in inspect.signature(acquire_index_snapshot).parameters + else {} + ) + with acquire_index_snapshot( + index.snapshot_id, + project_root, + index.source_generation, + **acquire_kwargs, + ) as (_, conn): + yield index, conn + + authority = registry_authority() + with authority as (index, conn): + if index.completeness != "complete": + raise ValueError(index.reason or "CONSTRAINT_INDEX_UNKNOWN") + source_scope = getattr(index, "source_scope", None) + if hasattr(index, "source_scope") and not ordinary_source_scope_is_full( + source_scope + ): + raise ValueError("CONSTRAINT_INDEX_SCOPE_MISMATCH") + result = cast( + tuple[list[dict[str, Any]], int], + tool._evaluate_connection( + conn, + constraints, + path_filter=path_filter, + min_severity_rank=min_severity_rank, + scope_paths=scope_paths, + evaluator=evaluator, + deadline=deadline, + ), + ) + if source_scope is not None: + source_root = getattr(index, "canonical_root", None) + if not isinstance(source_root, str) or not source_root: + raise ValueError("CONSTRAINT_INDEX_UNKNOWN") + current = _capture_constraint_sources(source_root, source_scope, deadline) + if current.state != "exact": + raise ValueError(current.reason or "SOURCE_SCOPE_UNKNOWN") + expected_generation = getattr(index, "source_generation", None) + if expected_generation is None: + expected_fingerprint = getattr(index, "source_fingerprint", None) + if expected_fingerprint is None: + raise ValueError("SOURCE_GENERATION_MISMATCH") + if current.fingerprint != expected_fingerprint: + raise ValueError("SOURCE_GENERATION_MISMATCH") + elif current.generation != expected_generation: + raise ValueError("SOURCE_GENERATION_MISMATCH") + return result diff --git a/tree_sitter_analyzer/mcp/tools/constraint_index_snapshot_budget.py b/tree_sitter_analyzer/mcp/tools/constraint_index_snapshot_budget.py new file mode 100644 index 000000000..371d268ae --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_index_snapshot_budget.py @@ -0,0 +1,45 @@ +"""Bounded streaming copy primitives for portable constraint indexes.""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from typing import Any + + +def copy_pinned_database( + fd: int, + expected: tuple[int, int, int, int, int], + stream: Any, + *, + deadline: float, + byte_limit: int, + check_deadline: Callable[[float], None], + stat_identity: Callable[[os.stat_result], tuple[int, int, int, int, int]], +) -> None: + """Stream one pinned database under exact size, deadline, and write bounds.""" + size = expected[2] + if size > byte_limit: + raise RuntimeError("INDEX_BACKUP_BUDGET") + remaining = size + while remaining: + check_deadline(deadline) + chunk = os.read(fd, min(64 * 1024, remaining)) + if not chunk: + raise ValueError("CONCURRENT_WRITER") + view = memoryview(chunk) + while view: + check_deadline(deadline) + written = stream.write(view) + if not isinstance(written, int) or written <= 0 or written > len(view): + raise OSError("INDEX_STAGE_WRITE_FAILED") + view = view[written:] + check_deadline(deadline) + remaining -= len(chunk) + check_deadline(deadline) + if os.read(fd, 1): + raise ValueError("CONCURRENT_WRITER") + check_deadline(deadline) + if stat_identity(os.fstat(fd)) != expected: + raise ValueError("CONCURRENT_WRITER") + check_deadline(deadline) diff --git a/tree_sitter_analyzer/mcp/tools/constraint_index_snapshot_faults.py b/tree_sitter_analyzer/mcp/tools/constraint_index_snapshot_faults.py new file mode 100644 index 000000000..37222ab88 --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/constraint_index_snapshot_faults.py @@ -0,0 +1,33 @@ +"""Filesystem identity and cleanup guards for portable constraint indexes.""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path + + +def stat_identity(info: os.stat_result) -> tuple[int, int, int, int, int]: + return ( + int(info.st_dev), + int(info.st_ino), + int(info.st_size), + int(info.st_mtime_ns), + int(info.st_ctime_ns), + ) + + +def path_identity(path: Path, *, directory: bool) -> tuple[int, int, int, int, int]: + """Authenticate a non-symlink directory or regular file identity.""" + info = os.lstat(path) + if stat.S_ISLNK(info.st_mode): + raise ValueError("INDEX_PATH_SYMLINK") + expected_kind = stat.S_ISDIR if directory else stat.S_ISREG + if not expected_kind(info.st_mode): + raise ValueError("INDEX_PATH_UNSAFE") + return stat_identity(info) + + +def close_optional_fd(fd: int | None) -> None: + if fd is not None: + os.close(fd) diff --git a/tree_sitter_analyzer/mcp/tools/edit_facade.py b/tree_sitter_analyzer/mcp/tools/edit_facade.py index 7dd9fac19..ee3552c87 100644 --- a/tree_sitter_analyzer/mcp/tools/edit_facade.py +++ b/tree_sitter_analyzer/mcp/tools/edit_facade.py @@ -38,61 +38,10 @@ from typing import Any -from ..utils.format_helper import apply_toon_format_to_response +from .edit_facade_schema import _EDIT_ANNOTATIONS, _EDIT_DESCRIPTION +from .edit_facade_snapshot_routes import release_snapshot from .facade_tool import FacadeTool -# Annotation honesty — see module docstring above. -# readOnlyHint=False because the facade includes mutating-intent actions -# (refactor/guard). We cannot claim read-only across a mixed action set. -_EDIT_ANNOTATIONS: dict[str, Any] = { - "readOnlyHint": False, - "destructiveHint": False, # suggests / analyses; never writes files - "idempotentHint": False, # analysis results can change as index updates - "openWorldHint": False, -} - -_EDIT_DESCRIPTION = ( - "Code-intelligence (codegraph-compatible) safety and change-management facade. " - "Covers codegraph_pr_review (PR analysis via codegraph), safe-to-edit gates, " - "blast-radius guards, change impact scanning, refactoring suggestions, " - "constraint checks, semantic classification, and AST diff in one tool. " - "Pick a capability via `action`:\n" - "- action=safe — pre-edit safety gate: is this file safe to edit right now? " - "Returns SAFE/UNSAFE verdict. Params: file_path, edit_type, output_format.\n" - "- action=guard — blast-radius guard BEFORE touching a symbol: how many callers, " - "what test coverage, what risk level. " - "Params: symbol* (required), modification_type* (required), file_path.\n" - "- action=impact — post-edit dependency blast-radius scan combining git diff + " - "dependency graph: affected files, must-run tests, risk verdict (SAFE/REVIEW/WARN). " - "Call after every non-trivial edit. Params: mode (diff|staged|branch|pr, " - "default: diff), scope_paths, output_format, capture_diff_snapshot (boolean; " - "explicit opt-in, same-process POSIX producer only).\n" - "- action=refactor — refactoring-opportunity analysis for a source file: extract " - "candidates, complexity hotspots, skeleton. Params: file_path, language, " - "max_suggestions, include_extractions, include_skeleton, output_format.\n" - "- action=constraints — scan the project for constraint/rule violations " - "(architecture, naming, coupling). Params: severity_min, output_format.\n" - "- action=pr — AI review of a PR diff via codegraph: structural issues, " - "blast-radius, test-coverage gaps (codegraph_pr_review equivalent). " - "Params: pr_url or diff (see inner schema).\n" - "- action=classify — semantic change classification: classify a file's diff " - "between git refs (file_path [+ old_ref/new_ref]) or two code strings " - "(old_source + new_source + language). With only file_path, defaults to the " - "file/git-ref mode. Params: file_path | old_source+new_source+language, " - "output_format.\n" - "- action=ast_diff — structural AST diff between two snapshots/versions of " - "a file: added/removed/changed nodes. Mode is inferred from args when omitted. " - "Modes: diff_files (old_file + new_file), " - "diff_strings (old_source + new_source + language), " - "diff_git (old_ref + new_ref + file_path). " - "Params: see inner schema.\n" - "- action=release_snapshot — idempotently release a process-local frozen diff. " - "Params: diff_snapshot_id + route_lease_id.\n" - "NOTE: ``safe``/``impact``/``classify``/``constraints``/``pr``/``ast_diff`` are " - "read-only in practice; ``refactor``/``guard`` suggest changes but do not write " - "files. readOnlyHint is False for the whole facade (mixed action set)." -) - def build_edit_facade(project_root: str | None = None) -> FacadeTool: """Construct the ``edit`` facade wired to live inner tool instances. @@ -109,27 +58,6 @@ def build_edit_facade(project_root: str | None = None) -> FacadeTool: impact_tool = ChangeImpactTool(project_root) - async def release_snapshot(arguments: dict[str, Any]) -> dict[str, Any]: - """Release one process-local RFC-0022 lease through the live MCP process.""" - from ...diff_snapshot_registry import REGISTRY - - snapshot_id = arguments.get("diff_snapshot_id") - lease_id = arguments.get("route_lease_id") - output_format = arguments.get("output_format", "toon") - if not isinstance(snapshot_id, str) or not isinstance(lease_id, str): - raise ValueError("diff_snapshot_id and route_lease_id are required") - error = REGISTRY.release_route_lease(snapshot_id, lease_id) - result: dict[str, Any] = { - "success": error is None, - "verdict": "INFO" if error is None else "ERROR", - "diff_snapshot_id": snapshot_id, - "released": error is None, - "output_format": output_format, - } - if error is not None: - result.update(error=error, error_code=error) - return apply_toon_format_to_response(result, output_format) - class _PRReviewViaFacade(CodeGraphPRReviewTool): """Facade ``action=pr`` implies ``mode=pr``. @@ -198,6 +126,15 @@ async def execute(self, arguments: dict[str, Any]) -> Any: # agents. Surface it with the authoritative enum from the inner tool # so facade/inner never drift. Never added to required[] (runtime- # resolved param convention, locked #397 family). + action_scoped_params={ + "capture_diff_snapshot": frozenset({"impact"}), + "diff_snapshot_id": frozenset( + {"constraints", "classify", "ast_diff", "release_snapshot"} + ), + "persist": frozenset({"constraints"}), + "route_lease_id": frozenset({"release_snapshot"}), + "scope_paths": frozenset({"impact", "constraints"}), + }, extra_public_params={ "capture_diff_snapshot": { "type": "boolean", @@ -208,7 +145,26 @@ async def execute(self, arguments: dict[str, Any]) -> Any: }, "diff_snapshot_id": { "type": "string", - "description": "RFC-0022 frozen diff ID for classify/ast_diff/release_snapshot.", + "description": ( + "RFC-0022 frozen diff ID for constraints/classify/ast_diff/" + "release_snapshot." + ), + }, + "persist": { + "type": "boolean", + "default": True, + "description": ( + "Write evaluated violations through to the cache. Set false for " + "RFC-0022 read-only evaluation; no database or file is created." + ), + }, + "scope_paths": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Primitive-issued frozen scope for action=constraints, or impact " + "capture scope for action=impact." + ), }, "route_lease_id": { "type": "string", diff --git a/tree_sitter_analyzer/mcp/tools/edit_facade_schema.py b/tree_sitter_analyzer/mcp/tools/edit_facade_schema.py new file mode 100644 index 000000000..2d1874578 --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/edit_facade_schema.py @@ -0,0 +1,59 @@ +"""Public schema metadata for the edit facade.""" + +from __future__ import annotations + +from typing import Any + +# Annotation honesty — see module docstring above. +# readOnlyHint=False because the facade includes mutating-intent actions +# (refactor/guard). We cannot claim read-only across a mixed action set. +_EDIT_ANNOTATIONS: dict[str, Any] = { + "readOnlyHint": False, + "destructiveHint": False, # suggests / analyses; never writes files + "idempotentHint": False, # analysis results can change as index updates + "openWorldHint": False, +} + +_EDIT_DESCRIPTION = ( + "Code-intelligence (codegraph-compatible) safety and change-management facade. " + "Covers codegraph_pr_review (PR analysis via codegraph), safe-to-edit gates, " + "blast-radius guards, change impact scanning, refactoring suggestions, " + "constraint checks, semantic classification, and AST diff in one tool. " + "Pick a capability via `action`:\n" + "- action=safe — pre-edit safety gate: is this file safe to edit right now? " + "Returns SAFE/UNSAFE verdict. Params: file_path, edit_type, output_format.\n" + "- action=guard — blast-radius guard BEFORE touching a symbol: how many callers, " + "what test coverage, what risk level. " + "Params: symbol* (required), modification_type* (required), file_path.\n" + "- action=impact — post-edit dependency blast-radius scan combining git diff + " + "dependency graph: affected files, must-run tests, risk verdict (SAFE/REVIEW/WARN). " + "Call after every non-trivial edit. Params: mode (diff|staged|branch|pr, " + "default: diff), scope_paths, output_format, capture_diff_snapshot (boolean; " + "explicit opt-in, same-process POSIX producer only).\n" + "- action=refactor — refactoring-opportunity analysis for a source file: extract " + "candidates, complexity hotspots, skeleton. Params: file_path, language, " + "max_suggestions, include_extractions, include_skeleton, output_format.\n" + "- action=constraints — scan the project for constraint/rule violations. " + "For RFC-0022 frozen read-only evaluation pass persist=false, " + "diff_snapshot_id, and the impact-produced scope_paths. " + "Params: severity_min, persist, diff_snapshot_id, scope_paths, output_format.\n" + "- action=pr — AI review of a PR diff via codegraph: structural issues, " + "blast-radius, test-coverage gaps (codegraph_pr_review equivalent). " + "Params: pr_url or diff (see inner schema).\n" + "- action=classify — semantic change classification: classify a file's diff " + "between git refs (file_path [+ old_ref/new_ref]) or two code strings " + "(old_source + new_source + language). With only file_path, defaults to the " + "file/git-ref mode. Params: file_path | old_source+new_source+language, " + "output_format.\n" + "- action=ast_diff — structural AST diff between two snapshots/versions of " + "a file: added/removed/changed nodes. Mode is inferred from args when omitted. " + "Modes: diff_files (old_file + new_file), " + "diff_strings (old_source + new_source + language), " + "diff_git (old_ref + new_ref + file_path). " + "Params: see inner schema.\n" + "- action=release_snapshot — idempotently release a process-local frozen diff. " + "Params: diff_snapshot_id + route_lease_id.\n" + "NOTE: ``safe``/``impact``/``classify``/``constraints``/``pr``/``ast_diff`` are " + "read-only in practice; ``refactor``/``guard`` suggest changes but do not write " + "files. readOnlyHint is False for the whole facade (mixed action set)." +) diff --git a/tree_sitter_analyzer/mcp/tools/edit_facade_snapshot_routes.py b/tree_sitter_analyzer/mcp/tools/edit_facade_snapshot_routes.py new file mode 100644 index 000000000..b5e6cf97f --- /dev/null +++ b/tree_sitter_analyzer/mcp/tools/edit_facade_snapshot_routes.py @@ -0,0 +1,29 @@ +"""Process-local frozen snapshot routes exposed by the edit facade.""" + +from __future__ import annotations + +from typing import Any + +from ..utils.format_helper import apply_toon_format_to_response + + +async def release_snapshot(arguments: dict[str, Any]) -> dict[str, Any]: + """Release one process-local RFC-0022 lease through the live MCP process.""" + from ...diff_snapshot_registry import REGISTRY + + snapshot_id = arguments.get("diff_snapshot_id") + lease_id = arguments.get("route_lease_id") + output_format = arguments.get("output_format", "toon") + if not isinstance(snapshot_id, str) or not isinstance(lease_id, str): + raise ValueError("diff_snapshot_id and route_lease_id are required") + error = REGISTRY.release_route_lease(snapshot_id, lease_id) + result: dict[str, Any] = { + "success": error is None, + "verdict": "INFO" if error is None else "ERROR", + "diff_snapshot_id": snapshot_id, + "released": error is None, + "output_format": output_format, + } + if error is not None: + result.update(error=error, error_code=error) + return apply_toon_format_to_response(result, output_format) diff --git a/tree_sitter_analyzer/portable_source_snapshot.py b/tree_sitter_analyzer/portable_source_snapshot.py new file mode 100644 index 000000000..b65371d2f --- /dev/null +++ b/tree_sitter_analyzer/portable_source_snapshot.py @@ -0,0 +1,188 @@ +"""Bounded source-scope certification for pathname-only platforms.""" + +from __future__ import annotations + +import fnmatch +import os +import stat +import time +from pathlib import Path + +from .constants import EXCLUDE_DIRS +from .index_source_scope import SourceScopeDescriptor +from .index_source_snapshot import CurrentSourceSnapshot, inventory_fingerprint +from .index_source_stream import hash_source_at +from .indexing_limits import KNOWLEDGE_INDEX_MAX_FILES +from .languages.lang_extension_map import EXT_TO_LANG + +_SOURCE_BYTE_BUDGET = 512 * 1024 * 1024 +_SOURCE_ENTRY_BUDGET = 1_000_000 +_SOURCE_PATH_BYTE_BUDGET = 128 * 1024 * 1024 + + +def _identity(info: os.stat_result) -> tuple[int, int, int, int, int, int, int]: + return ( + int(info.st_dev), + int(info.st_ino), + int(info.st_mode), + int(info.st_size), + int(info.st_mtime_ns), + int(info.st_ctime_ns), + int(getattr(info, "st_file_attributes", 0)), + ) + + +def _marker(info: os.stat_result) -> str: + return ( + f"{info.st_dev}:{info.st_ino}:{info.st_size}:" + f"{info.st_mtime_ns}:{info.st_ctime_ns}" + ) + + +def _is_reparse(info: os.stat_result) -> bool: + attributes = int(getattr(info, "st_file_attributes", 0)) + reparse = int(getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + return stat.S_ISLNK(info.st_mode) or bool(reparse and attributes & reparse) + + +def _same(before: os.stat_result, after: os.stat_result) -> bool: + return _identity(before) == _identity(after) + + +def _scope_root(project_root: Path, relative: str) -> Path: + normalized = relative.replace("\\", "/") + parts = tuple(part for part in normalized.split("/") if part not in ("", ".")) + if normalized.startswith("/") or any(part == ".." for part in parts): + raise OSError("source root escapes project") + return project_root.joinpath(*parts) + + +def _portable_inventory( + project_root: str, + scope: SourceScopeDescriptor, + deadline: float, +) -> tuple[frozenset[tuple[str, str, str]], bool]: + """Hash one bounded pathname inventory without following directory links.""" + project = Path(project_root) + root_before = os.lstat(project) + if not stat.S_ISDIR(root_before.st_mode) or _is_reparse(root_before): + return frozenset(), True + rows: set[tuple[str, str, str]] = set() + counters = {"entries": 0, "path_bytes": 0, "input": 0, "output": 0} + supported = 0 + unsafe = False + for relative_root in scope.roots: + base = _scope_root(project, relative_root) + try: + base_before = os.lstat(base) + except OSError: + return frozenset(), True + if not stat.S_ISDIR(base_before.st_mode) or _is_reparse(base_before): + return frozenset(), True + stack = [(base, base_before)] + while stack: + directory, discovered = stack.pop() + try: + directory_before = os.lstat(directory) + if ( + not stat.S_ISDIR(directory_before.st_mode) + or _is_reparse(directory_before) + or _identity(directory_before) != _identity(discovered) + ): + return frozenset(), True + entries = os.scandir(directory) + with entries: + for entry in entries: + if time.monotonic() > deadline: + raise TimeoutError + path = Path(entry.path) + relative = path.relative_to(project).as_posix() + counters["entries"] += 1 + counters["path_bytes"] += len( + relative.encode("utf-8", "surrogatepass") + ) + if ( + counters["entries"] > _SOURCE_ENTRY_BUDGET + or counters["path_bytes"] > _SOURCE_PATH_BYTE_BUDGET + ): + raise OverflowError + before = os.lstat(path) + language = EXT_TO_LANG.get(path.suffix.lower()) + if stat.S_ISDIR(before.st_mode): + if entry.name in EXCLUDE_DIRS or entry.name.startswith("."): + continue + if _is_reparse(before): + continue + stack.append((path, before)) + continue + if _is_reparse(before): + if language is not None: + unsafe = True + continue + if language is None or any( + fnmatch.fnmatch(relative, pattern) + for pattern in scope.effective_excludes + ): + continue + if not stat.S_ISREG(before.st_mode): + unsafe = True + continue + supported += 1 + if supported > min( + scope.certification_max_files, KNOWLEDGE_INDEX_MAX_FILES + ): + raise OverflowError + marker, digest, clean = hash_source_at( + None, + str(path), + before, + deadline, + counters, + _SOURCE_BYTE_BUDGET, + _marker, + _same, + ) + if not clean: + unsafe = True + rows.add((relative, digest, language)) + except OSError: + return frozenset(), True + if _identity(os.lstat(directory)) != _identity(directory_before): + unsafe = True + if _identity(os.lstat(base)) != _identity(base_before): + unsafe = True + if _identity(os.lstat(project)) != _identity(root_before): + unsafe = True + return frozenset(rows), unsafe + + +def capture_portable_source_snapshot( + project_root: str, + source_scope: SourceScopeDescriptor, + *, + deadline: float, +) -> CurrentSourceSnapshot: + """Capture two equal bounded inventories on Windows/pathname-only hosts.""" + root = os.path.abspath(project_root) + try: + first, unsafe_first = _portable_inventory(root, source_scope, deadline) + second, unsafe_second = _portable_inventory(root, source_scope, deadline) + fingerprint = inventory_fingerprint(first, deadline=deadline) + except TimeoutError: + return CurrentSourceSnapshot( + frozenset(), None, None, "unknown", "SOURCE_SCAN_DEADLINE" + ) + except OverflowError: + return CurrentSourceSnapshot( + frozenset(), None, None, "unknown", "SOURCE_SCOPE_UNBOUNDED" + ) + except OSError: + return CurrentSourceSnapshot( + frozenset(), None, None, "unknown", "SOURCE_SCOPE_UNREADABLE" + ) + generation = "idxsrc-v3:" + fingerprint.removeprefix("sha256:") + if unsafe_first or unsafe_second or first != second: + return CurrentSourceSnapshot( + first, fingerprint, generation, "unsafe", "SOURCE_SCOPE_UNSAFE" + ) + return CurrentSourceSnapshot(first, fingerprint, generation, "exact", None)