diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e332b12..6f4202f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,7 +32,7 @@ jobs: - name: Install Python dependencies working-directory: libs/openant-core - run: pip install -r requirements.txt && pip install pytest ruff + run: pip install -e ".[dev]" - name: Lint Python (undefined names, syntax errors) working-directory: libs/openant-core @@ -44,7 +44,7 @@ jobs: - name: Run Python and parser tests working-directory: libs/openant-core - run: python -m pytest tests/test_token_tracker.py tests/test_parser_adapter.py tests/test_python_parser.py tests/test_js_parser.py tests/test_resume_stage1.py -v + run: python -m pytest tests/test_token_tracker.py tests/test_parser_adapter.py tests/test_python_parser.py tests/test_js_parser.py tests/test_resume_stage1.py tests/test_declared_dependencies.py -v go-tests: name: Go build + integration (${{ matrix.os }}) @@ -99,7 +99,7 @@ jobs: - name: Install Python dependencies working-directory: libs/openant-core - run: pip install -r requirements.txt && pip install pytest + run: pip install -e ".[dev]" - name: Install JS parser dependencies working-directory: libs/openant-core/parsers/javascript diff --git a/libs/openant-core/README.md b/libs/openant-core/README.md index fdc2d80..81062dc 100644 --- a/libs/openant-core/README.md +++ b/libs/openant-core/README.md @@ -24,7 +24,7 @@ git clone https://github.com/your-org/openant.git cd openant # Install Python dependencies -pip install -r requirements.txt +pip install -e . # Set API key echo "ANTHROPIC_API_KEY=your-key-here" > .env diff --git a/libs/openant-core/pyproject.toml b/libs/openant-core/pyproject.toml index 34ffb1c..8f3d935 100644 --- a/libs/openant-core/pyproject.toml +++ b/libs/openant-core/pyproject.toml @@ -5,6 +5,7 @@ description = "Two-stage SAST tool using Claude for vulnerability analysis" readme = "README.md" requires-python = ">=3.11" dependencies = [ + "anthropic>=0.40.0", "claude-agent-sdk>=0.1.48", "python-dotenv>=1.0.0", "pydantic>=2.0.0", @@ -16,6 +17,7 @@ dependencies = [ "tree-sitter-cpp>=0.21.0", "tree-sitter-ruby>=0.21.0", "tree-sitter-php>=0.22.0", + "tree-sitter-zig>=0.20.0", ] [project.optional-dependencies] diff --git a/libs/openant-core/requirements.txt b/libs/openant-core/requirements.txt deleted file mode 100644 index 966904a..0000000 --- a/libs/openant-core/requirements.txt +++ /dev/null @@ -1,24 +0,0 @@ -annotated-types==0.7.0 -anthropic==0.75.0 -anyio==4.12.0 -certifi==2025.11.12 -distro==1.9.0 -docstring_parser==0.17.0 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -idna==3.11 -jiter==0.12.0 -pydantic==2.12.5 -pydantic_core==2.41.5 -python-dotenv==1.2.1 -sniffio==1.3.1 -typing-inspection==0.4.2 -typing_extensions==4.15.0 -PyYAML>=6.0 -requests>=2.31.0 -tree-sitter>=0.21.0 -tree-sitter-c>=0.21.0 -tree-sitter-cpp>=0.21.0 -tree-sitter-ruby>=0.21.0 -tree-sitter-php>=0.22.0 diff --git a/libs/openant-core/tests/test_declared_dependencies.py b/libs/openant-core/tests/test_declared_dependencies.py new file mode 100644 index 0000000..103d34e --- /dev/null +++ b/libs/openant-core/tests/test_declared_dependencies.py @@ -0,0 +1,127 @@ +"""Guard against pyproject.toml declared deps drifting from actual imports. + +Regression guard for the Claude Agent SDK migration (#25), which dropped +`anthropic` from pyproject.toml while leaving `import anthropic` live in +four files. Every clean install of openant broke at `openant parse`. +""" +import ast +import sys +import tomllib +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent +PACKAGED_DIRS = ["openant", "core", "utilities", "parsers", "prompts", "context", "report"] + +# Maps PyPI distribution names to their top-level import names when they differ. +# Extend only when adding a new dependency whose import name diverges from its +# PyPI name; the test will tell you which direction it's failing. +DIST_TO_IMPORT = { + "python-dotenv": "dotenv", + "pyyaml": "yaml", + "claude-agent-sdk": "claude_agent_sdk", + "tree-sitter": "tree_sitter", + "tree-sitter-c": "tree_sitter_c", + "tree-sitter-cpp": "tree_sitter_cpp", + "tree-sitter-ruby": "tree_sitter_ruby", + "tree-sitter-php": "tree_sitter_php", +} + + +def _dist_name_to_import(dist: str) -> str: + key = dist.lower().replace("_", "-") + return DIST_TO_IMPORT.get(key, dist.replace("-", "_").lower()) + + +def _declared_imports() -> set[str]: + with open(PROJECT_ROOT / "pyproject.toml", "rb") as f: + data = tomllib.load(f) + deps = data["project"]["dependencies"] + names = [] + for dep in deps: + for sep in ("[", ">=", "<=", "==", "!=", ">", "<", "~=", ";", " "): + dep = dep.split(sep, 1)[0] + names.append(dep.strip()) + return {_dist_name_to_import(n) for n in names if n} + + +def _collect_top_level_imports(root: Path) -> set[str]: + """Return the set of top-level module names imported anywhere under `root`.""" + imports: set[str] = set() + for py in root.rglob("*.py"): + try: + tree = ast.parse(py.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): + continue + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + imports.add(alias.name.split(".", 1)[0]) + elif isinstance(node, ast.ImportFrom): + # Relative imports (level > 0) have module=None or point at a + # sibling — they can't be third-party by definition. + if node.level == 0 and node.module: + imports.add(node.module.split(".", 1)[0]) + return imports + + +def _first_party_names() -> set[str]: + """Every module/package name reachable in the repo — treated as first-party. + + Parsers use sys.path hackery to import siblings as top-level names + (e.g. `from call_graph_builder import ...`), so the set of first-party + names isn't just the packaged top-level dirs. + """ + names: set[str] = set(PACKAGED_DIRS) + for path in PROJECT_ROOT.rglob("*.py"): + # Skip the managed dev venv and any other nested virtualenvs. + if ".venv" in path.parts or "site-packages" in path.parts: + continue + names.add(path.stem) + for parent in path.parents: + if parent == PROJECT_ROOT: + break + names.add(parent.name) + return names + + +def test_every_third_party_import_is_declared(): + first_party = _first_party_names() + stdlib = set(sys.stdlib_module_names) + declared = _declared_imports() + + all_imports: set[str] = set() + for pkg in PACKAGED_DIRS: + pkg_dir = PROJECT_ROOT / pkg + if pkg_dir.is_dir(): + all_imports |= _collect_top_level_imports(pkg_dir) + + # Deps pulled in transitively that some callsites import by name. These + # aren't direct deps of openant but are guaranteed present by something + # we *do* declare, so it's safe to treat them as allowed. + transitive_allowed = { + # pulled in by claude-agent-sdk + "mcp", + } + + third_party = all_imports - first_party - stdlib - transitive_allowed + missing = sorted(third_party - declared) + assert not missing, ( + f"Imports not declared in pyproject.toml dependencies: {missing}. " + "Either add the distribution to `dependencies`, or remove the import. " + "If a distribution's import name differs from its PyPI name, add it to " + "DIST_TO_IMPORT in this test." + ) + + +@pytest.mark.parametrize("pkg", PACKAGED_DIRS) +def test_package_imports_cleanly(pkg): + """Smoke-test: every packaged top-level module can be imported. + + This catches the specific failure mode from #25 — where a dropped dep + only manifested at `import utilities` time, not at `import openant`. + """ + if not (PROJECT_ROOT / pkg).is_dir(): + pytest.skip(f"{pkg} not present") + __import__(pkg)