Skip to content

runner

_runner

Node-ecosystem subprocess runner — the npx pendant of core.runner.

core.runner.run_in_project is uv/Python-centric (it shells out through uv run). Node rules need the same guarantees — process-group isolation, timeout-kills-the-group, env-failure classification — but routed through the project's local node_modules/.bin (preferred) or npx (fallback).

This module reuses the env-failure verdict semantics from core.runner so a tool that fails to run never scores a green result.

ProcessVerdict

Bases: Enum

Centralized interpretation of a subprocess exit.

One source of truth for what a returncode means to a scored rule, so individual rules no longer re-derive returncode semantics (which is how an env-failure could silently score a green 100).

Members

CLEAN: the tool ran and found nothing (rc == 0). ISSUES: the tool ran and reported findings via an expected non-zero exit (e.g. ruff rc=1 with JSON findings). ENV_FAILURE: the tool did not actually complete the check (rc in :data:_ENV_FAILURE_RETURNCODES, or a timeout). A scored rule MUST fail loud on this verdict, never green.

Source code in packages/axm-audit/src/axm_audit/core/runner.py
Python
class ProcessVerdict(Enum):
    """Centralized interpretation of a subprocess exit.

    One source of truth for what a returncode *means* to a scored rule,
    so individual rules no longer re-derive returncode semantics
    (which is how an env-failure could silently score a green 100).

    Members:
        CLEAN: the tool ran and found nothing (rc == 0).
        ISSUES: the tool ran and reported findings via an expected
            non-zero exit (e.g. ruff rc=1 with JSON findings).
        ENV_FAILURE: the tool did not actually complete the check
            (rc in :data:`_ENV_FAILURE_RETURNCODES`, or a timeout).
            A scored rule MUST fail loud on this verdict, never green.
    """

    CLEAN = "clean"
    ISSUES = "issues"
    ENV_FAILURE = "env_failure"

interpret_process(result)

Classify a finished subprocess into a :class:ProcessVerdict.

This is the single home of the env-failure returncode set (:data:_ENV_FAILURE_RETURNCODES). Both the lint and type rules route their env-failure decision through here, removing the historical mypy-vs-lint asymmetry.

Parameters:

Name Type Description Default
result CompletedProcess[str]

The completed (or synthetic-on-timeout) subprocess.

required

Returns:

Type Description
ProcessVerdict

CLEAN when returncode == 0; ENV_FAILURE when the

ProcessVerdict

returncode is in the env-failure set; otherwise ISSUES

ProcessVerdict

(an expected non-zero exit carrying findings).

Source code in packages/axm-audit/src/axm_audit/core/runner.py
Python
def interpret_process(
    result: subprocess.CompletedProcess[str],
) -> ProcessVerdict:
    """Classify a finished subprocess into a :class:`ProcessVerdict`.

    This is the single home of the env-failure returncode set
    (:data:`_ENV_FAILURE_RETURNCODES`). Both the lint and type rules
    route their env-failure decision through here, removing the
    historical mypy-vs-lint asymmetry.

    Args:
        result: The completed (or synthetic-on-timeout) subprocess.

    Returns:
        ``CLEAN`` when ``returncode == 0``; ``ENV_FAILURE`` when the
        returncode is in the env-failure set; otherwise ``ISSUES``
        (an expected non-zero exit carrying findings).
    """
    if result.returncode == 0:
        return ProcessVerdict.CLEAN
    if result.returncode in _ENV_FAILURE_RETURNCODES:
        return ProcessVerdict.ENV_FAILURE
    return ProcessVerdict.ISSUES

node_tool_available(project_path, binary)

Return True if binary is actually installed for project_path.

A tool counts as available only if it resolves to a real executable in the project's local node_modules/.bin. A bare npx on PATH is not enough: npx --no-install of an uninstalled tool exits non-zero with no output, which a scorer would otherwise mistake for "ran clean, zero issues" (a false green). A serious project installs its lint/type toolchain in its devDependencies, so requiring the local binary is the correct contract.

Source code in packages/axm-audit/src/axm_audit/core/rules/node/_runner.py
Python
def node_tool_available(project_path: Path, binary: str) -> bool:
    """Return True if *binary* is actually installed for *project_path*.

    A tool counts as available only if it resolves to a real executable in the
    project's local ``node_modules/.bin``. A bare ``npx`` on PATH is **not**
    enough: ``npx --no-install`` of an uninstalled tool exits non-zero with no
    output, which a scorer would otherwise mistake for "ran clean, zero issues"
    (a false green). A serious project installs its lint/type toolchain in its
    devDependencies, so requiring the local binary is the correct contract.
    """
    local = project_path / "node_modules" / ".bin" / binary
    return local.is_file()

path_tool_available(binary)

Return True if binary resolves on the system PATH.

For tools that are not project-local node_modules binaries but global CLIs: npm (for npm audit) and gitleaks (a system install).

Source code in packages/axm-audit/src/axm_audit/core/rules/node/_runner.py
Python
def path_tool_available(binary: str) -> bool:
    """Return True if *binary* resolves on the system PATH.

    For tools that are not project-local node_modules binaries but global CLIs:
    ``npm`` (for ``npm audit``) and ``gitleaks`` (a system install).
    """
    return shutil.which(binary) is not None

run_node_tool(binary, args, project_path, *, timeout=_DEFAULT_TIMEOUT, on_path=False)

Run a Node CLI tool in project_path with process-group isolation.

Mirrors :func:axm_audit.core.runner.run_in_project: the child runs in its own process group and, on timeout, the whole group is killed and a synthetic returncode=124 result is returned so the caller can route it through :func:interpret_process as an ENV_FAILURE.

Parameters:

Name Type Description Default
binary str

Node CLI binary name (e.g. "eslint").

required
args list[str]

Arguments to pass to the binary.

required
project_path Path

Project root (cwd for the subprocess).

required
timeout int

Maximum seconds before the process group is killed.

_DEFAULT_TIMEOUT

Returns:

Type Description
CompletedProcess[str]

The completed process (stdout/stderr captured as text).

Source code in packages/axm-audit/src/axm_audit/core/rules/node/_runner.py
Python
def run_node_tool(
    binary: str,
    args: list[str],
    project_path: Path,
    *,
    timeout: int = _DEFAULT_TIMEOUT,
    on_path: bool = False,
) -> subprocess.CompletedProcess[str]:
    """Run a Node CLI tool in *project_path* with process-group isolation.

    Mirrors :func:`axm_audit.core.runner.run_in_project`: the child runs in its
    own process group and, on timeout, the whole group is killed and a synthetic
    ``returncode=124`` result is returned so the caller can route it through
    :func:`interpret_process` as an ``ENV_FAILURE``.

    Args:
        binary: Node CLI binary name (e.g. ``"eslint"``).
        args: Arguments to pass to the binary.
        project_path: Project root (cwd for the subprocess).
        timeout: Maximum seconds before the process group is killed.

    Returns:
        The completed process (stdout/stderr captured as text).
    """
    full_cmd = _resolve_cmd(project_path, binary, args, on_path=on_path)
    new_session, creation_flags = _process_group_isolation()
    proc = subprocess.Popen(  # noqa: S603
        full_cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        cwd=str(project_path),
        start_new_session=new_session,
        creationflags=creation_flags,
    )
    try:
        stdout, stderr = proc.communicate(timeout=timeout)
    except subprocess.TimeoutExpired:
        _kill_process_group(proc)
        proc.communicate()
        return subprocess.CompletedProcess(
            args=full_cmd,
            returncode=124,
            stdout="",
            stderr=f"Command timed out after {timeout}s",
        )
    return subprocess.CompletedProcess(
        args=full_cmd, returncode=proc.returncode, stdout=stdout, stderr=stderr
    )