crapkit 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {crapkit-0.2.0/src/crapkit.egg-info → crapkit-0.3.0}/PKG-INFO +7 -5
- {crapkit-0.2.0 → crapkit-0.3.0}/README.md +6 -4
- {crapkit-0.2.0 → crapkit-0.3.0}/pyproject.toml +1 -1
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/__init__.py +1 -1
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/_pygdefer.py +1 -1
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/analyze.py +38 -10
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/__init__.py +6 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/parser.py +8 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/reports.py +89 -7
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/verifying.py +5 -4
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/config.py +5 -2
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/discover.py +7 -3
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/lizardcognitive.py +8 -2
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/mutate.py +4 -2
- crapkit-0.3.0/src/crapkit/report.py +414 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/sarif.py +15 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/scaffold.py +3 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/uncovered.py +40 -12
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/universe.py +1 -0
- {crapkit-0.2.0 → crapkit-0.3.0/src/crapkit.egg-info}/PKG-INFO +7 -5
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit.egg-info/SOURCES.txt +1 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/LICENSE +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/setup.cfg +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/__main__.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cache.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/churn.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/churn_cache.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/churn_log.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/_shared.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/admin.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/analyses.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/queue.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/ratchet_cmds.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/scoring.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/coupling.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/coverage_istanbul.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/coverage_py.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/covstream.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/diffparse.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/digest.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/doctor.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/dup.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/errors.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/gitio.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/hook.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/junitparse.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/lanes.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/mcp_server.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/merge.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/mutate_pool.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/override.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/packet.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/ratchet.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/ratchet_report.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/sarifio.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/score.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/snapshot.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/store.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/verify.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/watch.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/worklist.py +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit.egg-info/dependency_links.txt +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit.egg-info/entry_points.txt +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit.egg-info/requires.txt +0 -0
- {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crapkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Deterministic CRAP-score framework: per-function complexity x coverage risk, worklists, ratchets, refactor verification
|
|
5
5
|
Author: Jean-Francois Gagne
|
|
6
6
|
License: MIT
|
|
@@ -28,9 +28,10 @@ Dynamic: license-file
|
|
|
28
28
|
|
|
29
29
|
crapkit scores every function in your repo on complexity times uncovered risk, ranks the
|
|
30
30
|
worst ones by how often the file changes, and blocks commits that add more. It reads
|
|
31
|
-
TypeScript, TSX, JavaScript and
|
|
31
|
+
TypeScript, TSX, JavaScript, Python, Swift and Go through [lizard](https://github.com/terryyin/lizard),
|
|
32
32
|
and joins per-function branch coverage from istanbul or coverage.py artifacts your own test
|
|
33
|
-
command already produces.
|
|
33
|
+
command already produces. Swift and Go have no coverage parser: declare those scopes
|
|
34
|
+
`coverage_optional` and they score on complexity alone. Every read-side command speaks JSON with a pinned schema, because
|
|
34
35
|
half the callers are coding agents.
|
|
35
36
|
|
|
36
37
|
```
|
|
@@ -76,7 +77,7 @@ Check the install:
|
|
|
76
77
|
|
|
77
78
|
```
|
|
78
79
|
$ crapkit --version
|
|
79
|
-
crapkit 0.
|
|
80
|
+
crapkit 0.3.0
|
|
80
81
|
```
|
|
81
82
|
|
|
82
83
|
`python -m crapkit` works identically to the `crapkit` console script, and is what to use
|
|
@@ -130,7 +131,7 @@ paths = ["calc"]
|
|
|
130
131
|
languages = ["python"]
|
|
131
132
|
|
|
132
133
|
[exclude]
|
|
133
|
-
globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
|
|
134
|
+
globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "**/*_test.go", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
|
|
134
135
|
|
|
135
136
|
[[lane]]
|
|
136
137
|
name = "py"
|
|
@@ -761,6 +762,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
|
|
|
761
762
|
| `overrides [--json]` | The override audit trail: who granted what, when, and why. |
|
|
762
763
|
| `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
|
|
763
764
|
| `digest [--alert]` | The delta between the two newest runs with identical lane sets. Silent when nothing changed. `--alert` pipes the body to `alert_command` on stdin. Plain lines, never JSON. |
|
|
765
|
+
| `report [--out PATH]` | One self-contained HTML page written to `.crapkit/report.html` (or `--out PATH`, repo-relative), with the path printed on stdout. It renders what `worklist --json` and `trend --json` already answer at their defaults: the ranked worklist capped at `worklist_top`, the per-scope grades off the newest run, the trend series, and a banner naming every stale lane. It measures nothing, opens no network connection, and carries no per-function CRAP or coverage, because no repo-wide payload has them; each row prints the `crapkit explain` call that does. |
|
|
764
766
|
| `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. |
|
|
765
767
|
| `coupling [--min-support N] [--min-confidence F] [--top N] [--json]` | File pairs that keep landing in the same commits. Defaults: `--min-support 5` shared commits, `--min-confidence 0.5` max-direction ratio, `--top 50`. Bulk commits never couple pairs, and a young repo returns nothing at the default support. |
|
|
766
768
|
| `mutate [--files F ...] [--max-mutants N] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. |
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
crapkit scores every function in your repo on complexity times uncovered risk, ranks the
|
|
4
4
|
worst ones by how often the file changes, and blocks commits that add more. It reads
|
|
5
|
-
TypeScript, TSX, JavaScript and
|
|
5
|
+
TypeScript, TSX, JavaScript, Python, Swift and Go through [lizard](https://github.com/terryyin/lizard),
|
|
6
6
|
and joins per-function branch coverage from istanbul or coverage.py artifacts your own test
|
|
7
|
-
command already produces.
|
|
7
|
+
command already produces. Swift and Go have no coverage parser: declare those scopes
|
|
8
|
+
`coverage_optional` and they score on complexity alone. Every read-side command speaks JSON with a pinned schema, because
|
|
8
9
|
half the callers are coding agents.
|
|
9
10
|
|
|
10
11
|
```
|
|
@@ -50,7 +51,7 @@ Check the install:
|
|
|
50
51
|
|
|
51
52
|
```
|
|
52
53
|
$ crapkit --version
|
|
53
|
-
crapkit 0.
|
|
54
|
+
crapkit 0.3.0
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
`python -m crapkit` works identically to the `crapkit` console script, and is what to use
|
|
@@ -104,7 +105,7 @@ paths = ["calc"]
|
|
|
104
105
|
languages = ["python"]
|
|
105
106
|
|
|
106
107
|
[exclude]
|
|
107
|
-
globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
|
|
108
|
+
globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "**/*_test.go", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
|
|
108
109
|
|
|
109
110
|
[[lane]]
|
|
110
111
|
name = "py"
|
|
@@ -735,6 +736,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
|
|
|
735
736
|
| `overrides [--json]` | The override audit trail: who granted what, when, and why. |
|
|
736
737
|
| `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
|
|
737
738
|
| `digest [--alert]` | The delta between the two newest runs with identical lane sets. Silent when nothing changed. `--alert` pipes the body to `alert_command` on stdin. Plain lines, never JSON. |
|
|
739
|
+
| `report [--out PATH]` | One self-contained HTML page written to `.crapkit/report.html` (or `--out PATH`, repo-relative), with the path printed on stdout. It renders what `worklist --json` and `trend --json` already answer at their defaults: the ranked worklist capped at `worklist_top`, the per-scope grades off the newest run, the trend series, and a banner naming every stale lane. It measures nothing, opens no network connection, and carries no per-function CRAP or coverage, because no repo-wide payload has them; each row prints the `crapkit explain` call that does. |
|
|
738
740
|
| `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. |
|
|
739
741
|
| `coupling [--min-support N] [--min-confidence F] [--top N] [--json]` | File pairs that keep landing in the same commits. Defaults: `--min-support 5` shared commits, `--min-confidence 0.5` max-direction ratio, `--top 50`. Bulk commits never couple pairs, and a young repo returns nothing at the default support. |
|
|
740
742
|
| `mutate [--files F ...] [--max-mutants N] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "crapkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Deterministic CRAP-score framework: per-function complexity x coverage risk, worklists, ratchets, refactor verification"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crapkit: deterministic CRAP-score framework."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.3.0"
|
|
@@ -6,7 +6,7 @@ importlib.metadata, email, zipfile and socket. Measured on this box: 42ms with
|
|
|
6
6
|
pygments, 16ms without, paid by every process that touches the analysis stack,
|
|
7
7
|
the pre-commit hook included.
|
|
8
8
|
|
|
9
|
-
crapkit analyzes
|
|
9
|
+
crapkit analyzes six languages (typescript, tsx, javascript, python, swift, go).
|
|
10
10
|
Erlang is not one of them and no scope can name it. The readers that need
|
|
11
11
|
pygments are still SHIPPED, not removed: `deferred_pygments()` puts proxies in
|
|
12
12
|
sys.modules for the duration of the lizard import, so the readers bind stand-ins
|
|
@@ -27,7 +27,7 @@ _POOL_THRESHOLD = 16
|
|
|
27
27
|
# Bump whenever analysis semantics change (merge rules, extension set, record
|
|
28
28
|
# extraction): the fingerprint must invalidate cached records produced by older
|
|
29
29
|
# logic even when file content and tool versions are identical.
|
|
30
|
-
ANALYSIS_VERSION =
|
|
30
|
+
ANALYSIS_VERSION = 4 # 4: cognitive stopped reading 0 for every Swift and Kotlin function
|
|
31
31
|
|
|
32
32
|
# The three tokens lizard's modified rule reacts to. Membership is checked before
|
|
33
33
|
# anything else runs, so the common token pays one frozenset lookup.
|
|
@@ -69,12 +69,39 @@ class _ModifiedDelta:
|
|
|
69
69
|
yield token
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
def _chain(cognitive_index: int) -> list:
|
|
73
|
+
"""lizard's standard extensions with cognitive spliced in at one index.
|
|
74
|
+
|
|
75
|
+
Index 0 puts cognitive ahead of lizard's own `preprocessing`, which is where
|
|
76
|
+
it has to sit for Python: `preprocessing` strips the whitespace tokens the
|
|
77
|
+
python indent rules read, and behind it a 6-branch function scores 6 instead
|
|
78
|
+
of 10. The delta comes last either way, where the modified pass used to sit.
|
|
79
|
+
"""
|
|
80
|
+
extensions = lizard.get_extensions(["ND"])
|
|
81
|
+
extensions.insert(cognitive_index, _Cognitive())
|
|
82
|
+
return extensions + [_ModifiedDelta()]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Two chains, built once per process each, not once per file: 14k files paid 14k
|
|
86
|
+
# chain builds. Every extension keeps its state in the generator frame __call__
|
|
87
|
+
# opens, so one chain serves every file of its kind.
|
|
88
|
+
_EXTENSIONS = _chain(0)
|
|
89
|
+
_PREPROCESSED_EXTENSIONS = _chain(1)
|
|
90
|
+
|
|
91
|
+
# lizard's SwiftReplaceLabel.preprocess RETURNS a list where the other seven
|
|
92
|
+
# preprocessors YIELD: it runs list() over its input, so every extension AHEAD of
|
|
93
|
+
# `preprocessing` is drained to exhaustion before lizard has split the file into
|
|
94
|
+
# functions. An extension at index 0 counts the whole file against one
|
|
95
|
+
# placeholder FunctionInfo, and every real function comes out at 0. SwiftReader
|
|
96
|
+
# and KotlinReader are the only two of lizard's 27 readers that inherit that
|
|
97
|
+
# preprocessor, so only these suffixes take the second chain.
|
|
98
|
+
_DRAINED_READER_SUFFIXES = (".swift", ".kt", ".kts")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _extensions_for(rel_path: str) -> list:
|
|
102
|
+
if rel_path.lower().endswith(_DRAINED_READER_SUFFIXES):
|
|
103
|
+
return _PREPROCESSED_EXTENSIONS
|
|
104
|
+
return _EXTENSIONS
|
|
78
105
|
|
|
79
106
|
|
|
80
107
|
def _record(rel_path: str, fn) -> FunctionRecord:
|
|
@@ -98,7 +125,7 @@ def _record(rel_path: str, fn) -> FunctionRecord:
|
|
|
98
125
|
def analyze_one(args: tuple[str, str]) -> tuple[str, list[FunctionRecord]]:
|
|
99
126
|
abs_path, rel_path = args
|
|
100
127
|
try:
|
|
101
|
-
analysis = lizard.FileAnalyzer(
|
|
128
|
+
analysis = lizard.FileAnalyzer(_extensions_for(rel_path))(abs_path)
|
|
102
129
|
return rel_path, [_record(rel_path, fn) for fn in analysis.function_list]
|
|
103
130
|
except Exception as exc: # loud, with the file named
|
|
104
131
|
raise ToolError(f"lizard failed on {rel_path}: {exc}") from exc
|
|
@@ -110,10 +137,11 @@ def analyze_source(rel_path: str, code: str) -> list[FunctionRecord]:
|
|
|
110
137
|
analyze_source_code is what FileAnalyzer.__call__ runs once it has read the
|
|
111
138
|
file, so nothing about the analysis depends on whether the source arrived
|
|
112
139
|
from the disk or from a git blob the caller already holds; rel_path picks
|
|
113
|
-
the language exactly as the path on disk did.
|
|
140
|
+
the language, and with it the extension chain, exactly as the path on disk did.
|
|
114
141
|
"""
|
|
115
142
|
try:
|
|
116
|
-
|
|
143
|
+
analyzer = lizard.FileAnalyzer(_extensions_for(rel_path))
|
|
144
|
+
analysis = analyzer.analyze_source_code(rel_path, code)
|
|
117
145
|
return [_record(rel_path, fn) for fn in analysis.function_list]
|
|
118
146
|
except Exception as exc: # loud, with the file named
|
|
119
147
|
raise ToolError(f"lizard failed on {rel_path}: {exc}") from exc
|
|
@@ -188,7 +188,10 @@ _OWNER = {
|
|
|
188
188
|
"_records_by_scope": "scoring",
|
|
189
189
|
"_release_claims": "verifying",
|
|
190
190
|
"_release_target": "queue",
|
|
191
|
+
"_report_lanes": "reports",
|
|
192
|
+
"_report_payload": "reports",
|
|
191
193
|
"_report_verify": "verifying",
|
|
194
|
+
"_report_worklist": "reports",
|
|
192
195
|
"_require_ancestor": "verifying",
|
|
193
196
|
"_rescore_analyze": "scoring",
|
|
194
197
|
"_rescore_gate": "scoring",
|
|
@@ -226,6 +229,7 @@ _OWNER = {
|
|
|
226
229
|
"_taint_note": "verifying",
|
|
227
230
|
"_traced_lane": "scoring",
|
|
228
231
|
"_tracked_files": "scoring",
|
|
232
|
+
"_trend_payload": "reports",
|
|
229
233
|
"_trend_row": "reports",
|
|
230
234
|
"_tsv_baseline": "verifying",
|
|
231
235
|
"_unclaimed": "queue",
|
|
@@ -257,6 +261,7 @@ _OWNER = {
|
|
|
257
261
|
"_worklist_marks": "queue",
|
|
258
262
|
"_worklist_payload": "queue",
|
|
259
263
|
"_worklist_print": "queue",
|
|
264
|
+
"_write_report": "reports",
|
|
260
265
|
"_write_tsv": "_shared",
|
|
261
266
|
"build_parser": "parser",
|
|
262
267
|
"cmd_brief": "queue",
|
|
@@ -275,6 +280,7 @@ _OWNER = {
|
|
|
275
280
|
"cmd_next_item": "queue",
|
|
276
281
|
"cmd_overrides": "reports",
|
|
277
282
|
"cmd_ratchet": "ratchet_cmds",
|
|
283
|
+
"cmd_report": "reports",
|
|
278
284
|
"cmd_rescore": "scoring",
|
|
279
285
|
"cmd_runs": "reports",
|
|
280
286
|
"cmd_test_scoped": "verifying",
|
|
@@ -247,6 +247,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
247
247
|
trd.add_argument("--json", action="store_true", help="print as JSON")
|
|
248
248
|
trd.set_defaults(func=_Handler("reports", "cmd_trend"))
|
|
249
249
|
|
|
250
|
+
rep = sub.add_parser("report", help="one self-contained HTML page: the ranked worklist, "
|
|
251
|
+
"the per-scope grades, the trend, and a staleness banner")
|
|
252
|
+
rep.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
|
|
253
|
+
rep.add_argument("--out", default=".crapkit/report.html", metavar="PATH",
|
|
254
|
+
help="where to write the page, relative to the repo "
|
|
255
|
+
"(default: .crapkit/report.html); the path is printed on stdout")
|
|
256
|
+
rep.set_defaults(func=_Handler("reports", "cmd_report"))
|
|
257
|
+
|
|
250
258
|
tsc = sub.add_parser("test-scoped", help="run the configured isolated test command for the files' scope")
|
|
251
259
|
tsc.add_argument("files", nargs="+", help="repo-relative test files")
|
|
252
260
|
tsc.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
|
|
@@ -114,19 +114,101 @@ def _print_trend(as_json: bool, rows_out: list, target: int) -> None:
|
|
|
114
114
|
f"{r['over_target']} over target, load {r['crap_load']}, avg {r['avg']}")
|
|
115
115
|
|
|
116
116
|
|
|
117
|
-
def
|
|
117
|
+
def _trend_payload(cfg, store: SnapshotStore) -> dict:
|
|
118
|
+
"""The whole series, shaped once. `trend --json` prints it and `report`
|
|
119
|
+
renders it, so the page and the payload cannot describe the same run
|
|
120
|
+
differently."""
|
|
118
121
|
from ..store import trusted_runs
|
|
119
122
|
|
|
120
|
-
root = Path(args.repo).resolve()
|
|
121
|
-
cfg = _load_repo_config(root)
|
|
122
|
-
store = _open_store(root)
|
|
123
123
|
# one GROUP BY for the whole history; this used to build every ScoredRow of
|
|
124
124
|
# every trusted run to add up three numbers per run
|
|
125
125
|
agg = store.run_totals(target=cfg.target, scope_targets=cfg.scope_targets)
|
|
126
126
|
by_scope = store.run_scope_totals(target=cfg.target, scope_targets=cfg.scope_targets)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
return {"target": cfg.target,
|
|
128
|
+
"runs": [_trend_row(run, agg.get(run["id"], (0, 0, 0.0)),
|
|
129
|
+
by_scope.get(run["id"], {}))
|
|
130
|
+
for run in trusted_runs(store)]}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_trend(args: argparse.Namespace) -> int:
|
|
134
|
+
root = Path(args.repo).resolve()
|
|
135
|
+
cfg = _load_repo_config(root)
|
|
136
|
+
payload = _trend_payload(cfg, _open_store(root))
|
|
137
|
+
_print_trend(args.json, payload["runs"], cfg.target)
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- the static page ---------------------------------------------------------
|
|
142
|
+
#
|
|
143
|
+
# `report` measures nothing. It collects the payloads `worklist --json` and
|
|
144
|
+
# `trend --json` already answer, at their defaults, adds the per-lane staleness
|
|
145
|
+
# `load_uncovered` computes, and hands the lot to a pure renderer. Everything
|
|
146
|
+
# below is collection; the markup lives in crapkit.report.
|
|
147
|
+
|
|
148
|
+
def _report_worklist(root: Path, cfg, store: SnapshotStore) -> dict:
|
|
149
|
+
"""The ranked queue through the SAME shaping `worklist --json` prints.
|
|
150
|
+
|
|
151
|
+
Reassembling it here would let the page rank one function first and the
|
|
152
|
+
command another, off one run.
|
|
153
|
+
"""
|
|
154
|
+
from ..churn_cache import load_churn
|
|
155
|
+
from ..gitio import head_commit
|
|
156
|
+
from ..report import report_top
|
|
157
|
+
from ..worklist import build_worklist
|
|
158
|
+
from .queue import _pushdown_floor, _worklist_marks, _worklist_payload, _worklist_run
|
|
159
|
+
|
|
160
|
+
latest = _worklist_run(root, store)
|
|
161
|
+
rows = store.read_rows(latest["id"], min_ccn=_pushdown_floor(cfg), scopes=[])
|
|
162
|
+
wl = build_worklist(rows, load_churn(root, cfg.churn_window_months),
|
|
163
|
+
floor=cfg.worklist_floor, top=report_top(cfg.worklist_top),
|
|
164
|
+
marks=_worklist_marks(store, cfg, latest["id"], []))
|
|
165
|
+
return _worklist_payload(wl, latest, cfg, latest["commit"] != head_commit(root), None)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _report_lanes(root: Path, cfg) -> list[dict]:
|
|
169
|
+
"""Per-lane staleness, the detail the joined note throws away."""
|
|
170
|
+
from ..uncovered import lane_states
|
|
171
|
+
|
|
172
|
+
return [{"name": name, "note": note} for name, note in lane_states(root, cfg)]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _report_payload(root: Path, cfg, store: SnapshotStore) -> dict:
|
|
176
|
+
from datetime import datetime, timezone
|
|
177
|
+
|
|
178
|
+
return {"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
179
|
+
"lanes": _report_lanes(root, cfg),
|
|
180
|
+
"repo": root.name,
|
|
181
|
+
"target": cfg.target,
|
|
182
|
+
"trend": _trend_payload(cfg, store),
|
|
183
|
+
"worklist": _report_worklist(root, cfg, store)}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _write_report(root: Path, out: str, page: str) -> Path:
|
|
187
|
+
"""Write the page and say where it went.
|
|
188
|
+
|
|
189
|
+
`--out` is repo-relative, like `--export` and `--sarif`: a path that climbs
|
|
190
|
+
out of the tree writes somewhere nobody asked for. LF endings, because the
|
|
191
|
+
page is an artifact people diff and publish.
|
|
192
|
+
"""
|
|
193
|
+
base = root.resolve()
|
|
194
|
+
path = (base / out).resolve()
|
|
195
|
+
if base not in path.parents:
|
|
196
|
+
raise ConfigError(f"report --out stays inside {base}, got {out!r}")
|
|
197
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
path.write_text(page, encoding="utf-8", newline="\n")
|
|
199
|
+
print(path)
|
|
200
|
+
return path
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
204
|
+
"""One self-contained page: the ranked worklist, the per-scope grades, the
|
|
205
|
+
trend series, and a banner when stale artifacts make them untrustworthy."""
|
|
206
|
+
from ..report import render_report
|
|
207
|
+
|
|
208
|
+
root = Path(args.repo).resolve()
|
|
209
|
+
cfg = _load_repo_config(root)
|
|
210
|
+
payload = _report_payload(root, cfg, _open_store(root))
|
|
211
|
+
_write_report(root, args.out, render_report(payload))
|
|
130
212
|
return 0
|
|
131
213
|
|
|
132
214
|
|
|
@@ -17,14 +17,15 @@ from ._shared import (_analysis_tools, _dirty_tag, _emit_findings, _gate_line,
|
|
|
17
17
|
from .scoring import _scored_run
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def _emit_verify_findings(root: Path, args, verdict) -> None:
|
|
20
|
+
def _emit_verify_findings(root: Path, args, verdict, uncovered: list) -> None:
|
|
21
21
|
if not (args.sarif or args.github):
|
|
22
22
|
return
|
|
23
|
-
from ..sarif import gate_results, regression_results
|
|
23
|
+
from ..sarif import diff_uncovered_results, gate_results, regression_results
|
|
24
24
|
|
|
25
25
|
_emit_findings(root, args.sarif, args.github,
|
|
26
26
|
gate_results(verdict.gate_violations)
|
|
27
|
-
+ regression_results(verdict.ratchet_regressions)
|
|
27
|
+
+ regression_results(verdict.ratchet_regressions)
|
|
28
|
+
+ diff_uncovered_results(uncovered))
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def _no_baseline(root: Path) -> str:
|
|
@@ -366,7 +367,7 @@ def cmd_verify(args: argparse.Namespace) -> int:
|
|
|
366
367
|
verdict, overridden = _apply_verify_override(store, run_id, root, cfg, verdict, args.override)
|
|
367
368
|
_settle_verify(store, run_id, verdict, overridden, ratchet_path, ratchet, scored, cfg)
|
|
368
369
|
_release_claims(store, git, cfg, scored)
|
|
369
|
-
_emit_verify_findings(root, args, verdict)
|
|
370
|
+
_emit_verify_findings(root, args, verdict, uncovered)
|
|
370
371
|
|
|
371
372
|
_report_verify(args.json,
|
|
372
373
|
{**_verify_result(verdict, overridden, run_id, baseline, commit, ranges, uncovered),
|
|
@@ -6,11 +6,14 @@ from typing import NamedTuple
|
|
|
6
6
|
|
|
7
7
|
from .errors import ConfigError
|
|
8
8
|
|
|
9
|
-
SUPPORTED_LANGUAGES = frozenset({"typescript", "tsx", "javascript", "python", "swift"})
|
|
9
|
+
SUPPORTED_LANGUAGES = frozenset({"typescript", "tsx", "javascript", "python", "swift", "go"})
|
|
10
10
|
SUPPORTED_PARSERS = frozenset({"istanbul", "coveragepy"})
|
|
11
11
|
DEFAULT_TARGET = 6
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# Only what a vitest command line can carry: this tuple guards istanbul lane
|
|
14
|
+
# commands against a positional file filter, and no .swift or .go path appears
|
|
15
|
+
# in one.
|
|
16
|
+
_SOURCE_SUFFIXES = (".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs", ".cjs", ".py")
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
class Scope(NamedTuple):
|
|
@@ -217,11 +217,15 @@ def _indent(line: str) -> int:
|
|
|
217
217
|
|
|
218
218
|
|
|
219
219
|
def _def_pattern(identifier: str) -> re.Pattern[str]:
|
|
220
|
-
"""A line that DEFINES the name: the keyword forms of
|
|
221
|
-
|
|
220
|
+
"""A line that DEFINES the name: the keyword forms of every language family
|
|
221
|
+
crapkit reads, plus the method shorthand a class body uses.
|
|
222
|
+
|
|
223
|
+
`function` sits before `func` and `fun` in the alternation so a JavaScript
|
|
224
|
+
definition never matches on a prefix of its own keyword.
|
|
225
|
+
"""
|
|
222
226
|
name = re.escape(identifier)
|
|
223
227
|
return re.compile(r"^\s*" + _DEF_MODIFIERS
|
|
224
|
-
+ r"(?:(?:def|class|function|const|let|var)\s+" + name + r"\b"
|
|
228
|
+
+ r"(?:(?:def|class|function|func|fun|const|let|var)\s+" + name + r"\b"
|
|
225
229
|
+ r"|" + name + r"\s*\([^)]*\)\s*[:{])")
|
|
226
230
|
|
|
227
231
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Cognitive complexity (Sonar spec) as a lizard token-stream extension.
|
|
2
2
|
|
|
3
|
-
Rides lizard's language-aware tokenizers, so TS/TSX/JS/Python all pay the
|
|
4
|
-
rules with no second parse and no new dependency:
|
|
3
|
+
Rides lizard's language-aware tokenizers, so TS/TSX/JS/Python/Swift all pay the
|
|
4
|
+
same rules with no second parse and no new dependency:
|
|
5
5
|
+1 and +nesting for if / ternary / switch / loops / catch-except
|
|
6
6
|
+1 flat for else / elif (an else-if chain costs one per link, no deepening)
|
|
7
7
|
+1 per boolean-operator run, +1 each time the operator alternates
|
|
@@ -13,6 +13,12 @@ Attribution follows lizard's function splitting (a nested arrow's tokens are
|
|
|
13
13
|
the arrow's), exactly as ccn is attributed today. Ternary branches do not
|
|
14
14
|
deepen nesting (a structure inside a ternary arm is rare enough to accept).
|
|
15
15
|
|
|
16
|
+
Where this extension sits in lizard's chain is load-bearing and differs by
|
|
17
|
+
reader: the python rules read whitespace tokens that lizard's own
|
|
18
|
+
`preprocessing` strips, while SwiftReader and KotlinReader drain the stream
|
|
19
|
+
inside their `preprocess` and starve anything placed ahead of it. analyze._chain
|
|
20
|
+
owns that placement.
|
|
21
|
+
|
|
16
22
|
The whitepaper's worked examples in tests/unit/test_cognitive.py are the spec.
|
|
17
23
|
"""
|
|
18
24
|
from __future__ import annotations
|
|
@@ -32,8 +32,10 @@ _OPS = {
|
|
|
32
32
|
"&&": ("||",), "||": ("&&",), "true": ("false",), "false": ("true",)},
|
|
33
33
|
}
|
|
34
34
|
# a short token matching INSIDE one of these is not that operator (== in ===,
|
|
35
|
-
# > in => arrows, < in
|
|
36
|
-
|
|
35
|
+
# > in => arrows, < in <=, < in a Swift half-open range): skip the occurrence
|
|
36
|
+
# entirely. `0..<b` mutated to `0..<=b` does not compile, so the mutant dies on
|
|
37
|
+
# the compiler and reads as killed by a test that never ran.
|
|
38
|
+
_PROTECT = ("===", "!==", "==", "!=", "<=", ">=", "=>", "->", "..<", "...")
|
|
37
39
|
_COMMENT_PREFIXES = ("#", "//", "/*", "*")
|
|
38
40
|
_STRING_RE = re.compile(r"'[^']*'|\"[^\"]*\"|`[^`]*`")
|
|
39
41
|
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""One static HTML page for a scored run: the ranked worklist, the per-scope
|
|
2
|
+
grades, the trend series, and a banner when the artifacts behind them are stale.
|
|
3
|
+
|
|
4
|
+
The whole module is a pure function from two payloads the CLI already answers to
|
|
5
|
+
a string. It measures nothing and reads no file, so the page can never say
|
|
6
|
+
something `crapkit worklist --json` and `crapkit trend --json` do not.
|
|
7
|
+
|
|
8
|
+
Three decisions are load-bearing.
|
|
9
|
+
|
|
10
|
+
The page renders the worklist AT ITS DEFAULT, `worklist_top` rows. Rendering
|
|
11
|
+
every admitted row was measured: 46,567 rows of openclaw came to 9.85 MB of HTML
|
|
12
|
+
and 46,567 DOM rows, which is a page that hangs the tab rather than one a
|
|
13
|
+
teammate opens. `report_top` refuses anything past REPORT_ROW_CEILING.
|
|
14
|
+
|
|
15
|
+
There is no per-function drill-down, because no --json surface carries
|
|
16
|
+
per-function CRAP or coverage repo-wide: a worklist row has `risk` and `weight`,
|
|
17
|
+
not `crap` and `cov`. Each row prints `crapkit explain PATH NAME` instead, so the
|
|
18
|
+
detail is one command away and the file stays in the tens of KB.
|
|
19
|
+
|
|
20
|
+
Every value that reaches the page goes through `_esc`. A function name is source
|
|
21
|
+
text, not markup: `read_chunk( path , max_bytes = 1 < < 20 )` is a real recorded
|
|
22
|
+
signature, and unescaped its `<` opens a tag that swallows the rest of the row.
|
|
23
|
+
|
|
24
|
+
The palette is the handbook's, copied verbatim so the tool has one look; the
|
|
25
|
+
contract test compares the two.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from html import escape
|
|
30
|
+
|
|
31
|
+
from .errors import ConfigError
|
|
32
|
+
|
|
33
|
+
# Measured at 46,567 rows / 9.85 MB. A few thousand rows is already a page
|
|
34
|
+
# nobody scrolls; past that it is a page nobody opens.
|
|
35
|
+
REPORT_ROW_CEILING = 2000
|
|
36
|
+
|
|
37
|
+
_CHART_W = 660
|
|
38
|
+
_CHART_H = 130
|
|
39
|
+
|
|
40
|
+
_WORKLIST_COLUMNS = ("Risk", "CCN", "nloc", "Churn", "Scope", "Function",
|
|
41
|
+
"Verdict", "Drill down")
|
|
42
|
+
_SCOPE_COLUMNS = ("Scope", "Functions", "Over target", "CRAP load", "Grade")
|
|
43
|
+
_TREND_COLUMNS = ("Run", "Commit", "When", "Functions", "Over target",
|
|
44
|
+
"CRAP load", "Average")
|
|
45
|
+
|
|
46
|
+
_EMPTY_RUN = {"run_id": 0, "commit": "", "created_at": "", "functions": 0,
|
|
47
|
+
"over_target": 0, "crap_load": 0.0, "avg": 0.0, "by_scope": {}}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def report_top(worklist_top: int) -> int:
|
|
51
|
+
"""The row cap, or a refusal naming the knob that set it.
|
|
52
|
+
|
|
53
|
+
A refusal beats a 10 MB page: the reader can lower `worklist_top` and get a
|
|
54
|
+
page, and cannot un-hang a tab.
|
|
55
|
+
"""
|
|
56
|
+
if worklist_top > REPORT_ROW_CEILING:
|
|
57
|
+
raise ConfigError(
|
|
58
|
+
f"report renders at most {REPORT_ROW_CEILING} rows and worklist_top is "
|
|
59
|
+
f"{worklist_top}: lower [crapkit] worklist_top, or read the whole "
|
|
60
|
+
"ranking with `crapkit worklist --json`")
|
|
61
|
+
return worklist_top
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_report(payload: dict) -> str:
|
|
65
|
+
"""The whole page.
|
|
66
|
+
|
|
67
|
+
`payload` carries `repo`, `generated_at`, `target`, `lanes` (name and the
|
|
68
|
+
note saying why its artifact cannot name line numbers, "" when it can), and
|
|
69
|
+
the `worklist` and `trend` payloads verbatim.
|
|
70
|
+
"""
|
|
71
|
+
body = [_header(payload), _banner(payload), _scopes(payload),
|
|
72
|
+
_worklist(payload), _trend(payload), _footer(payload)]
|
|
73
|
+
return (f"{_DOC_OPEN}<title>crapkit report: {_esc(payload['repo'])}</title>\n"
|
|
74
|
+
f"<style>{_STYLE}</style>\n</head>\n<body>\n<div class=\"shell\">\n"
|
|
75
|
+
+ "\n".join(body) + "\n</div>\n</body>\n</html>\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _esc(value) -> str:
|
|
79
|
+
"""Every value on the page passes through here. Paths, signatures, scope
|
|
80
|
+
names and lane notes are all source text somebody else wrote."""
|
|
81
|
+
return escape(str(value), quote=True)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- the page's parts --------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _header(payload: dict) -> str:
|
|
87
|
+
wl = payload["worklist"]
|
|
88
|
+
return (f'<header class="hero"><p class="kicker">crapkit report · '
|
|
89
|
+
f'self-contained · generated {_esc(payload["generated_at"])}</p>'
|
|
90
|
+
f'<h1>{_esc(payload["repo"])}</h1>'
|
|
91
|
+
f'<p class="standfirst">Run {_esc(wl["run_id"])} at '
|
|
92
|
+
f'<code>{_esc(wl["commit"][:11])}</code>, target ccn {_esc(payload["target"])}. '
|
|
93
|
+
f'Every number here comes from <code>crapkit worklist --json</code> and '
|
|
94
|
+
f'<code>crapkit trend --json</code> at their defaults.</p></header>')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _footer(payload: dict) -> str:
|
|
98
|
+
wl = payload["worklist"]
|
|
99
|
+
return (f'<footer>Ranked by risk (ccn times recency-weighted churn) over a '
|
|
100
|
+
f'{_esc(wl["churn_window_months"])}-month window, admitted at '
|
|
101
|
+
f'ccn >= {_esc(wl["floor"])}. Per-function CRAP and coverage are not on this '
|
|
102
|
+
f'page: no repo-wide payload carries them. Run the drill-down command on a row '
|
|
103
|
+
f'to get them.</footer>')
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- the banner --------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _banner(payload: dict) -> str:
|
|
109
|
+
"""Loud when anything makes the numbers below untrustworthy, quiet otherwise.
|
|
110
|
+
|
|
111
|
+
Three separate faults read the same on the surface and want the same move,
|
|
112
|
+
so they stack into one block rather than competing for the top of the page.
|
|
113
|
+
"""
|
|
114
|
+
lanes = payload["lanes"]
|
|
115
|
+
reasons = (_no_lane_reason(lanes) + _stale_lane_reason(lanes)
|
|
116
|
+
+ _behind_head_reason(payload["worklist"]))
|
|
117
|
+
if reasons:
|
|
118
|
+
return ('<div class="banner stale"><p class="shout">Read this before the '
|
|
119
|
+
'numbers below</p>' + "".join(reasons) + "</div>")
|
|
120
|
+
return (f'<div class="banner fresh"><p>All {len(lanes)} lane artifact(s) still '
|
|
121
|
+
f'describe this working tree, and the run sits on HEAD.</p></div>')
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _no_lane_reason(lanes: list[dict]) -> list[str]:
|
|
125
|
+
if lanes:
|
|
126
|
+
return []
|
|
127
|
+
return ["<p>This repo declares no [[lane]], so no artifact can say which lines "
|
|
128
|
+
"any test ran. Coverage reads 0 by default here, not by measurement.</p>"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _stale_lane_reason(lanes: list[dict]) -> list[str]:
|
|
132
|
+
"""The blackout, stated at its real size.
|
|
133
|
+
|
|
134
|
+
A single stale lane makes `load_uncovered` return no line numbers for ANY
|
|
135
|
+
path, not just that lane's. Uncommitted edits count, which is the normal
|
|
136
|
+
state of a tree somebody generates a report from.
|
|
137
|
+
"""
|
|
138
|
+
stale = [lane for lane in lanes if lane["note"]]
|
|
139
|
+
if not stale:
|
|
140
|
+
return []
|
|
141
|
+
return [f"<p><b>{len(stale)} of {len(lanes)} lanes are stale.</b> One stale lane "
|
|
142
|
+
"blacks out line-level coverage repo-wide, not just its own scopes. "
|
|
143
|
+
"Commit or revert the edits, then rerun <code>crapkit coverage</code>.</p>"
|
|
144
|
+
+ _lane_list(stale)]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _lane_list(stale: list[dict]) -> str:
|
|
148
|
+
items = "".join(f'<li><b>{_esc(lane["name"])}</b>: {_esc(lane["note"])}</li>'
|
|
149
|
+
for lane in stale)
|
|
150
|
+
return f'<ul class="lanes">{items}</ul>'
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _behind_head_reason(wl: dict) -> list[str]:
|
|
154
|
+
if not wl["stale"]:
|
|
155
|
+
return []
|
|
156
|
+
return [f'<p>The snapshot is run {_esc(wl["run_id"])} at '
|
|
157
|
+
f'<code>{_esc(wl["commit"][:11])}</code> and HEAD has moved on. Fresh '
|
|
158
|
+
f'artifacts do not rescue a run measured at another commit: rerun '
|
|
159
|
+
f'<code>crapkit coverage</code>.</p>']
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- per-scope grades --------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _newest_run(trend: dict) -> dict:
|
|
165
|
+
"""The run the grades describe. An empty history grades nothing rather than
|
|
166
|
+
raising: a repo with one inventory run still gets a page."""
|
|
167
|
+
runs = trend["runs"]
|
|
168
|
+
return runs[-1] if runs else _EMPTY_RUN
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _scopes(payload: dict) -> str:
|
|
172
|
+
newest = _newest_run(payload["trend"])
|
|
173
|
+
rows = "".join(_scope_row(name, block)
|
|
174
|
+
for name, block in sorted(newest["by_scope"].items()))
|
|
175
|
+
return _section("scopes", "Grades by scope",
|
|
176
|
+
_table(_SCOPE_COLUMNS, rows or _empty_row(len(_SCOPE_COLUMNS),
|
|
177
|
+
"no scored run yet")))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _scope_row(name: str, block: dict) -> str:
|
|
181
|
+
return (f'<tr data-scope="{_esc(name)}"><td class="mono">{_esc(name)}</td>'
|
|
182
|
+
f'<td class="mono">{_esc(block["functions"])}</td>'
|
|
183
|
+
f'<td class="mono">{_esc(block["over_target"])}</td>'
|
|
184
|
+
f'<td class="mono">{_esc(block["crap_load"])}</td>'
|
|
185
|
+
f'<td>{_grade_chip(block["grade"])}</td></tr>')
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _grade_chip(grade: str) -> str:
|
|
189
|
+
return f'<span class="chip {_grade_tone(grade)}">{_esc(grade)}</span>'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _grade_tone(grade: str) -> str:
|
|
193
|
+
"""One letter, one colour. `score.grade` answers A+, A, B, C, D or F: green
|
|
194
|
+
through B, amber at C, red at D and F."""
|
|
195
|
+
if grade.startswith(("A", "B")):
|
|
196
|
+
return "o"
|
|
197
|
+
if grade.startswith("C"):
|
|
198
|
+
return "w"
|
|
199
|
+
return "a"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# --- the worklist ------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _worklist(payload: dict) -> str:
|
|
205
|
+
wl = payload["worklist"]
|
|
206
|
+
rows = "".join(_worklist_row(entry) for entry in wl["active"])
|
|
207
|
+
table = _table(_WORKLIST_COLUMNS,
|
|
208
|
+
rows or _empty_row(len(_WORKLIST_COLUMNS), "nothing admitted"))
|
|
209
|
+
return _section("worklist", f'Worklist: {len(wl["active"])} ranked, '
|
|
210
|
+
f'{_esc(wl["dormant_count"])} dormant', table)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _worklist_row(entry: dict) -> str:
|
|
214
|
+
"""One ranked function. `risk` prints as the payload carries it, so a number
|
|
215
|
+
read off the page and one read off `worklist --json` are the same number."""
|
|
216
|
+
return (f'<tr class="wl"><td class="mono">{_esc(entry["risk"])}</td>'
|
|
217
|
+
f'<td class="mono">{_esc(entry["ccn"])}</td>'
|
|
218
|
+
f'<td class="mono">{_esc(entry["nloc"])}</td>'
|
|
219
|
+
f'<td class="mono">{_esc(entry["commits"])}c/{_esc(entry["authors"])}a</td>'
|
|
220
|
+
f'<td class="mono">{_esc(entry["scope"])}</td>'
|
|
221
|
+
f'<td>{_function_cell(entry)}</td>'
|
|
222
|
+
f'<td>{_verdict_cell(entry)}</td>'
|
|
223
|
+
f'<td><code>{_drill_down(entry)}</code></td></tr>')
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _function_cell(entry: dict) -> str:
|
|
227
|
+
return (f'<div class="fn mono">{_esc(entry["function"])}</div>'
|
|
228
|
+
f'<div class="loc">{_esc(entry["path"])}:{_esc(entry["start"])}</div>')
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _verdict_cell(entry: dict) -> str:
|
|
232
|
+
"""`flag` and `remedy` are the run's own verdict, null on an inventory run."""
|
|
233
|
+
chips = [_chip(entry["flag"], _flag_tone(entry["flag"])),
|
|
234
|
+
_chip(entry["remedy"], _remedy_tone(entry["remedy"]))]
|
|
235
|
+
return "".join(c for c in chips if c)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _chip(text, tone: str) -> str:
|
|
239
|
+
return "" if text is None else f'<span class="chip {tone}">{_esc(text)}</span>'
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _flag_tone(flag) -> str:
|
|
243
|
+
return "i" if flag == "measured" else "w"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _remedy_tone(remedy) -> str:
|
|
247
|
+
return "o" if remedy == "ok" else "a"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _drill_down(entry: dict) -> str:
|
|
251
|
+
"""The command that answers what this page deliberately does not carry."""
|
|
252
|
+
return _esc(f'crapkit explain {entry["path"]} "{entry["function"]}"')
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# --- the trend series --------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def _trend(payload: dict) -> str:
|
|
258
|
+
runs = payload["trend"]["runs"]
|
|
259
|
+
rows = "".join(_trend_row(run) for run in runs)
|
|
260
|
+
table = _table(_TREND_COLUMNS, rows or _empty_row(len(_TREND_COLUMNS), "no scored run yet"))
|
|
261
|
+
return _section("trend", f"Trend: {len(runs)} scored run(s)", _chart(runs) + table)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _trend_row(run: dict) -> str:
|
|
265
|
+
return (f'<tr data-run="{_esc(run["run_id"])}">'
|
|
266
|
+
f'<td class="mono">{_esc(run["run_id"])}</td>'
|
|
267
|
+
f'<td class="mono">{_esc(run["commit"][:11])}</td>'
|
|
268
|
+
f'<td class="mono">{_esc(run["created_at"])}</td>'
|
|
269
|
+
f'<td class="mono">{_esc(run["functions"])}</td>'
|
|
270
|
+
f'<td class="mono">{_esc(run["over_target"])}</td>'
|
|
271
|
+
f'<td class="mono">{_esc(run["crap_load"])}</td>'
|
|
272
|
+
f'<td class="mono">{_esc(run["avg"])}</td></tr>')
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _chart(runs: list[dict]) -> str:
|
|
276
|
+
"""CRAP load per run as one polyline. Two runs is the smallest series with a
|
|
277
|
+
direction; one run is a dot nobody can read a trend off."""
|
|
278
|
+
loads = [run["crap_load"] for run in runs]
|
|
279
|
+
if len(loads) < 2:
|
|
280
|
+
return ""
|
|
281
|
+
points = " ".join(f"{x},{y}" for x, y in _chart_points(loads))
|
|
282
|
+
return (f'<figure class="panel"><svg viewBox="0 0 {_CHART_W} {_CHART_H}" role="img" '
|
|
283
|
+
f'aria-label="CRAP load across {len(loads)} runs">'
|
|
284
|
+
f'<polyline class="series" points="{points}"/></svg>'
|
|
285
|
+
f'<figcaption>CRAP load across {len(loads)} scored runs, oldest at the left. '
|
|
286
|
+
f'Peak {_esc(max(loads))}. Down is better.</figcaption></figure>')
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _chart_points(loads: list[float]) -> list[tuple[float, float]]:
|
|
290
|
+
"""Load scaled to the box, oldest first. A flat-zero series draws along the
|
|
291
|
+
floor rather than dividing by nothing."""
|
|
292
|
+
top = max(loads) or 1.0
|
|
293
|
+
step = _CHART_W / (len(loads) - 1)
|
|
294
|
+
return [(round(i * step, 1), round(_CHART_H - 8 - (value / top) * (_CHART_H - 16), 1))
|
|
295
|
+
for i, value in enumerate(loads)]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --- markup helpers ----------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def _section(anchor: str, title: str, body: str) -> str:
|
|
301
|
+
return f'<section id="{anchor}">\n<h2>{title}</h2>\n{body}\n</section>'
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _table(columns: tuple[str, ...], rows: str) -> str:
|
|
305
|
+
head = "".join(f"<th>{column}</th>" for column in columns)
|
|
306
|
+
return (f'<div class="tw"><table><thead><tr>{head}</tr></thead>'
|
|
307
|
+
f"<tbody>{rows}</tbody></table></div>")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _empty_row(width: int, text: str) -> str:
|
|
311
|
+
return f'<tr><td colspan="{width}" class="empty">{_esc(text)}</td></tr>'
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
_DOC_OPEN = ('<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
|
|
315
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">\n')
|
|
316
|
+
|
|
317
|
+
# Copied from docs/handbook.html so the tool has one look. The contract test
|
|
318
|
+
# compares every colour token here against that page.
|
|
319
|
+
_STYLE = """
|
|
320
|
+
:root {
|
|
321
|
+
--paper: #FAF8F4; --ink: #211C17; --muted: #6E6459; --faint: #94897C;
|
|
322
|
+
--line: #E5DFD5; --card: #FFFFFF; --card-line: #EAE4DA;
|
|
323
|
+
--accent: #A6391F; --accent-soft: #F7E9E4;
|
|
324
|
+
--ok: #2E7D46; --ok-soft: #E7F2EA;
|
|
325
|
+
--warn: #A66A00; --warn-soft: #F8EFDC;
|
|
326
|
+
--info: #3B5B8C; --info-soft: #E8EEF7;
|
|
327
|
+
--data1: #A6391F; --data2: #B8860B; --data3: #3E6B8F; --data4: #2E7D46;
|
|
328
|
+
--code-bg: #F1EDE5; --grid: #E9E3D8;
|
|
329
|
+
}
|
|
330
|
+
@media (prefers-color-scheme: dark) {
|
|
331
|
+
:root {
|
|
332
|
+
--paper: #17140F; --ink: #EAE4DB; --muted: #A79C8D; --faint: #7E7466;
|
|
333
|
+
--line: #322C24; --card: #1F1B15; --card-line: #373128;
|
|
334
|
+
--accent: #E0714F; --accent-soft: #33211B;
|
|
335
|
+
--ok: #6FBF8A; --ok-soft: #1E2E23;
|
|
336
|
+
--warn: #D9A250; --warn-soft: #302716;
|
|
337
|
+
--info: #8AA8D4; --info-soft: #1E2634;
|
|
338
|
+
--data1: #E0714F; --data2: #D9A250; --data3: #7FA3C8; --data4: #6FBF8A;
|
|
339
|
+
--code-bg: #262119; --grid: #2C271F;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
* { box-sizing: border-box; }
|
|
343
|
+
body { margin: 0; background: var(--paper); color: var(--ink);
|
|
344
|
+
font: 16px/1.62 system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; }
|
|
345
|
+
.shell { max-width: 1080px; margin: 0 auto; padding: 40px 24px 90px; }
|
|
346
|
+
code, .mono { font-family: ui-monospace, Consolas, "Cascadia Mono", Menlo, monospace; }
|
|
347
|
+
code { font-size: .85em; background: var(--code-bg); padding: 1px 5px; border-radius: 3px;
|
|
348
|
+
white-space: nowrap; }
|
|
349
|
+
|
|
350
|
+
header.hero { padding: 30px 0 24px; border-bottom: 3px solid var(--accent);
|
|
351
|
+
position: relative; overflow: hidden; }
|
|
352
|
+
header.hero::after { content: "ccn2"; position: absolute; right: -10px; top: -30px;
|
|
353
|
+
font: 700 170px/1 Charter, Cambria, Georgia, serif; color: var(--accent);
|
|
354
|
+
opacity: .06; pointer-events: none; user-select: none; }
|
|
355
|
+
.kicker { font: 600 12px/1 system-ui, sans-serif; letter-spacing: .16em;
|
|
356
|
+
text-transform: uppercase; color: var(--accent); margin: 0 0 16px; }
|
|
357
|
+
h1 { font-family: Charter, "Bitstream Charter", Cambria, Georgia, serif;
|
|
358
|
+
font-size: clamp(38px, 6vw, 56px); font-weight: 700; line-height: 1.02;
|
|
359
|
+
letter-spacing: -.015em; margin: 0 0 14px; }
|
|
360
|
+
.standfirst { max-width: 66ch; font-size: 17px; color: var(--muted); margin: 0; }
|
|
361
|
+
h2 { font-family: Charter, "Bitstream Charter", Cambria, Georgia, serif;
|
|
362
|
+
font-size: 28px; font-weight: 700; margin: 56px 0 10px; padding-top: 20px;
|
|
363
|
+
border-top: 1px solid var(--line); }
|
|
364
|
+
p { max-width: 72ch; margin: 0 0 12px; }
|
|
365
|
+
|
|
366
|
+
.banner { margin: 24px 0 0; padding: 16px 20px; border-radius: 8px;
|
|
367
|
+
border-left: 5px solid var(--ok); background: var(--ok-soft); }
|
|
368
|
+
.banner p { margin: 0 0 8px; font-size: 14.5px; }
|
|
369
|
+
.banner p:last-child { margin-bottom: 0; }
|
|
370
|
+
.banner.stale { border-left-color: var(--accent); background: var(--accent-soft); }
|
|
371
|
+
.banner.stale .shout { font: 700 15px/1.3 system-ui, sans-serif;
|
|
372
|
+
text-transform: uppercase; letter-spacing: .05em;
|
|
373
|
+
color: var(--accent); }
|
|
374
|
+
ul.lanes { margin: 6px 0 0; padding-left: 22px; font-size: 13.5px; color: var(--muted); }
|
|
375
|
+
ul.lanes li { margin-bottom: 5px; }
|
|
376
|
+
ul.lanes b { color: var(--ink); font-family: ui-monospace, Consolas, monospace; }
|
|
377
|
+
|
|
378
|
+
.tw { overflow-x: auto; border: 1px solid var(--card-line); border-radius: 8px;
|
|
379
|
+
background: var(--card); margin: 16px 0; }
|
|
380
|
+
table { border-collapse: collapse; width: 100%; min-width: 720px; font-size: 14px; }
|
|
381
|
+
th { font: 700 11px/1.3 system-ui, sans-serif; letter-spacing: .09em;
|
|
382
|
+
text-transform: uppercase; text-align: left; color: var(--muted);
|
|
383
|
+
padding: 11px 14px 9px; border-bottom: 2px solid var(--line); }
|
|
384
|
+
td { padding: 9px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
385
|
+
tr:last-child td { border-bottom: none; }
|
|
386
|
+
tbody tr:hover td { background: color-mix(in srgb, var(--accent) 4%, transparent); }
|
|
387
|
+
td.mono { white-space: nowrap; font-size: 13px; }
|
|
388
|
+
td.empty { color: var(--faint); font-style: italic; }
|
|
389
|
+
.fn { font-size: 13px; word-break: break-word; }
|
|
390
|
+
.loc { font-size: 12px; color: var(--faint); font-family: ui-monospace, Consolas, monospace; }
|
|
391
|
+
|
|
392
|
+
.chip { display: inline-block; font: 700 10.5px/1 system-ui, sans-serif;
|
|
393
|
+
letter-spacing: .07em; text-transform: uppercase; padding: 4px 8px 3px;
|
|
394
|
+
border-radius: 3px; white-space: nowrap; margin: 0 4px 3px 0; }
|
|
395
|
+
.chip.a { background: var(--accent-soft); color: var(--accent); }
|
|
396
|
+
.chip.o { background: var(--ok-soft); color: var(--ok); }
|
|
397
|
+
.chip.w { background: var(--warn-soft); color: var(--warn); }
|
|
398
|
+
.chip.i { background: var(--info-soft); color: var(--info); }
|
|
399
|
+
|
|
400
|
+
figure.panel { margin: 18px 0; background: var(--card); border: 1px solid var(--card-line);
|
|
401
|
+
border-radius: 10px; padding: 16px 18px 10px; overflow-x: auto; }
|
|
402
|
+
figcaption { font-size: 13px; color: var(--faint); margin-top: 8px; }
|
|
403
|
+
svg { display: block; max-width: 100%; height: auto; }
|
|
404
|
+
svg .series { fill: none; stroke: var(--data1); stroke-width: 2.5;
|
|
405
|
+
stroke-linejoin: round; stroke-linecap: round; }
|
|
406
|
+
|
|
407
|
+
footer { margin-top: 60px; padding-top: 20px; border-top: 1px solid var(--line);
|
|
408
|
+
color: var(--faint); font-size: 13.5px; max-width: 76ch; }
|
|
409
|
+
::selection { background: var(--accent-soft); }
|
|
410
|
+
@media (max-width: 640px) { .shell { padding: 28px 16px 60px; }
|
|
411
|
+
header.hero::after { display: none; } }
|
|
412
|
+
@media print { body { background: #fff; color: #111; }
|
|
413
|
+
header.hero::after { display: none; } .tw, figure { break-inside: avoid; } }
|
|
414
|
+
"""
|
|
@@ -14,6 +14,8 @@ _RULES = (
|
|
|
14
14
|
"shortDescription": {"text": "touched function over the complexity gate"}},
|
|
15
15
|
{"id": "crapkit/ratchet-regression",
|
|
16
16
|
"shortDescription": {"text": "a recorded CRAP mark got worse"}},
|
|
17
|
+
{"id": "crapkit/diff-uncovered",
|
|
18
|
+
"shortDescription": {"text": "a changed line no lane ever ran"}},
|
|
17
19
|
)
|
|
18
20
|
|
|
19
21
|
|
|
@@ -54,6 +56,19 @@ def regression_results(regressions) -> list[dict]:
|
|
|
54
56
|
for r in regressions]
|
|
55
57
|
|
|
56
58
|
|
|
59
|
+
def diff_uncovered_results(uncovered) -> list[dict]:
|
|
60
|
+
"""One finding per changed line no lane ran, from verify's own list.
|
|
61
|
+
|
|
62
|
+
A warning, not an error: the count is what `diff_uncovered_max` gates on,
|
|
63
|
+
and a single dark line is not a refusal by itself. Before this, these lines
|
|
64
|
+
reached stderr and nothing else, so the output a code-scanning UI reads
|
|
65
|
+
dropped every one of them.
|
|
66
|
+
"""
|
|
67
|
+
return [_result("crapkit/diff-uncovered", "warning", path, line,
|
|
68
|
+
"changed line has no coverage: no lane ran it")
|
|
69
|
+
for path, line in uncovered]
|
|
70
|
+
|
|
71
|
+
|
|
57
72
|
def sarif_document(results: list[dict]) -> dict:
|
|
58
73
|
return {
|
|
59
74
|
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/"
|
|
@@ -11,6 +11,9 @@ _EXT_LANGUAGE = {ext: lang for lang, exts in LANGUAGE_EXTENSIONS.items() for ext
|
|
|
11
11
|
DEFAULT_EXCLUDES = (
|
|
12
12
|
"**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**",
|
|
13
13
|
"**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py",
|
|
14
|
+
# Go puts its tests beside the source, so the test-directory rule never
|
|
15
|
+
# fires on them and every _test.go file would score as production code
|
|
16
|
+
"**/*_test.go",
|
|
14
17
|
# runner config files the docs themselves tell users to create; globs are
|
|
15
18
|
# whole-path, so the root form and the nested form are both required
|
|
16
19
|
"*.config.ts", "*.config.js", "*.config.mts",
|
|
@@ -70,23 +70,36 @@ def _absent_note(path: str, flag: str) -> str:
|
|
|
70
70
|
f"nothing for it; write the first test that imports {path})")
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def _parse_missing(lane, root: Path, artifact: Path) -> dict[str, set[int]]:
|
|
74
|
+
"""One lane's missing lines, read off the file.
|
|
75
|
+
|
|
76
|
+
Off the file, not out of a string: every declared lane's artifact would
|
|
77
|
+
otherwise be decoded whole, one after another, on one heap.
|
|
78
|
+
|
|
79
|
+
An unrecognised parser raises, in the same words `lanes._read_and_parse`
|
|
80
|
+
uses. This dispatch used to fall through to the coverage.py reader, so the
|
|
81
|
+
day a third parser joins SUPPORTED_PARSERS its lane would land here and
|
|
82
|
+
blame a perfectly good artifact for being unparseable.
|
|
83
|
+
"""
|
|
84
|
+
from . import covstream
|
|
85
|
+
from .errors import ToolError
|
|
86
|
+
|
|
87
|
+
if lane.parser == "istanbul":
|
|
88
|
+
return covstream.parse_istanbul_missing_file(artifact, repo_root=str(root))
|
|
89
|
+
if lane.parser == "coveragepy":
|
|
90
|
+
return covstream.parse_coveragepy_missing_file(artifact, path_prefix=lane.path_prefix)
|
|
91
|
+
raise ToolError(f"lane {lane.name!r}: parser {lane.parser!r} not implemented yet")
|
|
92
|
+
|
|
93
|
+
|
|
73
94
|
def missing_by_path(root: Path, cfg) -> dict[str, set[int]]:
|
|
74
95
|
"""Union of the lanes' line-level truth; a file two lanes measured keeps a
|
|
75
96
|
line dead only when NO lane ran it."""
|
|
76
|
-
from . import covstream
|
|
77
|
-
|
|
78
97
|
missing: dict[str, set[int]] = {}
|
|
79
98
|
for lane in cfg.lanes:
|
|
80
99
|
artifact = root / lane.artifact
|
|
81
100
|
if not artifact.is_file():
|
|
82
101
|
continue
|
|
83
|
-
|
|
84
|
-
# would otherwise be decoded whole, one after another, on one heap.
|
|
85
|
-
parsed = (covstream.parse_istanbul_missing_file(artifact, repo_root=str(root))
|
|
86
|
-
if lane.parser == "istanbul"
|
|
87
|
-
else covstream.parse_coveragepy_missing_file(
|
|
88
|
-
artifact, path_prefix=lane.path_prefix))
|
|
89
|
-
for path, lines in parsed.items():
|
|
102
|
+
for path, lines in _parse_missing(lane, root, artifact).items():
|
|
90
103
|
missing[path] = missing[path] & lines if path in missing else set(lines)
|
|
91
104
|
return missing
|
|
92
105
|
|
|
@@ -104,10 +117,25 @@ def _artifact_state(root: Path, lane, scope_paths: dict, git) -> str:
|
|
|
104
117
|
return ""
|
|
105
118
|
|
|
106
119
|
|
|
120
|
+
def lane_states(root: Path, cfg, git=None) -> list[tuple[str, str]]:
|
|
121
|
+
"""(lane name, why its line numbers are unusable) for every declared lane,
|
|
122
|
+
"" for a lane whose artifact still describes the tree.
|
|
123
|
+
|
|
124
|
+
`load_uncovered` joins these into one note and throws the per-lane detail
|
|
125
|
+
away, which is the right shape for "no lines for any path" and the wrong one
|
|
126
|
+
for a reader who wants to know WHICH lane to rerun. The report's staleness
|
|
127
|
+
banner reads this list; the joined note is built from it, so a lane cannot be
|
|
128
|
+
fresh in one and stale in the other.
|
|
129
|
+
"""
|
|
130
|
+
from .gitio import GitFacts
|
|
131
|
+
|
|
132
|
+
facts = git if git is not None else GitFacts(root)
|
|
133
|
+
return [(lane.name, _artifact_state(root, lane, cfg.scope_paths, facts))
|
|
134
|
+
for lane in cfg.lanes]
|
|
135
|
+
|
|
136
|
+
|
|
107
137
|
def _staleness_note(root: Path, cfg, git) -> str:
|
|
108
|
-
return "; ".join(
|
|
109
|
-
(_artifact_state(root, lane, cfg.scope_paths, git) for lane in cfg.lanes)
|
|
110
|
-
if state)
|
|
138
|
+
return "; ".join(note for _, note in lane_states(root, cfg, git) if note)
|
|
111
139
|
|
|
112
140
|
|
|
113
141
|
def load_uncovered(root: Path, cfg, git=None) -> MissingLines:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crapkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Deterministic CRAP-score framework: per-function complexity x coverage risk, worklists, ratchets, refactor verification
|
|
5
5
|
Author: Jean-Francois Gagne
|
|
6
6
|
License: MIT
|
|
@@ -28,9 +28,10 @@ Dynamic: license-file
|
|
|
28
28
|
|
|
29
29
|
crapkit scores every function in your repo on complexity times uncovered risk, ranks the
|
|
30
30
|
worst ones by how often the file changes, and blocks commits that add more. It reads
|
|
31
|
-
TypeScript, TSX, JavaScript and
|
|
31
|
+
TypeScript, TSX, JavaScript, Python, Swift and Go through [lizard](https://github.com/terryyin/lizard),
|
|
32
32
|
and joins per-function branch coverage from istanbul or coverage.py artifacts your own test
|
|
33
|
-
command already produces.
|
|
33
|
+
command already produces. Swift and Go have no coverage parser: declare those scopes
|
|
34
|
+
`coverage_optional` and they score on complexity alone. Every read-side command speaks JSON with a pinned schema, because
|
|
34
35
|
half the callers are coding agents.
|
|
35
36
|
|
|
36
37
|
```
|
|
@@ -76,7 +77,7 @@ Check the install:
|
|
|
76
77
|
|
|
77
78
|
```
|
|
78
79
|
$ crapkit --version
|
|
79
|
-
crapkit 0.
|
|
80
|
+
crapkit 0.3.0
|
|
80
81
|
```
|
|
81
82
|
|
|
82
83
|
`python -m crapkit` works identically to the `crapkit` console script, and is what to use
|
|
@@ -130,7 +131,7 @@ paths = ["calc"]
|
|
|
130
131
|
languages = ["python"]
|
|
131
132
|
|
|
132
133
|
[exclude]
|
|
133
|
-
globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
|
|
134
|
+
globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "**/*_test.go", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
|
|
134
135
|
|
|
135
136
|
[[lane]]
|
|
136
137
|
name = "py"
|
|
@@ -761,6 +762,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
|
|
|
761
762
|
| `overrides [--json]` | The override audit trail: who granted what, when, and why. |
|
|
762
763
|
| `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
|
|
763
764
|
| `digest [--alert]` | The delta between the two newest runs with identical lane sets. Silent when nothing changed. `--alert` pipes the body to `alert_command` on stdin. Plain lines, never JSON. |
|
|
765
|
+
| `report [--out PATH]` | One self-contained HTML page written to `.crapkit/report.html` (or `--out PATH`, repo-relative), with the path printed on stdout. It renders what `worklist --json` and `trend --json` already answer at their defaults: the ranked worklist capped at `worklist_top`, the per-scope grades off the newest run, the trend series, and a banner naming every stale lane. It measures nothing, opens no network connection, and carries no per-function CRAP or coverage, because no repo-wide payload has them; each row prints the `crapkit explain` call that does. |
|
|
764
766
|
| `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. |
|
|
765
767
|
| `coupling [--min-support N] [--min-confidence F] [--top N] [--json]` | File pairs that keep landing in the same commits. Defaults: `--min-support 5` shared commits, `--min-confidence 0.5` max-direction ratio, `--top 50`. Bulk commits never couple pairs, and a young repo returns nothing at the default support. |
|
|
766
768
|
| `mutate [--files F ...] [--max-mutants N] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. |
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|