Skip to content

Commit aea4d07

Browse files
authored
Merge pull request #308 from Wolfvin/fix/issue-306-umbrella-markdown
Merged on local verification: 6 umbrellas render non-empty markdown, scan not regressed, 8 new tests, full suite 19=19 on main. Makes context --check tags human-readable (Wolfvin's direct ask). CI test-suite gate non-functional (#303).
2 parents 213826c + 300f10e commit aea4d07

3 files changed

Lines changed: 193 additions & 1 deletion

File tree

scripts/formatters/__init__.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,33 @@ def _normalize_to_ai(data: Any, command: str = "") -> Dict[str, Any]:
4747
"suggestion": data.get("suggestion", ""),
4848
})
4949

50+
# Umbrella envelope {s, st, r:[...]} (the #195 consolidation): normalize
51+
# each sub-result and merge, so `ai` consumers get the data instead of an
52+
# empty items list. Stats are namespaced per sub-check to avoid collisions
53+
# when several checks run at once (issue #306).
54+
sub_results = data.get("r")
55+
if isinstance(sub_results, list) and "st" in data:
56+
merged_items = []
57+
merged_stats = {}
58+
for sub in sub_results:
59+
if not isinstance(sub, dict):
60+
continue
61+
check = sub.get("_check", "")
62+
norm = _normalize_to_ai(sub, check or command)
63+
merged_items.extend(norm.get("items", []))
64+
sub_stats = norm.get("stats", {})
65+
if sub_stats:
66+
merged_stats[check or "result"] = sub_stats
67+
return stamp_schema_version({
68+
"status": "ok" if data.get("s", "ok") != "error" else "error",
69+
"command": command,
70+
"stats": merged_stats,
71+
"items": merged_items,
72+
"truncated": False,
73+
"recommendations": [],
74+
"metadata": {"checks": data.get("st", {})},
75+
})
76+
5077
result = {
5178
"status": status,
5279
"command": command,
@@ -79,6 +106,10 @@ def _normalize_to_ai(data: Any, command: str = "") -> Dict[str, Any]:
79106
elif "identity" in data and "registry_stats" in data:
80107
# summary
81108
stats.update(data["registry_stats"])
109+
elif isinstance(data.get("summary"), dict):
110+
# Generic: many sub-checks (tags, diff) carry a flat `summary` dict.
111+
# Last resort so it never shadows a more specific stats source above.
112+
stats.update(data["summary"])
82113

83114
result["stats"] = stats
84115

@@ -90,7 +121,7 @@ def _normalize_to_ai(data: Any, command: str = "") -> Dict[str, Any]:
90121
_ITEM_KEYS = [
91122
"functions", "findings", "leaks", "hints", "issues",
92123
"matches", "violations", "entrypoints", "routes", "stores",
93-
"results", "ownership_summary", "chains",
124+
"results", "ownership_summary", "chains", "flows",
94125
"by_category", "top_priority", "actionable_items",
95126
]
96127

scripts/formatters/markdown.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ def to_markdown(data: Any, command: str = "") -> str:
1212
lines = []
1313
status = data.get("status", "")
1414

15+
# Umbrella envelope {s, st, r:[...]} (the #195 command consolidation):
16+
# unwrap and render each sub-result through its own `_check` handler.
17+
# Without this, every umbrella sub-check rendered empty or "Symbol not
18+
# found" because the old flat handlers never saw the shape they expect
19+
# (issue #306). Recursion reuses every existing per-command renderer.
20+
sub_results = data.get("r")
21+
if isinstance(sub_results, list) and "st" in data:
22+
parts = []
23+
for sub in sub_results:
24+
if isinstance(sub, dict):
25+
rendered = to_markdown(sub, sub.get("_check", "")).strip()
26+
if rendered:
27+
parts.append(rendered)
28+
return "\n\n".join(parts) if parts else "_No results._"
29+
1530
# Error output
1631
if status == "error":
1732
lines.append(f"## Error")
@@ -124,13 +139,59 @@ def to_markdown(data: Any, command: str = "") -> str:
124139
_md_summary(data, lines)
125140
elif command == "analyze":
126141
_md_analyze(data, lines)
142+
elif command == "tags":
143+
_md_tags(data, lines)
127144
else:
128145
# Generic markdown for any command
129146
_md_generic(data, lines)
130147

131148
return "\n".join(lines)
132149

133150

151+
def _md_tags(data: Dict, lines: list) -> None:
152+
"""Markdown for the doc-tag audit (`context --check tags`, issue #305)."""
153+
s = data.get("summary", {})
154+
lines.append("## Doc-Tag Audit")
155+
lines.append("")
156+
lines.append(
157+
f"**Header coverage:** {s.get('header_coverage_pct', 0)}% "
158+
f"({s.get('with_full_header', 0)} full, "
159+
f"{s.get('with_partial_header', 0)} partial, "
160+
f"{s.get('without_header', 0)} untagged of "
161+
f"{s.get('files_scanned', 0)} files)"
162+
)
163+
lines.append(f"**Named flows:** {s.get('distinct_flows', 0)}")
164+
lines.append("")
165+
166+
flows = data.get("flows", [])
167+
if flows:
168+
lines.append("### Flows")
169+
for f in flows:
170+
locs = f.get("locations", [])
171+
where = locs[0] if locs else ""
172+
extra = f" (+{len(locs) - 1} more)" if len(locs) > 1 else ""
173+
lines.append(f"- `{f.get('name', '')}` — {where}{extra}")
174+
lines.append("")
175+
176+
partial = data.get("partial_headers", [])
177+
if partial:
178+
lines.append("### Partial headers (missing tags)")
179+
for p in partial:
180+
lines.append(f"- `{p.get('file', '')}` — missing {', '.join(p.get('missing', []))}")
181+
if data.get("partial_headers_truncated"):
182+
lines.append("- …")
183+
lines.append("")
184+
185+
untagged = data.get("untagged_files", [])
186+
if untagged:
187+
lines.append(f"### Untagged files ({s.get('without_header', 0)})")
188+
for u in untagged:
189+
lines.append(f"- `{u}`")
190+
if data.get("untagged_files_truncated"):
191+
lines.append("- …")
192+
lines.append("")
193+
194+
134195
def _md_generic(data: Dict, lines: list) -> None:
135196
"""Generic markdown output for any command."""
136197
lines.append(f"## Result")

tests/test_umbrella_formats.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""
2+
Tests for umbrella envelope rendering in markdown + ai formats (issue #306).
3+
4+
The #195 command consolidation wraps sub-check output in an envelope
5+
``{s, st, r:[...]}``. The markdown and ai formatters never learned that shape,
6+
so every umbrella sub-check rendered empty ("Symbol not found") or with an
7+
empty ``items`` list. These tests pin the unwrap.
8+
"""
9+
10+
import os
11+
import sys
12+
import unittest
13+
14+
SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
15+
sys.path.insert(0, SCRIPT_DIR)
16+
17+
from formatters.markdown import to_markdown # noqa: E402
18+
from formatters import _normalize_to_ai # noqa: E402
19+
20+
21+
def _envelope(sub):
22+
"""Wrap a sub-result the way an umbrella command does."""
23+
return {"s": "ok", "st": {"checks_requested": 1, "checks_run": 1, "checks_failed": 0}, "r": [sub]}
24+
25+
26+
_TAGS_SUB = {
27+
"status": "ok",
28+
"_check": "tags",
29+
"summary": {
30+
"files_scanned": 10, "with_full_header": 4, "with_partial_header": 1,
31+
"without_header": 5, "header_coverage_pct": 50.0, "distinct_flows": 2,
32+
"total_flow_declarations": 2,
33+
},
34+
"flows": [
35+
{"name": "PAYMENT", "count": 2, "locations": ["a.py:1", "b.py:9"]},
36+
{"name": "AUTH", "count": 1, "locations": ["c.py:3"]},
37+
],
38+
"partial_headers": [{"file": "d.py", "present": ["WHO"], "missing": ["WHAT", "PART", "ENTRY"]}],
39+
"partial_headers_truncated": False,
40+
"untagged_files": ["e.py", "f.py"],
41+
"untagged_files_truncated": False,
42+
}
43+
44+
45+
class TestMarkdownEnvelope(unittest.TestCase):
46+
def test_tags_envelope_renders_content_not_symbol_not_found(self):
47+
out = to_markdown(_envelope(_TAGS_SUB), "context")
48+
49+
self.assertNotIn("Symbol not found", out)
50+
self.assertIn("Doc-Tag Audit", out)
51+
self.assertIn("PAYMENT", out)
52+
self.assertIn("a.py:1", out)
53+
54+
def test_tags_envelope_lists_partial_and_untagged(self):
55+
out = to_markdown(_envelope(_TAGS_SUB), "context")
56+
57+
self.assertIn("d.py", out) # partial header
58+
self.assertIn("WHAT", out) # its missing tag
59+
self.assertIn("e.py", out) # untagged file
60+
61+
def test_envelope_dispatches_sub_to_its_own_handler(self):
62+
"""A dead-code sub-result must reach the dead-code renderer, not generic."""
63+
sub = {"status": "ok", "_check": "dead-code", "dead_functions": [], "summary": {}}
64+
out = to_markdown(_envelope(sub), "audit")
65+
66+
self.assertIn("Dead Code Analysis", out)
67+
68+
def test_empty_envelope_is_not_a_crash(self):
69+
out = to_markdown({"s": "ok", "st": {}, "r": []}, "context")
70+
71+
self.assertIn("No results", out)
72+
73+
def test_non_umbrella_output_not_regressed(self):
74+
"""A flat (non-envelope) result must still use its own handler."""
75+
out = to_markdown({"status": "ok", "dead_functions": [], "summary": {}}, "dead-code")
76+
77+
self.assertIn("Dead Code Analysis", out)
78+
79+
80+
class TestAiEnvelope(unittest.TestCase):
81+
def test_tags_envelope_populates_items(self):
82+
out = _normalize_to_ai(_envelope(_TAGS_SUB), "context")
83+
84+
self.assertEqual(len(out["items"]), 2) # the two flows
85+
self.assertEqual({i["name"] for i in out["items"]}, {"PAYMENT", "AUTH"})
86+
87+
def test_tags_envelope_populates_stats(self):
88+
out = _normalize_to_ai(_envelope(_TAGS_SUB), "context")
89+
90+
self.assertIn("tags", out["stats"])
91+
self.assertEqual(out["stats"]["tags"]["distinct_flows"], 2)
92+
93+
def test_envelope_records_check_metadata(self):
94+
out = _normalize_to_ai(_envelope(_TAGS_SUB), "context")
95+
96+
self.assertIn("checks", out["metadata"])
97+
98+
99+
if __name__ == "__main__":
100+
unittest.main()

0 commit comments

Comments
 (0)