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.
- slopstopper/__init__.py +1 -0
- slopstopper/__main__.py +4 -0
- slopstopper/checks/__init__.py +41 -0
- slopstopper/checks/accessibility.py +133 -0
- slopstopper/checks/broken_links.py +128 -0
- slopstopper/checks/complexity.py +237 -0
- slopstopper/checks/csp_exceptions.py +329 -0
- slopstopper/checks/cwv.py +269 -0
- slopstopper/checks/dast.py +331 -0
- slopstopper/checks/dependencies.py +195 -0
- slopstopper/checks/docs_accuracy.py +298 -0
- slopstopper/checks/docs_size.py +245 -0
- slopstopper/checks/docs_structure.py +258 -0
- slopstopper/checks/entry_files.py +190 -0
- slopstopper/checks/sast.py +227 -0
- slopstopper/checks/secrets.py +148 -0
- slopstopper/checks/seo.py +515 -0
- slopstopper/checks/smoke.py +101 -0
- slopstopper/cli.py +670 -0
- slopstopper/config.py +193 -0
- slopstopper/dast_gate.py +257 -0
- slopstopper/data/lighthouserc.json +20 -0
- slopstopper/data/lighthouserc.prod.json +24 -0
- slopstopper/data/playwright.config.js +37 -0
- slopstopper/data/server.js +208 -0
- slopstopper/data/tests/accessibility.spec.ts +87 -0
- slopstopper/data/tests/broken-links.spec.ts +54 -0
- slopstopper/data/tests/smoke.spec.ts +77 -0
- slopstopper/discovery.py +366 -0
- slopstopper/emit.py +258 -0
- slopstopper/headers_adapters/__init__.py +54 -0
- slopstopper/headers_adapters/cloudflare_adapter.py +55 -0
- slopstopper/headers_adapters/json_adapter.py +29 -0
- slopstopper/output.py +127 -0
- slopstopper/templates.py +134 -0
- slopstopper_cli-0.3.0.dist-info/METADATA +123 -0
- slopstopper_cli-0.3.0.dist-info/RECORD +40 -0
- slopstopper_cli-0.3.0.dist-info/WHEEL +4 -0
- slopstopper_cli-0.3.0.dist-info/entry_points.txt +2 -0
- slopstopper_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
slopstopper/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
slopstopper/__main__.py
ADDED
|
@@ -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
|