diff --git a/libs/openant-core/core/parser_adapter.py b/libs/openant-core/core/parser_adapter.py index d52c89f..b705db2 100644 --- a/libs/openant-core/core/parser_adapter.py +++ b/libs/openant-core/core/parser_adapter.py @@ -11,6 +11,7 @@ import json import os +import shutil import subprocess import sys from pathlib import Path @@ -21,6 +22,9 @@ # Root of openant-core (where parsers/ lives) _CORE_ROOT = Path(__file__).parent.parent +# JS parser directory (holds its own package.json / node_modules) +_JS_PARSER_DIR = _CORE_ROOT / "parsers" / "javascript" + # Shared language detection config (single source of truth: config/languages.json) _LANGUAGES_CONFIG = Path(__file__).parent.parent.parent.parent / "config" / "languages.json" @@ -324,12 +328,47 @@ def _parse_python(repo_path: str, output_dir: str, processing_level: str, skip_t # JavaScript/TypeScript parser # --------------------------------------------------------------------------- +def _ensure_js_parser_dependencies() -> None: + """Install the JS parser's Node dependencies on first use. + + Mirrors the Go CLI's venv bootstrap (apps/openant-cli/internal/python/runtime.go): + the first invocation installs, subsequent invocations are a no-op. Runs only + when a JS repo is actually being parsed, so Python/Go-only users never need npm. + """ + if (_JS_PARSER_DIR / "node_modules").is_dir(): + return + + npm = shutil.which("npm") + if npm is None: + raise RuntimeError( + "JavaScript parser dependencies are not installed and `npm` is not on PATH. " + f"Install Node.js/npm, then run: npm install (from {_JS_PARSER_DIR})" + ) + + print( + f"[Parser] Installing JS parser dependencies (first run, this may take a minute)...", + file=sys.stderr, + ) + result = subprocess.run( + [npm, "install"], + cwd=str(_JS_PARSER_DIR), + stdout=sys.stderr, + stderr=sys.stderr, + ) + if result.returncode != 0: + raise RuntimeError( + f"`npm install` failed in {_JS_PARSER_DIR} with exit code {result.returncode}" + ) + + def _parse_javascript(repo_path: str, output_dir: str, processing_level: str, skip_tests: bool = True, name: str = None) -> ParseResult: """Invoke the JavaScript/TypeScript parser. The JS parser is a PipelineTest class that runs Node.js subprocesses. We invoke it via subprocess to avoid the sys.path hacks. """ + _ensure_js_parser_dependencies() + print("[Parser] Running JavaScript parser...", file=sys.stderr) # Build command — analyzer-path now defaults to co-located file in the parser diff --git a/libs/openant-core/tests/test_js_parser_bootstrap.py b/libs/openant-core/tests/test_js_parser_bootstrap.py new file mode 100644 index 0000000..698efc9 --- /dev/null +++ b/libs/openant-core/tests/test_js_parser_bootstrap.py @@ -0,0 +1,90 @@ +"""Tests for the JS parser's lazy npm-install bootstrap. + +Covers `_ensure_js_parser_dependencies` in core.parser_adapter: behavior when +node_modules is present, missing, npm is unavailable, or `npm install` fails. +These tests monkeypatch subprocess and shutil.which so they don't need Node. +""" +from pathlib import Path + +import pytest + +from core import parser_adapter + + +@pytest.fixture +def fake_parser_dir(tmp_path, monkeypatch): + """Point _JS_PARSER_DIR at a tmp dir so tests don't touch the real one.""" + monkeypatch.setattr(parser_adapter, "_JS_PARSER_DIR", tmp_path) + return tmp_path + + +def test_skips_install_when_node_modules_present(fake_parser_dir, monkeypatch): + (fake_parser_dir / "node_modules").mkdir() + + calls = [] + monkeypatch.setattr(parser_adapter.subprocess, "run", lambda *a, **kw: calls.append((a, kw))) + monkeypatch.setattr(parser_adapter.shutil, "which", lambda name: "/usr/bin/npm") + + parser_adapter._ensure_js_parser_dependencies() + + assert calls == [] + + +def test_runs_npm_install_when_node_modules_missing(fake_parser_dir, monkeypatch): + calls = [] + + class _Ok: + returncode = 0 + + def _fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + return _Ok() + + monkeypatch.setattr(parser_adapter.subprocess, "run", _fake_run) + monkeypatch.setattr(parser_adapter.shutil, "which", lambda name: "/usr/bin/npm") + + parser_adapter._ensure_js_parser_dependencies() + + assert len(calls) == 1 + cmd, kwargs = calls[0] + assert cmd == ["/usr/bin/npm", "install"] + assert kwargs["cwd"] == str(fake_parser_dir) + + +def test_raises_when_npm_not_on_path(fake_parser_dir, monkeypatch): + monkeypatch.setattr(parser_adapter.shutil, "which", lambda name: None) + + with pytest.raises(RuntimeError, match="npm"): + parser_adapter._ensure_js_parser_dependencies() + + +def test_raises_when_npm_install_fails(fake_parser_dir, monkeypatch): + class _Fail: + returncode = 1 + + monkeypatch.setattr(parser_adapter.subprocess, "run", lambda *a, **kw: _Fail()) + monkeypatch.setattr(parser_adapter.shutil, "which", lambda name: "/usr/bin/npm") + + with pytest.raises(RuntimeError, match="npm install.*exit code 1"): + parser_adapter._ensure_js_parser_dependencies() + + +def test_parse_javascript_surfaces_bootstrap_error(fake_parser_dir, monkeypatch): + """When bootstrap fails, _parse_javascript must not run the Node subprocess.""" + monkeypatch.setattr(parser_adapter.shutil, "which", lambda name: None) + + ran_node = [] + monkeypatch.setattr( + parser_adapter.subprocess, + "run", + lambda *a, **kw: ran_node.append((a, kw)), + ) + + with pytest.raises(RuntimeError, match="npm"): + parser_adapter._parse_javascript( + repo_path="/tmp/fake-repo", + output_dir="/tmp/fake-out", + processing_level="all", + ) + + assert ran_node == [], "Node subprocess should not run when bootstrap fails"