From dfb9591af9ced31ed8d8b75e953043e081e7b201 Mon Sep 17 00:00:00 2001 From: joshbouncesecurity Date: Sun, 19 Apr 2026 18:04:14 +0300 Subject: [PATCH] fix: lazy-install JS parser npm deps on first use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openant parse fails with "Cannot find module 'ts-morph'" when the JS parser's node_modules directory hasn't been populated — there's no bootstrap step that runs npm install the way the Go CLI's runtime.go does for the Python venv. _parse_javascript now checks for parsers/javascript/node_modules/ before invoking Node, and shells out to npm install once if it's missing. Matches the venv's "first run installs, later runs are fast" UX, but scoped to actual JS parser use so Python/Go-only users don't need npm. Fails with a clear error if npm is not on PATH or install fails. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/openant-core/core/parser_adapter.py | 39 ++++++++ .../tests/test_js_parser_bootstrap.py | 90 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 libs/openant-core/tests/test_js_parser_bootstrap.py 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"