Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions libs/openant-core/core/parser_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions libs/openant-core/tests/test_js_parser_bootstrap.py
Original file line number Diff line number Diff line change
@@ -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"
Loading