Skip to content

Commit 2e4b0b9

Browse files
committed
Add FailMap JSON CLI output
1 parent 1690d4f commit 2e4b0b9

3 files changed

Lines changed: 203 additions & 0 deletions

File tree

projects/failmap/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ failmap issue-bundle-summary examples/bundle/bundle.json
4242
failmap trend examples/trends.json examples/baseline_clusters.json examples/candidate_clusters.json examples/release3_clusters.json
4343
failmap trend-summary examples/trends.json
4444
failmap trend-markdown examples/trends.json examples/trends.md
45+
failmap summarize examples/clusters.json --json
4546
```
4647

4748
## Example output
@@ -53,6 +54,13 @@ By priority:
5354
- P0: 2
5455
By owner:
5556
- tooling: 2
57+
58+
$ failmap summarize examples/clusters.json --json
59+
{
60+
"case_count": 3,
61+
"cluster_count": 2,
62+
"format": "failmap-v1"
63+
}
5664
```
5765

5866
Example metadata emitted into each issue draft:
@@ -110,8 +118,11 @@ failmap issue-bundle-summary path/to/bundle.json
110118
failmap trend path/to/trends.json baseline.json candidate.json release3.json
111119
failmap trend-summary path/to/trends.json
112120
failmap trend-markdown path/to/trends.json path/to/report.md
121+
failmap compare-summary path/to/compare.json --json
113122
```
114123

124+
All summary-style commands support `--json`, and write commands like `cluster`, `compare`, `issue-drafts`, `issue-bundle`, and `trend` can also emit machine-readable output metadata.
125+
115126
## Cluster fields
116127

117128
Each cluster contains:

projects/failmap/src/failmap/cli.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import argparse
4+
import json
45
from pathlib import Path
56
import sys
67

@@ -20,61 +21,118 @@
2021
from .trends import build_trend_report
2122

2223

24+
def _print_json(data: dict[str, object]) -> None:
25+
print(json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False))
26+
27+
28+
def _add_json_flag(parser: argparse.ArgumentParser) -> None:
29+
parser.add_argument("--json", action="store_true", help="print machine-readable JSON output")
30+
31+
2332
def _cmd_cluster(args: argparse.Namespace) -> int:
2433
payload = build_clusters(args.pack)
2534
write_json(args.output, payload)
35+
if args.json:
36+
response = dict(payload)
37+
response["output"] = str(args.output)
38+
_print_json(response)
39+
return 0
2640
print(f"Wrote clusters to {args.output}")
2741
return 0
2842

2943

3044
def _cmd_summarize(args: argparse.Namespace) -> int:
3145
payload = load_clusters(args.path)
46+
if args.json:
47+
_print_json(payload)
48+
return 0
3249
print(summarize_clusters(payload))
3350
return 0
3451

3552

3653
def _cmd_markdown(args: argparse.Namespace) -> int:
3754
payload = load_clusters(args.path)
3855
Path(args.output).write_text(markdown_report(payload), encoding="utf-8")
56+
if args.json:
57+
_print_json(
58+
{
59+
"output": str(args.output),
60+
"cluster_count": int(payload.get("cluster_count", 0)),
61+
"case_count": int(payload.get("case_count", 0)),
62+
"format": "markdown",
63+
}
64+
)
65+
return 0
3966
print(f"Wrote report to {args.output}")
4067
return 0
4168

4269

4370
def _cmd_compare(args: argparse.Namespace) -> int:
4471
payload = compare_cluster_files(args.baseline, args.candidate)
4572
write_json(args.output, payload)
73+
if args.json:
74+
response = dict(payload)
75+
response["output"] = str(args.output)
76+
_print_json(response)
77+
return 0
4678
print(f"Wrote compare report to {args.output}")
4779
return 0
4880

4981

5082
def _cmd_compare_summary(args: argparse.Namespace) -> int:
5183
payload = load_clusters(args.path)
84+
if args.json:
85+
_print_json(payload)
86+
return 0
5287
print(summarize_compare(payload))
5388
return 0
5489

5590

5691
def _cmd_compare_markdown(args: argparse.Namespace) -> int:
5792
payload = load_clusters(args.path)
5893
Path(args.output).write_text(markdown_compare_report(payload), encoding="utf-8")
94+
if args.json:
95+
_print_json(
96+
{
97+
"output": str(args.output),
98+
"cluster_count": int(payload.get("cluster_count", 0)),
99+
"summary": dict(payload.get("summary", {})),
100+
"format": "markdown",
101+
}
102+
)
103+
return 0
59104
print(f"Wrote compare markdown to {args.output}")
60105
return 0
61106

62107

63108
def _cmd_issue_drafts(args: argparse.Namespace) -> int:
64109
statuses = set(args.status) if args.status else None
65110
manifest = generate_issue_drafts(args.path, args.output_dir, include_statuses=statuses, rules_path=args.rules)
111+
if args.json:
112+
response = dict(manifest)
113+
response["output_dir"] = str(args.output_dir)
114+
_print_json(response)
115+
return 0
66116
print(f"Wrote {manifest['draft_count']} issue drafts to {args.output_dir}")
67117
return 0
68118

69119

70120
def _cmd_issue_bundle(args: argparse.Namespace) -> int:
71121
bundle = build_issue_bundle(args.issues_path, args.output_dir)
122+
if args.json:
123+
response = dict(bundle)
124+
response["output_dir"] = str(args.output_dir)
125+
_print_json(response)
126+
return 0
72127
print(f"Wrote issue bundle with {bundle['draft_count']} drafts to {args.output_dir}")
73128
return 0
74129

75130

76131
def _cmd_issue_bundle_summary(args: argparse.Namespace) -> int:
77132
bundle = load_issue_manifest(args.path)
133+
if args.json:
134+
_print_json(bundle)
135+
return 0
78136
draft_count = int(bundle.get("draft_count", 0))
79137
print(f"Drafts: {draft_count}")
80138
print("By priority:")
@@ -89,19 +147,37 @@ def _cmd_issue_bundle_summary(args: argparse.Namespace) -> int:
89147
def _cmd_trend(args: argparse.Namespace) -> int:
90148
payload = build_trend_report(args.paths)
91149
write_json(args.output, payload)
150+
if args.json:
151+
response = dict(payload)
152+
response["output"] = str(args.output)
153+
_print_json(response)
154+
return 0
92155
print(f"Wrote trend report to {args.output}")
93156
return 0
94157

95158

96159
def _cmd_trend_summary(args: argparse.Namespace) -> int:
97160
payload = load_clusters(args.path)
161+
if args.json:
162+
_print_json(payload)
163+
return 0
98164
print(summarize_trends(payload))
99165
return 0
100166

101167

102168
def _cmd_trend_markdown(args: argparse.Namespace) -> int:
103169
payload = load_clusters(args.path)
104170
Path(args.output).write_text(markdown_trend_report(payload), encoding="utf-8")
171+
if args.json:
172+
_print_json(
173+
{
174+
"output": str(args.output),
175+
"snapshot_count": int(payload.get("snapshot_count", 0)),
176+
"signature_count": int(payload.get("signature_count", 0)),
177+
"format": "markdown",
178+
}
179+
)
180+
return 0
105181
print(f"Wrote trend markdown to {args.output}")
106182
return 0
107183

@@ -113,30 +189,36 @@ def build_parser() -> argparse.ArgumentParser:
113189
cluster = subparsers.add_parser("cluster", help="cluster failures from a TracePack pack")
114190
cluster.add_argument("pack")
115191
cluster.add_argument("output")
192+
_add_json_flag(cluster)
116193
cluster.set_defaults(func=_cmd_cluster)
117194

118195
summarize = subparsers.add_parser("summarize", help="summarize a clusters json file")
119196
summarize.add_argument("path")
197+
_add_json_flag(summarize)
120198
summarize.set_defaults(func=_cmd_summarize)
121199

122200
markdown = subparsers.add_parser("markdown", help="render a markdown report from clusters json")
123201
markdown.add_argument("path")
124202
markdown.add_argument("output")
203+
_add_json_flag(markdown)
125204
markdown.set_defaults(func=_cmd_markdown)
126205

127206
compare = subparsers.add_parser("compare", help="compare two cluster json files")
128207
compare.add_argument("baseline")
129208
compare.add_argument("candidate")
130209
compare.add_argument("output")
210+
_add_json_flag(compare)
131211
compare.set_defaults(func=_cmd_compare)
132212

133213
compare_summary = subparsers.add_parser("compare-summary", help="summarize a compare json file")
134214
compare_summary.add_argument("path")
215+
_add_json_flag(compare_summary)
135216
compare_summary.set_defaults(func=_cmd_compare_summary)
136217

137218
compare_markdown = subparsers.add_parser("compare-markdown", help="render markdown from a compare json file")
138219
compare_markdown.add_argument("path")
139220
compare_markdown.add_argument("output")
221+
_add_json_flag(compare_markdown)
140222
compare_markdown.set_defaults(func=_cmd_compare_markdown)
141223

142224
issue_drafts = subparsers.add_parser("issue-drafts", help="generate issue-ready markdown drafts from a compare json file")
@@ -149,29 +231,35 @@ def build_parser() -> argparse.ArgumentParser:
149231
choices=["new", "resolved", "growing", "shrinking", "unchanged"],
150232
help="limit generated drafts to selected statuses; can be passed multiple times",
151233
)
234+
_add_json_flag(issue_drafts)
152235
issue_drafts.set_defaults(func=_cmd_issue_drafts)
153236

154237
issue_bundle = subparsers.add_parser("issue-bundle", help="group issue drafts into a batch triage bundle")
155238
issue_bundle.add_argument("issues_path")
156239
issue_bundle.add_argument("output_dir")
240+
_add_json_flag(issue_bundle)
157241
issue_bundle.set_defaults(func=_cmd_issue_bundle)
158242

159243
issue_bundle_summary = subparsers.add_parser("issue-bundle-summary", help="summarize an issue bundle json file")
160244
issue_bundle_summary.add_argument("path")
245+
_add_json_flag(issue_bundle_summary)
161246
issue_bundle_summary.set_defaults(func=_cmd_issue_bundle_summary)
162247

163248
trend = subparsers.add_parser("trend", help="build a trend report from multiple cluster snapshots")
164249
trend.add_argument("output")
165250
trend.add_argument("paths", nargs="+")
251+
_add_json_flag(trend)
166252
trend.set_defaults(func=_cmd_trend)
167253

168254
trend_summary = subparsers.add_parser("trend-summary", help="summarize a trend json file")
169255
trend_summary.add_argument("path")
256+
_add_json_flag(trend_summary)
170257
trend_summary.set_defaults(func=_cmd_trend_summary)
171258

172259
trend_markdown = subparsers.add_parser("trend-markdown", help="render markdown from a trend json file")
173260
trend_markdown.add_argument("path")
174261
trend_markdown.add_argument("output")
262+
_add_json_flag(trend_markdown)
175263
trend_markdown.set_defaults(func=_cmd_trend_markdown)
176264

177265
return parser

projects/failmap/tests/test_failmap.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
import json
44
import tempfile
55
import unittest
6+
from contextlib import redirect_stdout
7+
from io import StringIO
68
from pathlib import Path
79

10+
from failmap.cli import main as cli_main
811
from failmap.cluster import build_clusters
912
from failmap.compare import compare_cluster_files
1013
from failmap.issues import build_issue_bundle, generate_issue_drafts
@@ -301,6 +304,107 @@ def test_trend_report_tracks_latest_statuses(self):
301304
self.assertIn("# FailMap Trend Report", markdown)
302305
self.assertIn("failure:model_call:planner", markdown)
303306

307+
def test_summarize_cli_can_emit_json(self):
308+
with tempfile.TemporaryDirectory() as tmpdir:
309+
root = Path(tmpdir)
310+
self._write_pack(root)
311+
clusters_path = root / "clusters.json"
312+
write_json(clusters_path, build_clusters(root))
313+
output = StringIO()
314+
with redirect_stdout(output):
315+
code = cli_main(["summarize", str(clusters_path), "--json"])
316+
payload = json.loads(output.getvalue())
317+
self.assertEqual(code, 0)
318+
self.assertEqual(payload["cluster_count"], 2)
319+
self.assertEqual(payload["case_count"], 3)
320+
321+
def test_compare_summary_cli_can_emit_json(self):
322+
with tempfile.TemporaryDirectory() as tmpdir:
323+
root = Path(tmpdir)
324+
baseline_root = root / "baseline"
325+
candidate_root = root / "candidate"
326+
self._write_pack(baseline_root)
327+
self._write_pack(candidate_root)
328+
baseline_path = root / "baseline.json"
329+
candidate_path = root / "candidate.json"
330+
write_json(baseline_path, build_clusters(baseline_root))
331+
candidate_payload = build_clusters(candidate_root)
332+
candidate_payload["clusters"][0]["case_count"] = 3
333+
write_json(candidate_path, candidate_payload)
334+
compare_path = root / "compare.json"
335+
write_json(compare_path, compare_cluster_files(baseline_path, candidate_path))
336+
output = StringIO()
337+
with redirect_stdout(output):
338+
code = cli_main(["compare-summary", str(compare_path), "--json"])
339+
payload = json.loads(output.getvalue())
340+
self.assertEqual(code, 0)
341+
self.assertEqual(payload["summary"]["growing"], 1)
342+
343+
def test_issue_bundle_summary_cli_can_emit_json(self):
344+
with tempfile.TemporaryDirectory() as tmpdir:
345+
root = Path(tmpdir)
346+
issues_dir = root / "issues"
347+
issues_dir.mkdir(parents=True, exist_ok=True)
348+
manifest = {
349+
"format": "failmap-issues-v1",
350+
"source_compare": "compare.json",
351+
"draft_count": 1,
352+
"drafts": [
353+
{
354+
"file": "001-new-tool.md",
355+
"title": "[FailMap] new: failure:tool_call:db_lookup",
356+
"status": "new",
357+
"signature": "failure:tool_call:db_lookup",
358+
"priority": "P1",
359+
"suggested_owner": "tooling",
360+
"labels": ["failmap", "status:new", "priority:P1"],
361+
}
362+
],
363+
}
364+
write_json(issues_dir / "manifest.json", manifest)
365+
bundle_dir = root / "bundle"
366+
build_issue_bundle(issues_dir, bundle_dir)
367+
output = StringIO()
368+
with redirect_stdout(output):
369+
code = cli_main(["issue-bundle-summary", str(bundle_dir / "bundle.json"), "--json"])
370+
payload = json.loads(output.getvalue())
371+
self.assertEqual(code, 0)
372+
self.assertEqual(payload["draft_count"], 1)
373+
self.assertEqual(payload["owner_counts"]["tooling"], 1)
374+
375+
def test_trend_summary_cli_can_emit_json(self):
376+
with tempfile.TemporaryDirectory() as tmpdir:
377+
root = Path(tmpdir)
378+
release_a = root / "release-a.json"
379+
release_b = root / "release-b.json"
380+
write_json(
381+
release_a,
382+
{
383+
"format": "failmap-v1",
384+
"case_count": 1,
385+
"cluster_count": 1,
386+
"clusters": [{"signature": "failure:tool_call:web_search", "case_count": 1}],
387+
},
388+
)
389+
write_json(
390+
release_b,
391+
{
392+
"format": "failmap-v1",
393+
"case_count": 2,
394+
"cluster_count": 1,
395+
"clusters": [{"signature": "failure:tool_call:web_search", "case_count": 2}],
396+
},
397+
)
398+
trends_path = root / "trends.json"
399+
write_json(trends_path, build_trend_report([release_a, release_b]))
400+
output = StringIO()
401+
with redirect_stdout(output):
402+
code = cli_main(["trend-summary", str(trends_path), "--json"])
403+
payload = json.loads(output.getvalue())
404+
self.assertEqual(code, 0)
405+
self.assertEqual(payload["snapshot_count"], 2)
406+
self.assertEqual(payload["summary"]["growing_in_latest"], 1)
407+
304408

305409
if __name__ == "__main__":
306410
unittest.main()

0 commit comments

Comments
 (0)