slopstopper-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. slopstopper/__init__.py +1 -0
  2. slopstopper/__main__.py +4 -0
  3. slopstopper/checks/__init__.py +41 -0
  4. slopstopper/checks/accessibility.py +133 -0
  5. slopstopper/checks/broken_links.py +128 -0
  6. slopstopper/checks/complexity.py +237 -0
  7. slopstopper/checks/csp_exceptions.py +329 -0
  8. slopstopper/checks/cwv.py +269 -0
  9. slopstopper/checks/dast.py +331 -0
  10. slopstopper/checks/dependencies.py +195 -0
  11. slopstopper/checks/docs_accuracy.py +298 -0
  12. slopstopper/checks/docs_size.py +245 -0
  13. slopstopper/checks/docs_structure.py +258 -0
  14. slopstopper/checks/entry_files.py +190 -0
  15. slopstopper/checks/sast.py +227 -0
  16. slopstopper/checks/secrets.py +148 -0
  17. slopstopper/checks/seo.py +515 -0
  18. slopstopper/checks/smoke.py +101 -0
  19. slopstopper/cli.py +670 -0
  20. slopstopper/config.py +193 -0
  21. slopstopper/dast_gate.py +257 -0
  22. slopstopper/data/lighthouserc.json +20 -0
  23. slopstopper/data/lighthouserc.prod.json +24 -0
  24. slopstopper/data/playwright.config.js +37 -0
  25. slopstopper/data/server.js +208 -0
  26. slopstopper/data/tests/accessibility.spec.ts +87 -0
  27. slopstopper/data/tests/broken-links.spec.ts +54 -0
  28. slopstopper/data/tests/smoke.spec.ts +77 -0
  29. slopstopper/discovery.py +366 -0
  30. slopstopper/emit.py +258 -0
  31. slopstopper/headers_adapters/__init__.py +54 -0
  32. slopstopper/headers_adapters/cloudflare_adapter.py +55 -0
  33. slopstopper/headers_adapters/json_adapter.py +29 -0
  34. slopstopper/output.py +127 -0
  35. slopstopper/templates.py +134 -0
  36. slopstopper_cli-0.3.0.dist-info/METADATA +123 -0
  37. slopstopper_cli-0.3.0.dist-info/RECORD +40 -0
  38. slopstopper_cli-0.3.0.dist-info/WHEEL +4 -0
  39. slopstopper_cli-0.3.0.dist-info/entry_points.txt +2 -0
  40. slopstopper_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,4 @@
1
+ from slopstopper.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,41 @@
1
+ """Check registry. Maps `<category>:<name>` keys to check entrypoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Optional
6
+
7
+ from slopstopper.checks import (
8
+ accessibility,
9
+ broken_links,
10
+ complexity,
11
+ csp_exceptions,
12
+ cwv,
13
+ dast,
14
+ dependencies,
15
+ docs_accuracy,
16
+ docs_size,
17
+ docs_structure,
18
+ entry_files,
19
+ sast,
20
+ secrets,
21
+ seo,
22
+ smoke,
23
+ )
24
+
25
+ REGISTRY: dict[str, Callable[[Optional[list[str]]], int]] = {
26
+ "hygiene:complexity": complexity.run,
27
+ "hygiene:csp-exceptions": csp_exceptions.run,
28
+ "hygiene:docs-accuracy": docs_accuracy.run,
29
+ "hygiene:docs-size": docs_size.run,
30
+ "hygiene:docs-structure": docs_structure.run,
31
+ "hygiene:entry-files": entry_files.run,
32
+ "reliability:accessibility": accessibility.run,
33
+ "reliability:broken-links": broken_links.run,
34
+ "reliability:cwv": cwv.run,
35
+ "reliability:seo": seo.run,
36
+ "reliability:smoke": smoke.run,
37
+ "security:dast": dast.run,
38
+ "security:dependencies": dependencies.run,
39
+ "security:sast": sast.run,
40
+ "security:secrets": secrets.run,
41
+ }
@@ -0,0 +1,133 @@
1
+ """Accessibility audit (Playwright + axe-core wrapper).
2
+
3
+ Ports the bash reliability:accessibility flow:
4
+
5
+ task ss:reliability:accessibility -- https://your-site.example.com
6
+
7
+ which is, under the covers:
8
+
9
+ ACCESSIBILITY_PAGES=$(python3 .ss/scripts/discover-pages.py
10
+ accessibility --event=local)
11
+ ACCESSIBILITY_TEST_URL=...
12
+ npx playwright test --config=.ss/playwright.config.js
13
+ .ss/tests/accessibility.spec.ts
14
+ --reporter=list[,html]
15
+
16
+ Subprocess-invokes `npx playwright`. Playwright is Apache-2.0, axe-core
17
+ is MPL-2.0; the slopstopper-cli wheel ships zero code from either —
18
+ both bind in via the adopter's node_modules. Page discovery uses the
19
+ in-CLI slopstopper.discovery module.
20
+
21
+ Falls back to SMOKE_TEST_URL when ACCESSIBILITY_TEST_URL is unset,
22
+ matching the bash flow exactly.
23
+
24
+ Configuration (.slopstopper.yml — all optional):
25
+
26
+ pages:
27
+ accessibility: /,/blog,/about
28
+ reliability:
29
+ coverage:
30
+ pr: changed # see slopstopper.discovery for the resolution order
31
+ main: sitemap
32
+
33
+ See .slopstopper.yml.example for the canonical schema and coverage
34
+ modes.
35
+
36
+ Exit codes:
37
+ 0 — playwright tests passed
38
+ non-zero — playwright tests failed, or URL/spec missing
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import os
45
+ import shutil
46
+ import subprocess
47
+
48
+ from slopstopper import discovery, output, templates
49
+
50
+ SPEC_NAME = "accessibility"
51
+
52
+
53
+ def _parse_args(args: list[str] | None) -> argparse.Namespace:
54
+ p = argparse.ArgumentParser(
55
+ prog="slopstopper run reliability:accessibility", add_help=False
56
+ )
57
+ p.add_argument("--url", default=None, help="Site URL to audit")
58
+ p.add_argument("--ci", action="store_true", help="CI mode: html reporter, CI=true")
59
+ p.add_argument("--help", "-h", action="help")
60
+ return p.parse_args(args or [])
61
+
62
+
63
+ def _npx_available() -> bool:
64
+ return shutil.which("npx") is not None
65
+
66
+
67
+ def _resolve_url(parsed_url: str | None) -> str | None:
68
+ return (
69
+ parsed_url
70
+ or os.environ.get("ACCESSIBILITY_TEST_URL")
71
+ or os.environ.get("SMOKE_TEST_URL")
72
+ )
73
+
74
+
75
+ def _discover_pages() -> str | None:
76
+ """Resolve pages.accessibility from .slopstopper.yml via the in-CLI
77
+ discovery module. Returns None on internal failure so the spec falls
78
+ back to its built-in default.
79
+ """
80
+ try:
81
+ paths = discovery.discover("accessibility", "local")
82
+ except Exception:
83
+ return None
84
+ return ",".join(paths) if paths else None
85
+
86
+
87
+ def _build_env(url: str, ci_mode: bool) -> dict[str, str]:
88
+ env = dict(os.environ)
89
+ env["ACCESSIBILITY_TEST_URL"] = url
90
+ if "ACCESSIBILITY_PAGES" not in env:
91
+ pages = _discover_pages()
92
+ if pages is not None:
93
+ env["ACCESSIBILITY_PAGES"] = pages
94
+ if ci_mode:
95
+ env["CI"] = "true"
96
+ return env
97
+
98
+
99
+ def _build_cmd(ci_mode: bool) -> list[str]:
100
+ reporter = "list,html" if ci_mode else "list"
101
+ return [
102
+ "npx", "playwright", "test",
103
+ f"--config={templates.playwright_config()}",
104
+ str(templates.playwright_spec(SPEC_NAME)),
105
+ f"--reporter={reporter}",
106
+ ]
107
+
108
+
109
+ def run(args: list[str] | None = None) -> int:
110
+ if not _npx_available():
111
+ output.error("npx is not available — install Node.js to run Playwright tests")
112
+ return 1
113
+
114
+ parsed = _parse_args(args)
115
+ url = _resolve_url(parsed.url)
116
+ if not url:
117
+ output.error("accessibility target URL is required")
118
+ output._emit("Usage:")
119
+ output._emit(" slopstopper run reliability:accessibility -- --url https://your-site.example.com")
120
+ output._emit(" ACCESSIBILITY_TEST_URL=https://your-site slopstopper run reliability:accessibility")
121
+ return 1
122
+
123
+ spec = templates.playwright_spec(SPEC_NAME)
124
+ if not spec.exists():
125
+ output.error(f"Accessibility spec not found at {spec}")
126
+ output._emit(" The spec is bundled inside slopstopper-cli; reinstall to repair.")
127
+ return 1
128
+
129
+ output.status("♿", f"Running accessibility audit against: {url}")
130
+ env = _build_env(url, parsed.ci)
131
+ cmd = _build_cmd(parsed.ci)
132
+ result = subprocess.run(cmd, env=env, check=False)
133
+ return result.returncode
@@ -0,0 +1,128 @@
1
+ """Broken-link checks (Playwright wrapper).
2
+
3
+ Ports the bash reliability:links flow:
4
+
5
+ task ss:reliability:links -- https://your-site.example.com
6
+
7
+ which is, under the covers:
8
+
9
+ BROKEN_LINKS_PAGES=$(python3 .ss/scripts/discover-pages.py
10
+ broken_links --event=local)
11
+ BROKEN_LINKS_TEST_URL=...
12
+ npx playwright test --config=.ss/playwright.config.js
13
+ .ss/tests/broken-links.spec.ts
14
+ --reporter=list[,html]
15
+
16
+ Subprocess-invokes `npx playwright`. Playwright is Apache-2.0; the
17
+ slopstopper-cli wheel ships zero Playwright code. Page discovery uses
18
+ the in-CLI slopstopper.discovery module.
19
+
20
+ Falls back to SMOKE_TEST_URL when BROKEN_LINKS_TEST_URL is unset,
21
+ matching the bash flow exactly.
22
+
23
+ Configuration (.slopstopper.yml — all optional):
24
+
25
+ pages:
26
+ broken_links: /,/blog,/about
27
+ reliability:
28
+ coverage:
29
+ pr: changed # see slopstopper.discovery for the resolution order
30
+ main: sitemap
31
+
32
+ See .slopstopper.yml.example for the canonical schema and coverage
33
+ modes.
34
+
35
+ Exit codes:
36
+ 0 — playwright tests passed
37
+ non-zero — playwright tests failed, or URL/spec missing
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import os
44
+ import shutil
45
+ import subprocess
46
+
47
+ from slopstopper import discovery, output, templates
48
+
49
+ SPEC_NAME = "broken-links"
50
+
51
+
52
+ def _parse_args(args: list[str] | None) -> argparse.Namespace:
53
+ p = argparse.ArgumentParser(
54
+ prog="slopstopper run reliability:broken-links", add_help=False
55
+ )
56
+ p.add_argument("--url", default=None, help="Site URL to scan")
57
+ p.add_argument("--ci", action="store_true", help="CI mode: html reporter, CI=true")
58
+ p.add_argument("--help", "-h", action="help")
59
+ return p.parse_args(args or [])
60
+
61
+
62
+ def _npx_available() -> bool:
63
+ return shutil.which("npx") is not None
64
+
65
+
66
+ def _resolve_url(parsed_url: str | None) -> str | None:
67
+ return (
68
+ parsed_url
69
+ or os.environ.get("BROKEN_LINKS_TEST_URL")
70
+ or os.environ.get("SMOKE_TEST_URL")
71
+ )
72
+
73
+
74
+ def _discover_pages() -> str | None:
75
+ """Resolve pages.broken_links via the in-CLI discovery module."""
76
+ try:
77
+ paths = discovery.discover("broken_links", "local")
78
+ except Exception:
79
+ return None
80
+ return ",".join(paths) if paths else None
81
+
82
+
83
+ def _build_env(url: str, ci_mode: bool) -> dict[str, str]:
84
+ env = dict(os.environ)
85
+ env["BROKEN_LINKS_TEST_URL"] = url
86
+ if "BROKEN_LINKS_PAGES" not in env:
87
+ pages = _discover_pages()
88
+ if pages is not None:
89
+ env["BROKEN_LINKS_PAGES"] = pages
90
+ if ci_mode:
91
+ env["CI"] = "true"
92
+ return env
93
+
94
+
95
+ def _build_cmd(ci_mode: bool) -> list[str]:
96
+ reporter = "list,html" if ci_mode else "list"
97
+ return [
98
+ "npx", "playwright", "test",
99
+ f"--config={templates.playwright_config()}",
100
+ str(templates.playwright_spec(SPEC_NAME)),
101
+ f"--reporter={reporter}",
102
+ ]
103
+
104
+
105
+ def run(args: list[str] | None = None) -> int:
106
+ if not _npx_available():
107
+ output.error("npx is not available — install Node.js to run Playwright tests")
108
+ return 1
109
+
110
+ parsed = _parse_args(args)
111
+ url = _resolve_url(parsed.url)
112
+ if not url:
113
+ output.error("broken-links target URL is required")
114
+ output._emit("Usage:")
115
+ output._emit(" slopstopper run reliability:broken-links -- --url https://your-site.example.com")
116
+ output._emit(" BROKEN_LINKS_TEST_URL=https://your-site slopstopper run reliability:broken-links")
117
+ return 1
118
+
119
+ spec = templates.playwright_spec(SPEC_NAME)
120
+ if not spec.exists():
121
+ output.error(f"Broken-links spec not found at {spec}")
122
+ return 1
123
+
124
+ output.status("🔗", f"Running broken-link checks against: {url}")
125
+ env = _build_env(url, parsed.ci)
126
+ cmd = _build_cmd(parsed.ci)
127
+ result = subprocess.run(cmd, env=env, check=False)
128
+ return result.returncode
@@ -0,0 +1,237 @@
1
+ """Code complexity analyzer (Lizard wrapper).
2
+
3
+ Ports the bash hygiene:complexity flow:
4
+
5
+ .ss/scripts/check-tool (in Taskfile.ss.yml — installs lizard)
6
+ + python3 -m lizard . ... --csv > complexity-report.csv
7
+ + .ss/scripts/generate-complexity-md.py
8
+
9
+ into one self-contained check. Subprocess-invokes `python -m lizard`
10
+ (using sys.executable so the same Python running the CLI provides
11
+ lizard), captures the CSV, parses it, writes both the CSV and the
12
+ markdown report.
13
+
14
+ This is the CLI's first external-tool integration. Subprocess-invoke
15
+ boundary is the load-bearing licensing rule (see plan doc): we call
16
+ lizard's CLI, never `import lizard`. Adopter installs lizard
17
+ themselves (or via the test extra in cli/pyproject.toml's [test]
18
+ group, which is what CI does).
19
+
20
+ Exit codes:
21
+ 0 — analysis completed (independent of whether high-complexity items
22
+ exist — gating happens at the workflow level, mirroring bash)
23
+ 1 — lizard is not installed
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import csv
29
+ import io
30
+ import subprocess
31
+ import sys
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ from slopstopper import output
36
+
37
+ REPORT_DIR = Path(".ss/reports/complexity")
38
+ REPORT_CSV = REPORT_DIR / "complexity-report.csv"
39
+ REPORT_MD = REPORT_DIR / "complexity-report.md"
40
+
41
+ # Consumed by `slopstopper emit hygiene:complexity --target {pr-comment,issue}`.
42
+ # The comment_discriminator is the H1 of the generated report — pre-flip the
43
+ # JS prepended its own "## 📊 Code Complexity Analysis" heading, but the
44
+ # report itself starts with "# Code Complexity Analysis Report", so the
45
+ # substring "Code Complexity Analysis" matches the same comment after the
46
+ # flip. Issue strings are byte-for-byte identical to the legacy block.
47
+ META = {
48
+ "report_path": str(REPORT_MD),
49
+ "comment_discriminator": "Code Complexity Analysis",
50
+ "issue_title": "⚠️ Code Complexity Issues in Main Branch",
51
+ "issue_labels": ["code-complexity", "technical-debt"],
52
+ "issue_followup": "🔔 Code complexity issues detected again in commit",
53
+ }
54
+
55
+ # Exclude tests, generated files, vendored deps from the scan. Same list
56
+ # as Taskfile.ss.yml's hygiene:complexity:analyze.
57
+ LIZARD_EXCLUDES = (
58
+ "tests/*",
59
+ ".ss/tests/*",
60
+ ".github/*",
61
+ "node_modules/*",
62
+ ".git/*",
63
+ )
64
+
65
+ CCN_THRESHOLD = 10
66
+
67
+ _LIZARD_INSTALL_HELP = (
68
+ "lizard is not installed.\n"
69
+ "Install with:\n"
70
+ " pip3 install --user lizard\n"
71
+ " python3 -m pip install --user lizard\n"
72
+ "Note: do NOT install via 'brew install lizard' — that's lz4's lizard,\n"
73
+ "a completely different tool that will shadow the Python package."
74
+ )
75
+
76
+
77
+ def _lizard_available() -> bool:
78
+ try:
79
+ result = subprocess.run(
80
+ [sys.executable, "-m", "lizard", "--version"],
81
+ capture_output=True,
82
+ text=True,
83
+ check=False,
84
+ )
85
+ return result.returncode == 0
86
+ except OSError:
87
+ return False
88
+
89
+
90
+ def _run_lizard(target_dir: str = ".") -> str:
91
+ """Invoke `python -m lizard <target> --csv ...` and return stdout."""
92
+ cmd = [sys.executable, "-m", "lizard", target_dir]
93
+ for ex in LIZARD_EXCLUDES:
94
+ cmd += ["-x", ex]
95
+ cmd += ["--csv"]
96
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
97
+ return result.stdout
98
+
99
+
100
+ def _parse_csv_rows(csv_text: str) -> list[tuple]:
101
+ """Parse lizard's CSV output.
102
+
103
+ Columns (0-indexed):
104
+ 0 nloc, 1 ccn, 2 tokens, 3 params, 4 length,
105
+ 5 location ("function@start-end@./path"),
106
+ 6 file path, 7 function name, 8 long name, ...
107
+
108
+ Returns [(nloc, ccn, tokens, params, length, location, file), ...].
109
+ Rows with non-numeric leading columns (header rows from fixtures) are
110
+ silently skipped.
111
+ """
112
+ rows: list[tuple] = []
113
+ reader = csv.reader(io.StringIO(csv_text))
114
+ for row in reader:
115
+ if len(row) < 6:
116
+ continue
117
+ try:
118
+ nloc = int(row[0])
119
+ ccn = int(row[1])
120
+ tokens = int(row[2])
121
+ params = int(row[3])
122
+ length = int(row[4])
123
+ except ValueError:
124
+ continue
125
+ location = row[5].strip('"')
126
+ if len(row) > 6 and row[6]:
127
+ file_path = row[6].strip('"')
128
+ elif "@" in location:
129
+ file_path = location.split("@")[-1]
130
+ else:
131
+ file_path = location.split(":")[0]
132
+ rows.append((nloc, ccn, tokens, params, length, location, file_path))
133
+ return rows
134
+
135
+
136
+ def _compute_summary_lines(rows: list[tuple]) -> list[str]:
137
+ if not rows:
138
+ return [
139
+ "Total NLOC Avg.NLOC Avg.CCN Avg.Tokens Fun Cnt Warning Cnt",
140
+ "----------------------------------------------------------------",
141
+ " 0 0.0 0.0 0.0 0 0",
142
+ "",
143
+ "No functions analyzed.",
144
+ ]
145
+
146
+ fun_cnt = len(rows)
147
+ total_nloc = sum(r[0] for r in rows)
148
+ avg_nloc = total_nloc / fun_cnt
149
+ avg_ccn = sum(r[1] for r in rows) / fun_cnt
150
+ avg_tokens = sum(r[2] for r in rows) / fun_cnt
151
+ warning_cnt = sum(1 for r in rows if r[1] > CCN_THRESHOLD)
152
+
153
+ files = {r[6] for r in rows}
154
+ file_count = len(files)
155
+ file_word = "file" if file_count == 1 else "files"
156
+
157
+ return [
158
+ "Total NLOC Avg.NLOC Avg.CCN Avg.Tokens Fun Cnt Warning Cnt",
159
+ "----------------------------------------------------------------",
160
+ f"{total_nloc:>10} {avg_nloc:>9.1f} {avg_ccn:>7.1f} {avg_tokens:>10.1f} {fun_cnt:>7} {warning_cnt:>11}",
161
+ "",
162
+ f"{file_count} {file_word} analyzed.",
163
+ ]
164
+
165
+
166
+ def _format_summary_section(summary_lines: list[str]) -> str:
167
+ if not summary_lines:
168
+ return ""
169
+ return "```\n" + "\n".join(summary_lines) + "\n```\n\n"
170
+
171
+
172
+ def _format_high_complexity_section(rows: list[tuple]) -> str:
173
+ high = [r for r in rows if r[1] > CCN_THRESHOLD]
174
+ if not high:
175
+ return "## ✅ Complexity Status\n\nNo high-complexity items found (all CCN ≤ 10)\n\n"
176
+
177
+ out = "## ⚠️ High Complexity Items (CCN > 10)\n\n"
178
+ out += "| NLOC | CCN | Tokens | Params | Length | Location |\n"
179
+ out += "|------|-----|--------|--------|--------|----------|\n"
180
+ for nloc, ccn, tokens, params, length, location, _file in high:
181
+ out += f"| {nloc} | {ccn} | {tokens} | {params} | {length} | `{location}` |\n"
182
+ return out
183
+
184
+
185
+ def _generated_at() -> str:
186
+ # Bash uses `datetime.now().strftime(...)` (naive local time). The
187
+ # parity test strips this line so format is irrelevant, but using
188
+ # timezone.utc here matches what the docs-size port chose.
189
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
190
+
191
+
192
+ def _build_md_report(rows: list[tuple]) -> str:
193
+ summary = _compute_summary_lines(rows)
194
+ md = "# Code Complexity Analysis Report\n\n"
195
+ md += f"**Generated**: {_generated_at()}\n\n"
196
+ md += "## Summary\n\n"
197
+ md += _format_summary_section(summary)
198
+ md += _format_high_complexity_section(rows)
199
+ md += "## Guidelines\n\n"
200
+ md += "- **Cyclomatic Complexity (CCN)**: Measure of code complexity based on decision points\n"
201
+ md += " - **Good**: CCN ≤ 10\n"
202
+ md += " - **Warning**: 10 < CCN ≤ 15\n"
203
+ md += " - **Critical**: CCN > 15\n\n"
204
+ md += "- **Function Length (NLOC)**: Number of lines of code\n"
205
+ md += " - Target: Keep functions under 50 lines\n"
206
+ md += " - For this static template, code should be minimal\n\n"
207
+ md += "- **Threshold**: Any function with CCN > 10 should be reviewed and simplified\n\n"
208
+ md += "## More Information\n\n"
209
+ md += "- Generated by [Lizard](http://www.lizard.ws/)\n"
210
+ md += "- Reports location: `.ss/reports/complexity/`\n"
211
+ md += " - `complexity-report.md` (this file)\n"
212
+ md += " - `complexity-report.csv` (machine-readable)\n"
213
+ return md
214
+
215
+
216
+ def run(_args: list[str] | None = None) -> int:
217
+ if not _lizard_available():
218
+ output.error(_LIZARD_INSTALL_HELP)
219
+ return 1
220
+
221
+ output.running("Analyzing code complexity…")
222
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
223
+
224
+ csv_text = _run_lizard()
225
+ REPORT_CSV.write_text(csv_text)
226
+
227
+ rows = _parse_csv_rows(csv_text)
228
+ REPORT_MD.write_text(_build_md_report(rows))
229
+
230
+ high_count = sum(1 for r in rows if r[1] > CCN_THRESHOLD)
231
+ if high_count:
232
+ output.warn(f"Found {high_count} item(s) with cyclomatic complexity > 10")
233
+ else:
234
+ output.success("No high-complexity items found (all CCN ≤ 10)")
235
+
236
+ output.footer(REPORT_DIR, [REPORT_MD.name, REPORT_CSV.name])
237
+ return 0