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
38 changes: 37 additions & 1 deletion scripts/codelens.py
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,41 @@ def _check_staleness(workspace: str, args) -> Optional[Dict[str, Any]]:

# ─── CLI Entry Point ──────────────────────────────────────────

# Formats an agent parses off stdout. For these, a CLI arg error must appear
# on stdout as JSON — not on stderr with an empty stdout, which reads as
# "no results" and silently misleads the agent (issue #315).
_MACHINE_FORMATS = frozenset({
"json", "compact", "ai", "sarif", "graphml", "junit-xml", "gitlab-sast",
})


def _argv_format(argv):
"""The --format value from a raw argv, or None."""
for i, a in enumerate(argv):
if a == "--format" and i + 1 < len(argv):
return argv[i + 1]
if a.startswith("--format="):
return a.split("=", 1)[1]
return None


class _StdoutErrorParser(argparse.ArgumentParser):
"""ArgumentParser that reports arg errors on stdout as JSON for machine
formats, so an agent can always tell an error from an empty result."""

def error(self, message):
if _argv_format(sys.argv) in _MACHINE_FORMATS:
import json as _json
print(_json.dumps({
"s": "error",
"error": message,
"error_type": "cli_argument",
"usage": self.format_usage().strip(),
}))
self.exit(2)
super().error(message)


def main():
# Command count is derived from the visible (non-hidden) command set at
# runtime so it can never drift from the actual number of umbrella
Expand All @@ -1090,7 +1125,7 @@ def main():
_visible_registry = _get_visible_commands()
_command_count = len(_visible_registry)

parser = argparse.ArgumentParser(
parser = _StdoutErrorParser(
description=(
f"CodeLens v{CODELENS_VERSION} — Live Codebase Reference Intelligence "
f"(Tree-sitter Edition). {_command_count} commands available; run "
Expand All @@ -1109,6 +1144,7 @@ def main():
)
subparsers = parser.add_subparsers(
dest="command",
parser_class=_StdoutErrorParser,
help="Available commands",
# Issue #195: the default metavar lists every choice including
# hidden commands. Override with only the 12 visible umbrella
Expand Down
65 changes: 65 additions & 0 deletions tests/test_cli_error_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
Tests for CLI arg errors surfacing on stdout for machine formats (issue #315).

An argparse error used to print to stderr and leave stdout empty, so an agent
parsing stdout saw "no results" instead of an error. For machine formats the
error must appear on stdout as JSON.

Subprocess-based (argparse lives at the process entry point). Every call passes
timeout= so a hang fails fast rather than stalling the suite (the #303 lesson).
"""

import json
import os
import subprocess
import sys

import pytest

CODELENS = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"scripts", "codelens.py",
)


def _run(args):
return subprocess.run(
[sys.executable, CODELENS, *args],
capture_output=True, text=True, timeout=60,
)


def test_arg_error_is_json_on_stdout_for_compact():
# `.` is taken as the pattern; `execute` is an unrecognized extra arg.
r = _run(["search", ".", "--mode", "symbol", "execute", "--format", "compact"])

assert r.stdout.strip(), "stdout must not be empty on a CLI arg error"
payload = json.loads(r.stdout)
assert payload["s"] == "error"
assert payload["error_type"] == "cli_argument"


def test_arg_error_is_json_on_stdout_for_json():
r = _run(["search", ".", "--mode", "symbol", "execute", "--format", "json"])

payload = json.loads(r.stdout)
assert payload["s"] == "error"


def test_human_mode_keeps_error_on_stderr():
"""No machine format: the friendly stderr behaviour is unchanged."""
r = _run(["search", ".", "--mode", "symbol", "execute"])

assert "unrecognized arguments" in r.stderr
assert not r.stdout.strip()


def test_valid_call_is_unaffected():
"""The error path must not touch normal parsing."""
r = _run(["--command-count"])
assert r.returncode == 0
assert r.stdout.strip().isdigit()


if __name__ == "__main__":
pytest.main([__file__, "-v"])
62 changes: 34 additions & 28 deletions tests/test_hybrid_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class TestHybridIntegration:
def test_lsp_status_cli(self):
result = subprocess.run(
[sys.executable, os.path.join(SCRIPT_DIR, "codelens.py"), "--lsp-status"],
capture_output=True, text=True, cwd=CODELENS_ROOT
capture_output=True, text=True, cwd=CODELENS_ROOT, timeout=60
)
combined = result.stdout + result.stderr
assert "pyright" in combined or result.returncode == 0
Expand All @@ -95,7 +95,7 @@ def test_query_confidence_without_deep(self):
result = subprocess.run(
[sys.executable, os.path.join(SCRIPT_DIR, "codelens.py"),
"query", "detect_dead_code", CODELENS_ROOT, "--format", "json"],
capture_output=True, text=True
capture_output=True, text=True, timeout=60
)
idx = result.stdout.find("{")
if idx >= 0:
Expand All @@ -104,27 +104,31 @@ def test_query_confidence_without_deep(self):
assert data["confidence"] in ("high", "medium", "low")

def test_impact_confidence_without_deep(self):
# Post-#195 umbrella form: `impact <ws> --name X` (was `impact X <ws>`,
# which errors — the old form left stdout empty so this test passed
# vacuously until #315 surfaced the error on stdout).
result = subprocess.run(
[sys.executable, os.path.join(SCRIPT_DIR, "codelens.py"),
"impact", "detect_dead_code", CODELENS_ROOT, "--format", "json"],
capture_output=True, text=True
"impact", CODELENS_ROOT, "--name", "detect_dead_code", "--format", "json"],
capture_output=True, text=True, timeout=60,
)
idx = result.stdout.find("{")
if idx >= 0:
data = json.loads(result.stdout[idx:])
assert "confidence" in data
assert idx >= 0, result.stdout + result.stderr
data = json.loads(result.stdout[idx:])
assert "confidence" in data["r"][0]

def test_ai_format_confidence_distribution(self):
# Post-#195: `audit --check dead-code` (was the dropped `dead-code`
# top-level command). ai stats are namespaced per check (#306).
result = subprocess.run(
[sys.executable, os.path.join(SCRIPT_DIR, "codelens.py"),
"dead-code", CODELENS_ROOT, "--format", "ai", "--top", "5"],
capture_output=True, text=True
"audit", CODELENS_ROOT, "--check", "dead-code", "--format", "ai", "--top", "5"],
capture_output=True, text=True, timeout=60,
)
idx = result.stdout.find("{")
if idx >= 0:
data = json.loads(result.stdout[idx:])
stats = data.get("stats", {})
assert "confidence_distribution" in stats
assert idx >= 0, result.stdout + result.stderr
data = json.loads(result.stdout[idx:])
assert "confidence_distribution" in data["stats"]["dead-code"]

def test_deep_with_pyright(self):
from lsp_client import detect_available_servers
Expand All @@ -134,7 +138,7 @@ def test_deep_with_pyright(self):
result = subprocess.run(
[sys.executable, os.path.join(SCRIPT_DIR, "codelens.py"),
"query", "detect_dead_code", CODELENS_ROOT, "--deep", "--format", "json"],
capture_output=True, text=True
capture_output=True, text=True, timeout=60
)
idx = result.stdout.find("{")
if idx >= 0:
Expand All @@ -143,23 +147,25 @@ def test_deep_with_pyright(self):
assert data.get("lsp_active") is True

def test_dead_code_confidence_fields(self):
# Post-#195: `audit --check dead-code`; results live under the sub-check
# envelope r[0].
result = subprocess.run(
[sys.executable, os.path.join(SCRIPT_DIR, "codelens.py"),
"dead-code", CODELENS_ROOT, "--format", "json", "--top", "5"],
capture_output=True, text=True
"audit", CODELENS_ROOT, "--check", "dead-code", "--format", "json", "--top", "5"],
capture_output=True, text=True, timeout=60,
)
idx = result.stdout.find("{")
if idx >= 0:
data = json.loads(result.stdout[idx:])
results = data.get("results", {})
found_confidence = False
for cat, items in results.items():
if isinstance(items, list):
for item in items[:3]:
if isinstance(item, dict) and "confidence" in item:
found_confidence = True
break
assert found_confidence
assert idx >= 0, result.stdout + result.stderr
data = json.loads(result.stdout[idx:])
results = data["r"][0].get("results", {})
found_confidence = False
for cat, items in results.items():
if isinstance(items, list):
for item in items[:3]:
if isinstance(item, dict) and "confidence" in item:
found_confidence = True
break
assert found_confidence

def test_deep_graceful_degradation(self):
import tempfile
Expand All @@ -171,7 +177,7 @@ def test_deep_graceful_degradation(self):
result = subprocess.run(
[sys.executable, os.path.join(SCRIPT_DIR, "codelens.py"),
"audit", tmpdir, "--check", "dead-code", "--deep"],
capture_output=True, text=True
capture_output=True, text=True, timeout=60
)
assert result.returncode == 0

Expand Down
Loading