Skip to content

Commit dea921f

Browse files
committed
feat(view): config-aware remediation when argus-results.json is missing
When ``argus view`` couldn't find ``argus-results.json`` it just reported "file not found." The most common root cause is that the project's ``argus.yml`` doesn't list ``json`` in ``reporting.formats``, so the previous ``argus scan`` wrote terminal/markdown/SARIF but never the JSON file the viewers consume. Users had to guess. The error now identifies that root cause when it's the cause, and gives both a config fix and a CLI fix in the same output, so users don't need a second round-trip to figure out what to do. What changed - New ``argus.viewers.diagnose`` module (UI-free) with one public entry point ``diagnose_missing_results(path)`` returning a multi-line, remediation-rich error string. Walks up from cwd looking for argus.yml/yaml variants; if found and ``reporting.formats`` omits ``json``, emits a *targeted* hint ("Detected argus.yml at <path>, but reporting.formats is ['terminal', 'sarif'] — 'json' isn't included…"). Otherwise emits a *generic* hint with the same two fix paths. - ``argus/viewers/terminal/loader.py``: ``locate_results`` now raises ``FileNotFoundError`` with the diagnoser's output. Both viewers surface this verbatim, so the terminal interface and the browser interface get identical guidance. - ``argus/viewers/browser/app.py``: ``_resolve_scan`` "no results inside this dir" branch defers to the same diagnoser instead of its previous one-line error. - ``argus/cli.py``: new ``--check`` flag on ``argus view``. When set, runs the resolver, prints ``OK: <path> is readable.`` on success or the diagnoser remediation on failure, and exits without launching the viewer. Useful in CI and pre-flight checks. What deliberately didn't change - Successful view flows: untouched. Loader still returns the resolved path; the diagnoser only fires on the FileNotFoundError branch. - Auto-fallback to latest results: the loader already resolves the ``latest`` symlink convention; no change needed. Defensive paths covered - argus.yml that doesn't parse → falls back to generic hint (no traceback, no exception leakage from the error path). - argus.yml without a ``reporting`` key → generic hint. - ``reporting.formats`` not a list → generic hint. - Empty / "." path → retry hint shows ``argus view <results-dir>`` placeholder rather than a useless ``argus view .``. Tests (+22) - ``argus/tests/viewers/test_diagnose.py`` (15 cases): targeted / generic / config-with-json branches; unparseable YAML; alternate config filenames (.argus.yml, .argus.yaml, argus.yaml); walk-up behavior; retry-arg parent extraction; placeholder fallback. - ``argus/tests/viewers/terminal/test_loader.py`` (2 cases): regression that ``locate_results`` routes through the diagnoser in both branches (config-omits-json AND no-config). - ``argus/tests/test_cli.py`` (5 cases): ``--check`` flag default is False; flag parses to True; success exit path; failure exit path emits the diagnoser to stderr and returns EXIT_ERROR; ``view --help`` includes the new flag. Validation - Full SDK suite: 1420 passed (+22 from this change), 8 skipped. - Manual smoke test: ``argus view --check`` in cwd without results prints the full remediation; ``argus view --check /tmp/has-json`` prints ``OK: …``.
1 parent 79def1a commit dea921f

8 files changed

Lines changed: 458 additions & 10 deletions

File tree

argus/cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,13 @@ def _build_view_parser(subparsers: argparse._SubParsersAction) -> None:
230230
"stdout is a TTY; CI and other non-interactive contexts already "
231231
"skip auto-open without this flag.",
232232
)
233+
view_parser.add_argument(
234+
"--check",
235+
action="store_true",
236+
help="Validate that the resolved scan directory contains "
237+
"argus-results.json and print actionable remediation if not. "
238+
"Doesn't launch the viewer — useful in CI and pre-flight checks.",
239+
)
233240

234241

235242
def _resolve_view_args(args: argparse.Namespace) -> tuple[str, str | None] | None:
@@ -290,6 +297,15 @@ def cmd_view(args: argparse.Namespace) -> int:
290297
if resolved is None:
291298
return EXIT_ERROR
292299
interface, path = resolved
300+
301+
# --check short-circuits before launching the viewer: validate that
302+
# argus-results.json is reachable from the supplied path and print
303+
# remediation guidance if not. Useful in CI and as a pre-flight
304+
# check before a maintainer hands off "open this scan in argus
305+
# view" to a less-technical stakeholder.
306+
if getattr(args, "check", False):
307+
return _check_view_artifact(path)
308+
293309
return _launch_view(
294310
interface,
295311
path=path,
@@ -298,6 +314,25 @@ def cmd_view(args: argparse.Namespace) -> int:
298314
)
299315

300316

317+
def _check_view_artifact(path: str | None) -> int:
318+
"""Resolve ``path`` to an argus-results.json without launching the viewer.
319+
320+
Reuses the terminal loader's resolver so the success / failure
321+
message matches what a real ``argus view`` would surface. On
322+
success, prints the resolved path; on failure, prints the
323+
diagnoser's remediation output (config-aware hint identifying the
324+
likely root cause) to stderr.
325+
"""
326+
from argus.viewers.terminal.loader import locate_results
327+
try:
328+
resolved = locate_results(path)
329+
except FileNotFoundError as exc:
330+
print(str(exc), file=sys.stderr)
331+
return EXIT_ERROR
332+
print(f"OK: {resolved} is readable.")
333+
return EXIT_SUCCESS
334+
335+
301336
def _should_open_browser(args: argparse.Namespace) -> bool:
302337
"""Default to auto-opening the browser when stdout is a TTY.
303338

argus/tests/test_cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,41 @@ def test_view_default_args(self):
849849
assert args.interface_or_path is None
850850
assert args.interface_flag is None
851851
assert args.path_arg is None
852+
assert args.check is False
853+
854+
def test_view_check_flag_parses(self):
855+
parser = build_parser()
856+
args = parser.parse_args(["view", "--check"])
857+
assert args.check is True
858+
859+
def test_view_check_succeeds_when_results_present(self, tmp_path, capsys):
860+
"""--check resolves the path, finds argus-results.json, prints OK,
861+
and exits 0 without launching the viewer."""
862+
from argus.cli import _check_view_artifact, EXIT_SUCCESS
863+
(tmp_path / "argus-results.json").write_text("{}")
864+
865+
rc = _check_view_artifact(str(tmp_path))
866+
assert rc == EXIT_SUCCESS
867+
out = capsys.readouterr().out
868+
assert "OK:" in out
869+
assert "argus-results.json" in out
870+
871+
def test_view_check_emits_diagnoser_when_results_missing(self, tmp_path, capsys, monkeypatch):
872+
"""--check fails clean with the diagnoser's remediation message —
873+
no traceback, no launching viewer, EXIT_ERROR for CI gating."""
874+
from argus.cli import _check_view_artifact, EXIT_ERROR
875+
# No argus-results.json in tmp_path; no argus.yml either, so we
876+
# exercise the generic-hint branch.
877+
monkeypatch.chdir(tmp_path)
878+
879+
rc = _check_view_artifact(str(tmp_path))
880+
assert rc == EXIT_ERROR
881+
err = capsys.readouterr().err
882+
# Original missing-file diagnostic is preserved...
883+
assert "argus-results.json not found" in err
884+
# ...and accompanied by both fix paths.
885+
assert "argus scan --format json" in err
886+
assert "reporting.formats" in err
852887

853888
def test_view_positional_terminal(self):
854889
parser = build_parser()

argus/tests/viewers/terminal/test_loader.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,35 @@ def test_reannotates_missing_scanner_from_enclosing_result(self):
109109
summary = ScanSummary(results=[ScanResult(scanner="osv", findings=[f])])
110110
out = flatten_findings(summary)
111111
assert out[0].scanner == "osv"
112+
113+
114+
class TestMissingResultsRoutesThroughDiagnoser:
115+
"""Regression: locate_results' FileNotFoundError must include the
116+
config-aware remediation hint, not just the bare "file not found"
117+
message. Both viewers surface this exception verbatim."""
118+
119+
def test_error_includes_diagnoser_remediation(self, tmp_path, monkeypatch):
120+
# No argus.yml anywhere → generic-hint branch of the diagnoser.
121+
monkeypatch.chdir(tmp_path)
122+
with pytest.raises(FileNotFoundError) as excinfo:
123+
locate_results(str(tmp_path))
124+
msg = str(excinfo.value)
125+
# File is identified.
126+
assert RESULTS_FILENAME in msg
127+
# Both fix paths are surfaced.
128+
assert "argus scan --format json" in msg
129+
assert "reporting.formats" in msg
130+
131+
def test_error_calls_out_config_root_cause_when_json_omitted(self, tmp_path, monkeypatch):
132+
"""Targeted hint when argus.yml is present but missing 'json'."""
133+
(tmp_path / "argus.yml").write_text(
134+
"reporting:\n formats:\n - terminal\n - sarif\n"
135+
)
136+
monkeypatch.chdir(tmp_path)
137+
with pytest.raises(FileNotFoundError) as excinfo:
138+
locate_results(str(tmp_path))
139+
msg = str(excinfo.value)
140+
# Targeted-branch markers.
141+
assert "Detected" in msg
142+
assert "argus.yml" in msg
143+
assert "'terminal'" in msg and "'sarif'" in msg
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Tests for argus.viewers.diagnose — missing-results-JSON remediation hints.
2+
3+
Three branches matter:
4+
- argus.yml exists and `reporting.formats` omits `json` → targeted hint
5+
identifying the config root cause.
6+
- argus.yml exists and lists `json` → fall back to the generic hint
7+
(config isn't the cause).
8+
- no argus.yml found anywhere up the tree → generic hint.
9+
10+
Plus defensive paths: malformed YAML, missing reporting key, etc., all
11+
fall back to the generic hint without raising.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from pathlib import Path
17+
18+
from argus.viewers.diagnose import (
19+
_find_nearby_argus_config,
20+
_read_reporting_formats,
21+
diagnose_missing_results,
22+
)
23+
24+
25+
# ───────────────────────────────────────────────
26+
# diagnose_missing_results — public surface
27+
# ───────────────────────────────────────────────
28+
29+
30+
class TestDiagnoseMissingResults:
31+
def test_targeted_hint_when_config_omits_json(self, tmp_path, monkeypatch):
32+
"""argus.yml present, json NOT in reporting.formats → message names
33+
the file, the actual formats value, and offers a one-shot CLI fix
34+
plus the config-edit fix."""
35+
config = tmp_path / "argus.yml"
36+
config.write_text(
37+
"reporting:\n formats:\n - terminal\n - sarif\n"
38+
)
39+
monkeypatch.chdir(tmp_path)
40+
41+
msg = diagnose_missing_results(tmp_path / "argus-results.json")
42+
43+
# Required: identifies the file.
44+
assert "argus-results.json" in msg
45+
# Required: message names config root cause.
46+
assert "argus.yml" in msg
47+
assert "reporting.formats" in msg
48+
assert "'terminal'" in msg and "'sarif'" in msg
49+
# Required: at least one config fix and one CLI fix.
50+
assert "Add 'json' to reporting.formats" in msg
51+
assert "argus scan --format json" in msg
52+
# Required: retry instruction echoes the searched path.
53+
assert "argus view" in msg
54+
55+
def test_generic_hint_when_no_argus_yml(self, tmp_path, monkeypatch):
56+
"""No argus.yml anywhere up the tree → generic hint, no false claim
57+
about config being the cause."""
58+
# Use an isolated tmp dir as cwd; walk-up shouldn't find any
59+
# config because tmp dirs are well outside the repo root.
60+
sub = tmp_path / "isolated"
61+
sub.mkdir()
62+
monkeypatch.chdir(sub)
63+
64+
msg = diagnose_missing_results(sub / "argus-results.json")
65+
66+
# Still identifies the file + still gives both fix paths.
67+
assert "argus-results.json" in msg
68+
assert "Add 'json' to reporting.formats in argus.yml" in msg
69+
assert "argus scan --format json" in msg
70+
# Doesn't make a confident claim about a config we never found.
71+
assert "Detected" not in msg
72+
73+
def test_generic_hint_when_config_includes_json(self, tmp_path, monkeypatch):
74+
"""Config has json — bug is somewhere else (wrong path, scan never
75+
ran, output dir mismatch). Don't blame the config."""
76+
config = tmp_path / "argus.yml"
77+
config.write_text(
78+
"reporting:\n formats:\n - terminal\n - json\n"
79+
)
80+
monkeypatch.chdir(tmp_path)
81+
82+
msg = diagnose_missing_results(tmp_path / "argus-results.json")
83+
84+
# Generic hint path — not the targeted one.
85+
assert "Detected" not in msg
86+
# Both fixes still listed (user might still need to run the scan).
87+
assert "argus scan --format json" in msg
88+
89+
def test_unparseable_argus_yml_falls_back_to_generic(self, tmp_path, monkeypatch):
90+
"""Broken YAML must NOT mask the original missing-file diagnostic
91+
with a parse-error traceback. Falls back to the generic hint."""
92+
config = tmp_path / "argus.yml"
93+
config.write_text("reporting:\n formats: [terminal,, : :")
94+
monkeypatch.chdir(tmp_path)
95+
96+
msg = diagnose_missing_results(tmp_path / "argus-results.json")
97+
98+
# No traceback / exception leakage.
99+
assert "Traceback" not in msg
100+
assert "yaml" not in msg.lower()
101+
# Falls back to generic path.
102+
assert "Detected" not in msg
103+
assert "argus-results.json" in msg
104+
105+
def test_argus_yml_without_reporting_key_falls_back(self, tmp_path, monkeypatch):
106+
"""Config exists but doesn't define reporting.formats → can't make
107+
a confident 'json missing from formats' claim, generic hint fires."""
108+
config = tmp_path / "argus.yml"
109+
config.write_text("scanners:\n bandit:\n enabled: true\n")
110+
monkeypatch.chdir(tmp_path)
111+
112+
msg = diagnose_missing_results(tmp_path / "argus-results.json")
113+
assert "Detected" not in msg
114+
115+
def test_retry_arg_uses_parent_for_results_filename(self, tmp_path, monkeypatch):
116+
"""When the searched path ends in argus-results.json, the retry
117+
hint should point at its parent dir (what argus view actually
118+
accepts), not the JSON file itself."""
119+
config = tmp_path / "argus.yml"
120+
config.write_text("reporting:\n formats: [terminal]\n")
121+
monkeypatch.chdir(tmp_path)
122+
123+
run_dir = tmp_path / "argus-results" / "2026-05-05T10-00-00Z"
124+
msg = diagnose_missing_results(run_dir / "argus-results.json")
125+
126+
assert f"argus view {run_dir}" in msg
127+
128+
def test_retry_arg_falls_back_to_placeholder_for_empty_path(self, tmp_path, monkeypatch):
129+
"""A relative '.' or empty path renders as a literal placeholder
130+
rather than a useless 'argus view .' line."""
131+
monkeypatch.chdir(tmp_path)
132+
msg = diagnose_missing_results(Path("."))
133+
assert "argus view <results-dir>" in msg
134+
135+
136+
# ───────────────────────────────────────────────
137+
# Helpers exercised separately for confidence at boundaries
138+
# ───────────────────────────────────────────────
139+
140+
141+
class TestFindNearbyArgusConfig:
142+
def test_finds_in_starting_dir(self, tmp_path):
143+
config = tmp_path / "argus.yml"
144+
config.write_text("# empty\n")
145+
assert _find_nearby_argus_config(tmp_path) == config
146+
147+
def test_walks_up_to_find_config(self, tmp_path):
148+
nested = tmp_path / "a" / "b" / "c"
149+
nested.mkdir(parents=True)
150+
config = tmp_path / "argus.yml"
151+
config.write_text("# empty\n")
152+
assert _find_nearby_argus_config(nested) == config
153+
154+
def test_returns_none_when_no_config_in_tree(self, tmp_path):
155+
# tmp_path's tree is well-isolated from any repo with argus.yml.
156+
assert _find_nearby_argus_config(tmp_path) is None
157+
158+
def test_recognises_alternate_config_names(self, tmp_path):
159+
# argus.yaml and the dotted variants are all valid filenames.
160+
for name in ("argus.yaml", ".argus.yml", ".argus.yaml"):
161+
sub = tmp_path / name.replace(".", "_")
162+
sub.mkdir()
163+
config = sub / name
164+
config.write_text("# empty\n")
165+
assert _find_nearby_argus_config(sub) == config
166+
167+
168+
class TestReadReportingFormats:
169+
def test_reads_formats_list(self, tmp_path):
170+
config = tmp_path / "argus.yml"
171+
config.write_text(
172+
"reporting:\n formats:\n - terminal\n - json\n"
173+
)
174+
assert _read_reporting_formats(config) == ["terminal", "json"]
175+
176+
def test_returns_none_for_missing_reporting_key(self, tmp_path):
177+
config = tmp_path / "argus.yml"
178+
config.write_text("scanners:\n bandit:\n enabled: true\n")
179+
assert _read_reporting_formats(config) is None
180+
181+
def test_returns_none_for_unparseable_yaml(self, tmp_path):
182+
config = tmp_path / "argus.yml"
183+
config.write_text("reporting: [{")
184+
assert _read_reporting_formats(config) is None
185+
186+
def test_returns_none_when_formats_isnt_a_list(self, tmp_path):
187+
config = tmp_path / "argus.yml"
188+
config.write_text("reporting:\n formats: terminal\n")
189+
assert _read_reporting_formats(config) is None

argus/viewers/browser/app.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,13 @@ def _resolve_scan(
145145
f"Use the picker to choose a specific run."
146146
)
147147

148-
return None, (
149-
f"No {RESULTS_FILENAME} inside {target}. "
150-
"Pick a results directory or pass a specific JSON path via ?scan=..."
151-
)
148+
# No results anywhere under the target. Defer to the shared
149+
# diagnoser so the message identifies the likely root cause —
150+
# most often the user's argus.yml lists 'reporting.formats'
151+
# without 'json', so the previous scan never wrote the file
152+
# the viewers consume.
153+
from argus.viewers.diagnose import diagnose_missing_results
154+
return None, diagnose_missing_results(direct)
152155

153156
return None, f"Unsupported path kind: {target}"
154157

0 commit comments

Comments
 (0)