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.
Files changed (65) hide show
  1. {crapkit-0.2.0/src/crapkit.egg-info → crapkit-0.3.0}/PKG-INFO +7 -5
  2. {crapkit-0.2.0 → crapkit-0.3.0}/README.md +6 -4
  3. {crapkit-0.2.0 → crapkit-0.3.0}/pyproject.toml +1 -1
  4. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/_pygdefer.py +1 -1
  6. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/analyze.py +38 -10
  7. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/__init__.py +6 -0
  8. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/parser.py +8 -0
  9. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/reports.py +89 -7
  10. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/verifying.py +5 -4
  11. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/config.py +5 -2
  12. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/discover.py +7 -3
  13. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/lizardcognitive.py +8 -2
  14. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/mutate.py +4 -2
  15. crapkit-0.3.0/src/crapkit/report.py +414 -0
  16. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/sarif.py +15 -0
  17. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/scaffold.py +3 -0
  18. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/uncovered.py +40 -12
  19. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/universe.py +1 -0
  20. {crapkit-0.2.0 → crapkit-0.3.0/src/crapkit.egg-info}/PKG-INFO +7 -5
  21. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit.egg-info/SOURCES.txt +1 -0
  22. {crapkit-0.2.0 → crapkit-0.3.0}/LICENSE +0 -0
  23. {crapkit-0.2.0 → crapkit-0.3.0}/setup.cfg +0 -0
  24. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/__main__.py +0 -0
  25. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cache.py +0 -0
  26. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/churn.py +0 -0
  27. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/churn_cache.py +0 -0
  28. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/churn_log.py +0 -0
  29. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/_shared.py +0 -0
  30. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/admin.py +0 -0
  31. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/analyses.py +0 -0
  32. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/queue.py +0 -0
  33. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/ratchet_cmds.py +0 -0
  34. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/cli/scoring.py +0 -0
  35. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/coupling.py +0 -0
  36. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/coverage_istanbul.py +0 -0
  37. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/coverage_py.py +0 -0
  38. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/covstream.py +0 -0
  39. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/diffparse.py +0 -0
  40. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/digest.py +0 -0
  41. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/doctor.py +0 -0
  42. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/dup.py +0 -0
  43. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/errors.py +0 -0
  44. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/gitio.py +0 -0
  45. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/hook.py +0 -0
  46. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/junitparse.py +0 -0
  47. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/lanes.py +0 -0
  48. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/mcp_server.py +0 -0
  49. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/merge.py +0 -0
  50. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/mutate_pool.py +0 -0
  51. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/override.py +0 -0
  52. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/packet.py +0 -0
  53. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/ratchet.py +0 -0
  54. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/ratchet_report.py +0 -0
  55. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/sarifio.py +0 -0
  56. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/score.py +0 -0
  57. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/snapshot.py +0 -0
  58. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/store.py +0 -0
  59. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/verify.py +0 -0
  60. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/watch.py +0 -0
  61. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit/worklist.py +0 -0
  62. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit.egg-info/dependency_links.txt +0 -0
  63. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit.egg-info/entry_points.txt +0 -0
  64. {crapkit-0.2.0 → crapkit-0.3.0}/src/crapkit.egg-info/requires.txt +0 -0
  65. {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.2.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 Python through [lizard](https://github.com/terryyin/lizard),
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. Every read-side command speaks JSON with a pinned schema, because
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.2.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 Python through [lizard](https://github.com/terryyin/lizard),
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. Every read-side command speaks JSON with a pinned schema, because
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.2.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.2.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.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 five languages (typescript, tsx, javascript, python, swift).
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 = 3 # 3: cognitive complexity column joined the standard pass
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
- # Built once per process, not once per file: 14k files paid 14k chain builds.
73
- # Every extension in it keeps its state in the generator frame __call__ opens,
74
- # so one chain serves every file. Cognitive comes FIRST because lizard's own
75
- # preprocessors strip the whitespace tokens its python rules read; the delta
76
- # comes last, where the modified pass used to sit.
77
- _EXTENSIONS = [_Cognitive()] + lizard.get_extensions(["ND"]) + [_ModifiedDelta()]
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(_EXTENSIONS)(abs_path)
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
- analysis = lizard.FileAnalyzer(_EXTENSIONS).analyze_source_code(rel_path, code)
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 cmd_trend(args: argparse.Namespace) -> int:
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
- rows_out = [_trend_row(run, agg.get(run["id"], (0, 0, 0.0)), by_scope.get(run["id"], {}))
128
- for run in trusted_runs(store)]
129
- _print_trend(args.json, rows_out, cfg.target)
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
- _SOURCE_SUFFIXES = (".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs", ".py")
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 both language
221
- families, plus the method shorthand a class body uses."""
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 same
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 <=): skip the occurrence entirely
36
- _PROTECT = ("===", "!==", "==", "!=", "<=", ">=", "=>", "->")
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 &middot; '
89
+ f'self-contained &middot; 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 &gt;= {_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
- # Off the file, not out of a string: every declared lane's artifact
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(state for state in
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:
@@ -19,6 +19,7 @@ LANGUAGE_EXTENSIONS = {
19
19
  "javascript": (".js", ".jsx", ".mjs", ".cjs"),
20
20
  "python": (".py",),
21
21
  "swift": (".swift",),
22
+ "go": (".go",),
22
23
  }
23
24
 
24
25
  # Test directories are excluded case-insensitively: Swift convention capitalizes Tests/.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.2.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 Python through [lizard](https://github.com/terryyin/lizard),
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. Every read-side command speaks JSON with a pinned schema, because
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.2.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. |
@@ -33,6 +33,7 @@ src/crapkit/override.py
33
33
  src/crapkit/packet.py
34
34
  src/crapkit/ratchet.py
35
35
  src/crapkit/ratchet_report.py
36
+ src/crapkit/report.py
36
37
  src/crapkit/sarif.py
37
38
  src/crapkit/sarifio.py
38
39
  src/crapkit/scaffold.py
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