from __future__ import annotations import shutil import tempfile import unittest from pathlib import Path from typing import cast from unittest import mock import docforge.adapters.javascript as javascript_adapter from docforge.adapter_sdk import ( AdapterProject, AdapterSource, AdapterSourceProjection, verify_adapter_conformance, ) from docforge.adapters.javascript import ( JAVASCRIPT_ADAPTER_ID, JAVASCRIPT_ADAPTER_VERSION, JAVASCRIPT_EXTRACTOR_VERSION, JavaScriptReferenceAdapter, ) from docforge.errors import DocForgeError from docforge.index import ProjectIndex ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "tests" / "fixtures" class RecordingScriptAdapter(JavaScriptReferenceAdapter): def __init__(self, root: Path, project_id: str) -> None: super().__init__( root, source_roots=("src",), project_id=project_id, title=f"{project_id} fixture", ) self.extracted_paths: list[str] = [] def extract_source(self, source: AdapterSource) -> AdapterSourceProjection: self.extracted_paths.append(source.source_path) return super().extract_source(source) class JavaScriptReferenceAdapterTests(unittest.TestCase): CASES = ( ("reference-javascript", "javascript", 14, 14, 5, "src/app/shared.js"), ("reference-typescript", "typescript", 13, 14, 4, "src/app/types.mts"), ) def copy_fixture(self, parent: Path, fixture: str) -> Path: root = parent / fixture shutil.copytree(FIXTURES / fixture, root) return root.resolve() @staticmethod def build_metrics(result: dict[str, object]) -> dict[str, object]: return cast(dict[str, object], result["build"]) def test_both_grammars_produce_deterministic_complete_logic_assemblies(self) -> None: for fixture, language, nodes, edges, logic, _shared in self.CASES: with self.subTest(language=language), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory), fixture) adapter = RecordingScriptAdapter(root, fixture) first = adapter.load_assembly() second = adapter.load_complete_assembly() projection = adapter.load_projection() self.assertEqual(first, second) self.assertEqual(first.projection, projection) self.assertEqual(JAVASCRIPT_ADAPTER_ID, projection.adapter_id) self.assertEqual(JAVASCRIPT_ADAPTER_VERSION, projection.adapter_version) self.assertEqual(nodes, len(projection.nodes)) self.assertEqual(edges, len(projection.edges)) self.assertEqual(logic, len(first.logic)) self.assertEqual( {language}, {dict(item.metadata)["language"] for item in projection.nodes}, ) self.assertEqual( {"contains", "depends_on"}, {item.edge.relation for item in projection.edges}, ) self.assertTrue( any( item.node.title == "Service.run" and dict(item.metadata)["kind"] == "function" for item in projection.nodes ) ) self.assertFalse((root / ".cache").exists()) report = adapter.support_report() self.assertEqual(1, report["schema_version"]) self.assertEqual(JAVASCRIPT_EXTRACTOR_VERSION, report["extractor_version"]) self.assertFalse(report["imports_project_code"]) self.assertFalse(report["executes_project_code"]) self.assertEqual( [ "call_resolution", "dynamic_module_resolution", "inheritance_resolution", "module_configuration_resolution", "runtime_generated_facts", "type_and_symbol_resolution", ], [item.code for item in adapter.unsupported_facts()], ) conformance = verify_adapter_conformance( adapter, cache_root=root / ".cache" / "conformance", ) self.assertEqual(nodes, conformance.node_count) self.assertEqual(edges, conformance.edge_count) self.assertEqual(logic, conformance.logic_projection_count) self.assertTrue(conformance.incremental) def test_cold_warm_reverse_dependency_and_exact_equivalence_for_both_grammars( self, ) -> None: for fixture, language, nodes, edges, logic, shared_path in self.CASES: with self.subTest(language=language), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory), fixture) adapter = RecordingScriptAdapter(root, fixture) project = AdapterProject(adapter, cache_root=root / ".cache" / "incremental") index = ProjectIndex(project) cold = index.build() self.assertEqual(4, self.build_metrics(cold)["reparsed_sources"]) self.assertEqual(4, len(adapter.extracted_paths)) adapter.extracted_paths.clear() warm = index.build() self.assertEqual(4, self.build_metrics(warm)["cache_hits"]) self.assertEqual(0, self.build_metrics(warm)["reparsed_sources"]) self.assertEqual([], adapter.extracted_paths) shared = root / shared_path shared.write_text( shared.read_text(encoding="utf-8") + "\n// changed dependency evidence\n", encoding="utf-8", ) adapter.extracted_paths.clear() changed = index.build() self.assertEqual(4, self.build_metrics(changed)["invalidated_sources"]) self.assertEqual(4, len(adapter.extracted_paths)) equivalent = project.verify_incremental_equivalence() self.assertEqual("ok", equivalent["status"]) self.assertEqual(nodes, equivalent["node_count"]) self.assertEqual(edges, equivalent["edge_count"]) self.assertEqual(logic, equivalent["logic_projection_count"]) def test_add_delete_and_corrupt_cache_recovery_for_both_grammars(self) -> None: additions = { "javascript": ( "src/app/extra.js", "export function extra() { return 'extra'; }\n", ), "typescript": ( "src/app/extra.ts", "export function extra(): string { return 'extra'; }\n", ), } for fixture, language, _nodes, _edges, _logic, _shared_path in self.CASES: with self.subTest(language=language), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory), fixture) adapter = RecordingScriptAdapter(root, fixture) cache_root = root / ".cache" / "incremental" project = AdapterProject(adapter, cache_root=cache_root) index = ProjectIndex(project) index.build() relative, content = additions[language] added_path = root / relative added_path.write_text(content, encoding="utf-8") adapter.extracted_paths.clear() added = index.build() self.assertEqual(1, self.build_metrics(added)["reparsed_sources"]) self.assertEqual([relative], adapter.extracted_paths) self.assertIn(relative, {node.source_path for node in project.load().nodes}) added_path.unlink() adapter.extracted_paths.clear() deleted = index.build() self.assertEqual(1, self.build_metrics(deleted)["deleted_sources"]) self.assertNotIn(relative, {node.source_path for node in project.load().nodes}) (cache_root / "extractions.json").write_text("{broken", encoding="utf-8") adapter.extracted_paths.clear() recovered = index.build() self.assertEqual(4, self.build_metrics(recovered)["reparsed_sources"]) self.assertEqual(4, len(adapter.extracted_paths)) self.assertEqual("ok", project.verify_incremental_equivalence()["status"]) def test_confinement_and_no_ast_reject_logic_for_both_grammars(self) -> None: for fixture, language, _nodes, _edges, _logic, _shared_path in self.CASES: with self.subTest(language=language), tempfile.TemporaryDirectory() as directory: parent = Path(directory) root = self.copy_fixture(parent, fixture) for source_root in (root / "src", Path("../outside")): with self.assertRaises(DocForgeError) as captured: JavaScriptReferenceAdapter(root, source_roots=(source_root,)) self.assertEqual("path_escape", captured.exception.code) outside = parent / "outside" outside.mkdir() linked_root = root / "linked" linked_root.symlink_to(outside, target_is_directory=True) with self.assertRaises(DocForgeError) as linked: JavaScriptReferenceAdapter(root, source_roots=("linked",)) self.assertEqual("path_escape", linked.exception.code) adapter = RecordingScriptAdapter(root, fixture) project = AdapterProject(adapter, cache_root=root / ".cache" / "no-ast") with self.assertRaises(DocForgeError) as no_ast: ProjectIndex(project, allow_logic=False).build() self.assertEqual("adapter_policy_forbids_logic", no_ast.exception.code) self.assertFalse(project.descriptor.index_path.exists()) def test_missing_optional_frontends_report_exact_install_remediation(self) -> None: for fixture, language, _nodes, _edges, _logic, _shared_path in self.CASES: grammar_module = ( "tree_sitter_javascript" if language == "javascript" else "tree_sitter_typescript" ) with self.subTest(language=language), tempfile.TemporaryDirectory() as directory: root = self.copy_fixture(Path(directory), fixture) adapter = RecordingScriptAdapter(root, fixture) missing = ModuleNotFoundError( f"No module named {grammar_module!r}", name=grammar_module, ) with ( mock.patch.object( javascript_adapter.importlib, "import_module", side_effect=missing, ), self.assertRaises(DocForgeError) as captured, ): adapter.load_manifest() self.assertEqual("optional_dependency_missing", captured.exception.code) self.assertEqual(language, captured.exception.details["extra"]) self.assertEqual( f"docforge[{language}]", captured.exception.details["install"], ) self.assertEqual( grammar_module, captured.exception.details["missing_module"], )