claude-crap 0.1.2

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 (202) hide show
  1. package/CHANGELOG.md +308 -0
  2. package/LICENSE +21 -0
  3. package/README.md +550 -0
  4. package/bin/claude-crap.mjs +141 -0
  5. package/dist/adapters/bandit.d.ts +48 -0
  6. package/dist/adapters/bandit.d.ts.map +1 -0
  7. package/dist/adapters/bandit.js +145 -0
  8. package/dist/adapters/bandit.js.map +1 -0
  9. package/dist/adapters/common.d.ts +73 -0
  10. package/dist/adapters/common.d.ts.map +1 -0
  11. package/dist/adapters/common.js +78 -0
  12. package/dist/adapters/common.js.map +1 -0
  13. package/dist/adapters/eslint.d.ts +52 -0
  14. package/dist/adapters/eslint.d.ts.map +1 -0
  15. package/dist/adapters/eslint.js +142 -0
  16. package/dist/adapters/eslint.js.map +1 -0
  17. package/dist/adapters/index.d.ts +47 -0
  18. package/dist/adapters/index.d.ts.map +1 -0
  19. package/dist/adapters/index.js +64 -0
  20. package/dist/adapters/index.js.map +1 -0
  21. package/dist/adapters/semgrep.d.ts +30 -0
  22. package/dist/adapters/semgrep.d.ts.map +1 -0
  23. package/dist/adapters/semgrep.js +130 -0
  24. package/dist/adapters/semgrep.js.map +1 -0
  25. package/dist/adapters/stryker.d.ts +55 -0
  26. package/dist/adapters/stryker.d.ts.map +1 -0
  27. package/dist/adapters/stryker.js +165 -0
  28. package/dist/adapters/stryker.js.map +1 -0
  29. package/dist/ast/cyclomatic.d.ts +48 -0
  30. package/dist/ast/cyclomatic.d.ts.map +1 -0
  31. package/dist/ast/cyclomatic.js +106 -0
  32. package/dist/ast/cyclomatic.js.map +1 -0
  33. package/dist/ast/index.d.ts +26 -0
  34. package/dist/ast/index.d.ts.map +1 -0
  35. package/dist/ast/index.js +23 -0
  36. package/dist/ast/index.js.map +1 -0
  37. package/dist/ast/language-config.d.ts +70 -0
  38. package/dist/ast/language-config.d.ts.map +1 -0
  39. package/dist/ast/language-config.js +192 -0
  40. package/dist/ast/language-config.js.map +1 -0
  41. package/dist/ast/tree-sitter-engine.d.ts +133 -0
  42. package/dist/ast/tree-sitter-engine.d.ts.map +1 -0
  43. package/dist/ast/tree-sitter-engine.js +270 -0
  44. package/dist/ast/tree-sitter-engine.js.map +1 -0
  45. package/dist/config.d.ts +57 -0
  46. package/dist/config.d.ts.map +1 -0
  47. package/dist/config.js +78 -0
  48. package/dist/config.js.map +1 -0
  49. package/dist/crap-config.d.ts +97 -0
  50. package/dist/crap-config.d.ts.map +1 -0
  51. package/dist/crap-config.js +144 -0
  52. package/dist/crap-config.js.map +1 -0
  53. package/dist/dashboard/server.d.ts +65 -0
  54. package/dist/dashboard/server.d.ts.map +1 -0
  55. package/dist/dashboard/server.js +147 -0
  56. package/dist/dashboard/server.js.map +1 -0
  57. package/dist/index.d.ts +32 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +574 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/metrics/crap.d.ts +71 -0
  62. package/dist/metrics/crap.d.ts.map +1 -0
  63. package/dist/metrics/crap.js +67 -0
  64. package/dist/metrics/crap.js.map +1 -0
  65. package/dist/metrics/index.d.ts +31 -0
  66. package/dist/metrics/index.d.ts.map +1 -0
  67. package/dist/metrics/index.js +27 -0
  68. package/dist/metrics/index.js.map +1 -0
  69. package/dist/metrics/score.d.ts +143 -0
  70. package/dist/metrics/score.d.ts.map +1 -0
  71. package/dist/metrics/score.js +224 -0
  72. package/dist/metrics/score.js.map +1 -0
  73. package/dist/metrics/tdr.d.ts +106 -0
  74. package/dist/metrics/tdr.d.ts.map +1 -0
  75. package/dist/metrics/tdr.js +117 -0
  76. package/dist/metrics/tdr.js.map +1 -0
  77. package/dist/metrics/workspace-walker.d.ts +43 -0
  78. package/dist/metrics/workspace-walker.d.ts.map +1 -0
  79. package/dist/metrics/workspace-walker.js +137 -0
  80. package/dist/metrics/workspace-walker.js.map +1 -0
  81. package/dist/sarif/index.d.ts +21 -0
  82. package/dist/sarif/index.d.ts.map +1 -0
  83. package/dist/sarif/index.js +19 -0
  84. package/dist/sarif/index.js.map +1 -0
  85. package/dist/sarif/sarif-builder.d.ts +128 -0
  86. package/dist/sarif/sarif-builder.d.ts.map +1 -0
  87. package/dist/sarif/sarif-builder.js +79 -0
  88. package/dist/sarif/sarif-builder.js.map +1 -0
  89. package/dist/sarif/sarif-store.d.ts +205 -0
  90. package/dist/sarif/sarif-store.d.ts.map +1 -0
  91. package/dist/sarif/sarif-store.js +246 -0
  92. package/dist/sarif/sarif-store.js.map +1 -0
  93. package/dist/sarif/sarif-validator.d.ts +45 -0
  94. package/dist/sarif/sarif-validator.d.ts.map +1 -0
  95. package/dist/sarif/sarif-validator.js +138 -0
  96. package/dist/sarif/sarif-validator.js.map +1 -0
  97. package/dist/schemas/tool-schemas.d.ts +216 -0
  98. package/dist/schemas/tool-schemas.d.ts.map +1 -0
  99. package/dist/schemas/tool-schemas.js +208 -0
  100. package/dist/schemas/tool-schemas.js.map +1 -0
  101. package/dist/sdk.d.ts +45 -0
  102. package/dist/sdk.d.ts.map +1 -0
  103. package/dist/sdk.js +44 -0
  104. package/dist/sdk.js.map +1 -0
  105. package/dist/tools/index.d.ts +24 -0
  106. package/dist/tools/index.d.ts.map +1 -0
  107. package/dist/tools/index.js +23 -0
  108. package/dist/tools/index.js.map +1 -0
  109. package/dist/tools/test-harness.d.ts +75 -0
  110. package/dist/tools/test-harness.d.ts.map +1 -0
  111. package/dist/tools/test-harness.js +137 -0
  112. package/dist/tools/test-harness.js.map +1 -0
  113. package/dist/workspace-guard.d.ts +53 -0
  114. package/dist/workspace-guard.d.ts.map +1 -0
  115. package/dist/workspace-guard.js +61 -0
  116. package/dist/workspace-guard.js.map +1 -0
  117. package/package.json +133 -0
  118. package/plugin/.claude-plugin/plugin.json +29 -0
  119. package/plugin/.mcp.json +18 -0
  120. package/plugin/CLAUDE.md +143 -0
  121. package/plugin/bundle/dashboard/public/index.html +368 -0
  122. package/plugin/bundle/dashboard/public/vendor/vue.global.prod.js +9 -0
  123. package/plugin/bundle/mcp-server.mjs +8718 -0
  124. package/plugin/bundle/mcp-server.mjs.map +7 -0
  125. package/plugin/bundle/tdr-engine.mjs +50 -0
  126. package/plugin/bundle/tdr-engine.mjs.map +7 -0
  127. package/plugin/hooks/hooks.json +62 -0
  128. package/plugin/hooks/lib/crap-config.mjs +152 -0
  129. package/plugin/hooks/lib/gatekeeper-rules.mjs +257 -0
  130. package/plugin/hooks/lib/hook-io.mjs +151 -0
  131. package/plugin/hooks/lib/quality-gate.mjs +329 -0
  132. package/plugin/hooks/lib/test-harness.mjs +152 -0
  133. package/plugin/hooks/post-tool-use.mjs +245 -0
  134. package/plugin/hooks/pre-tool-use.mjs +290 -0
  135. package/plugin/hooks/session-start.mjs +109 -0
  136. package/plugin/hooks/stop-quality-gate.mjs +226 -0
  137. package/plugin/package.json +18 -0
  138. package/plugin/skills/adopt/SKILL.md +74 -0
  139. package/plugin/skills/analyze/SKILL.md +77 -0
  140. package/plugin/skills/check-test/SKILL.md +50 -0
  141. package/plugin/skills/score/SKILL.md +31 -0
  142. package/scripts/bug-report.mjs +328 -0
  143. package/scripts/build-fast.mjs +130 -0
  144. package/scripts/bundle-plugin.mjs +74 -0
  145. package/scripts/doctor.mjs +320 -0
  146. package/scripts/install.mjs +192 -0
  147. package/scripts/lib/cli-ui.mjs +122 -0
  148. package/scripts/postinstall.mjs +127 -0
  149. package/scripts/run-tests.mjs +95 -0
  150. package/scripts/status.mjs +110 -0
  151. package/scripts/uninstall.mjs +72 -0
  152. package/src/adapters/bandit.ts +191 -0
  153. package/src/adapters/common.ts +133 -0
  154. package/src/adapters/eslint.ts +187 -0
  155. package/src/adapters/index.ts +78 -0
  156. package/src/adapters/semgrep.ts +150 -0
  157. package/src/adapters/stryker.ts +218 -0
  158. package/src/ast/cyclomatic.ts +131 -0
  159. package/src/ast/index.ts +33 -0
  160. package/src/ast/language-config.ts +231 -0
  161. package/src/ast/tree-sitter-engine.ts +385 -0
  162. package/src/config.ts +109 -0
  163. package/src/crap-config.ts +196 -0
  164. package/src/dashboard/public/index.html +368 -0
  165. package/src/dashboard/public/vendor/vue.global.prod.js +9 -0
  166. package/src/dashboard/server.ts +205 -0
  167. package/src/index.ts +696 -0
  168. package/src/metrics/crap.ts +101 -0
  169. package/src/metrics/index.ts +51 -0
  170. package/src/metrics/score.ts +329 -0
  171. package/src/metrics/tdr.ts +155 -0
  172. package/src/metrics/workspace-walker.ts +146 -0
  173. package/src/sarif/index.ts +31 -0
  174. package/src/sarif/sarif-builder.ts +139 -0
  175. package/src/sarif/sarif-store.ts +347 -0
  176. package/src/sarif/sarif-validator.ts +145 -0
  177. package/src/schemas/tool-schemas.ts +225 -0
  178. package/src/sdk.ts +110 -0
  179. package/src/tests/adapters/bandit.test.ts +111 -0
  180. package/src/tests/adapters/dispatch.test.ts +100 -0
  181. package/src/tests/adapters/eslint.test.ts +138 -0
  182. package/src/tests/adapters/semgrep.test.ts +125 -0
  183. package/src/tests/adapters/stryker.test.ts +103 -0
  184. package/src/tests/crap-config.test.ts +228 -0
  185. package/src/tests/crap.test.ts +59 -0
  186. package/src/tests/cyclomatic.test.ts +87 -0
  187. package/src/tests/dashboard-http.test.ts +108 -0
  188. package/src/tests/dashboard-integrity.test.ts +128 -0
  189. package/src/tests/integration/mcp-server.integration.test.ts +352 -0
  190. package/src/tests/pre-tool-use-hook.test.ts +178 -0
  191. package/src/tests/sarif-store.test.ts +241 -0
  192. package/src/tests/sarif-validator.test.ts +164 -0
  193. package/src/tests/score.test.ts +260 -0
  194. package/src/tests/skills-frontmatter.test.ts +172 -0
  195. package/src/tests/stop-quality-gate-strictness.test.ts +243 -0
  196. package/src/tests/tdr.test.ts +86 -0
  197. package/src/tests/test-harness.test.ts +153 -0
  198. package/src/tests/workspace-guard.test.ts +111 -0
  199. package/src/tools/index.ts +24 -0
  200. package/src/tools/test-harness.ts +158 -0
  201. package/src/workspace-guard.ts +64 -0
  202. package/tsconfig.json +27 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * CRAP (Change Risk Anti-Patterns) index — deterministic computation.
3
+ *
4
+ * The CRAP index is a single number that summarizes how dangerous it is
5
+ * to change a given function. It combines two signals:
6
+ *
7
+ * 1. Cyclomatic complexity (`comp`) — how many independent paths the
8
+ * function has. Tracks how easy it is to reason about the code.
9
+ * 2. Test coverage percentage (`cov`) — empirical safety net provided
10
+ * by the automated test suite.
11
+ *
12
+ * The formula (see docs/quality-gate.md) is:
13
+ *
14
+ * CRAP(m) = comp(m)² × (1 − cov(m)/100)³ + comp(m)
15
+ *
16
+ * The cubic uncovered-weight term makes CRAP punish uncovered, branchy
17
+ * code extremely aggressively. A function with complexity 10 and 0%
18
+ * coverage scores CRAP = 10² × 1³ + 10 = 110, well above the 30 threshold.
19
+ *
20
+ * The additive `+ comp(m)` tail is intentional: it means that any function
21
+ * with `comp ≥ 30` can NEVER reach a passing CRAP score, even with 100%
22
+ * coverage (because the final term alone equals the threshold). This
23
+ * encodes the policy "functions above complexity 30 must be decomposed,
24
+ * period" — you cannot test your way out of structural complexity.
25
+ *
26
+ * @module metrics/crap
27
+ */
28
+
29
+ /**
30
+ * Inputs required to compute CRAP for a single function.
31
+ */
32
+ export interface CrapInput {
33
+ /** Cyclomatic complexity of the function. Must be an integer ≥ 1. */
34
+ readonly cyclomaticComplexity: number;
35
+ /** Test coverage percentage for the function. Must be in `[0, 100]`. */
36
+ readonly coveragePercent: number;
37
+ }
38
+
39
+ /**
40
+ * Result of a CRAP computation, including the inputs used so callers can
41
+ * echo the context back to the LLM or dump it to a SARIF result's
42
+ * `properties` bag without re-reading the source data.
43
+ */
44
+ export interface CrapResult {
45
+ /** The CRAP score, rounded to 4 decimals for stable serialization. */
46
+ readonly crap: number;
47
+ /** Cyclomatic complexity echoed from the input. */
48
+ readonly cyclomaticComplexity: number;
49
+ /** Coverage percentage echoed from the input. */
50
+ readonly coveragePercent: number;
51
+ /** `true` when `crap > threshold` — caller should block on this. */
52
+ readonly exceedsThreshold: boolean;
53
+ /** The threshold used for the `exceedsThreshold` decision. */
54
+ readonly threshold: number;
55
+ }
56
+
57
+ /**
58
+ * Compute the CRAP index for a single function, against a configurable
59
+ * block threshold. This function is pure, deterministic, and performs no
60
+ * I/O — it can be called from any context (MCP tool handler, hook, unit
61
+ * test) without side effects.
62
+ *
63
+ * @param input Cyclomatic complexity and coverage for the function.
64
+ * @param threshold The CRAP score above which the caller should block.
65
+ * @returns A {@link CrapResult} containing the score and decision.
66
+ * @throws When any input is out of range or not finite.
67
+ *
68
+ * @example
69
+ * // 12 branches, 60% coverage, threshold = 30
70
+ * computeCrap({ cyclomaticComplexity: 12, coveragePercent: 60 }, 30)
71
+ * // → { crap: 21.216, exceedsThreshold: false, ... }
72
+ */
73
+ export function computeCrap(input: CrapInput, threshold: number): CrapResult {
74
+ if (!Number.isFinite(input.cyclomaticComplexity) || input.cyclomaticComplexity < 1) {
75
+ throw new Error(
76
+ `[crap] cyclomaticComplexity must be ≥ 1, got ${input.cyclomaticComplexity}`,
77
+ );
78
+ }
79
+ if (!Number.isFinite(input.coveragePercent) || input.coveragePercent < 0 || input.coveragePercent > 100) {
80
+ throw new Error(
81
+ `[crap] coveragePercent must be in [0, 100], got ${input.coveragePercent}`,
82
+ );
83
+ }
84
+ if (!Number.isFinite(threshold) || threshold <= 0) {
85
+ throw new Error(`[crap] threshold must be > 0, got ${threshold}`);
86
+ }
87
+
88
+ const comp = input.cyclomaticComplexity;
89
+ const uncovered = 1 - input.coveragePercent / 100;
90
+ const crap = comp * comp * Math.pow(uncovered, 3) + comp;
91
+
92
+ return {
93
+ // Round to 4 decimals so JSON serialization is stable across runs
94
+ // (important for SARIF diffing and dashboard caching).
95
+ crap: Number(crap.toFixed(4)),
96
+ cyclomaticComplexity: comp,
97
+ coveragePercent: input.coveragePercent,
98
+ exceedsThreshold: crap > threshold,
99
+ threshold,
100
+ };
101
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Public SDK entry point for the deterministic metrics engines.
3
+ *
4
+ * Everything re-exported from this barrel is part of the stable public
5
+ * API of `claude-crap/metrics`. Downstream consumers can rely
6
+ * on the shapes here remaining semver-stable — breaking changes only
7
+ * land in major versions.
8
+ *
9
+ * Usage:
10
+ *
11
+ * ```ts
12
+ * import {
13
+ * computeCrap,
14
+ * computeTdr,
15
+ * computeProjectScore,
16
+ * classifyTdr,
17
+ * ratingIsWorseThan,
18
+ * } from "claude-crap/metrics";
19
+ * ```
20
+ *
21
+ * @module metrics
22
+ */
23
+
24
+ export { computeCrap } from "./crap.js";
25
+ export type { CrapInput, CrapResult } from "./crap.js";
26
+
27
+ export {
28
+ classifyTdr,
29
+ computeTdr,
30
+ ratingIsWorseThan,
31
+ ratingToRank,
32
+ } from "./tdr.js";
33
+ export type { TdrInput, TdrResult } from "./tdr.js";
34
+
35
+ export {
36
+ computeProjectScore,
37
+ renderProjectScoreMarkdown,
38
+ } from "./score.js";
39
+ export type {
40
+ ComputeProjectScoreInput,
41
+ DimensionScore,
42
+ FindingsSummary,
43
+ MaintainabilityScore,
44
+ ProjectScore,
45
+ ScoreLocation,
46
+ SeverityRating,
47
+ WorkspaceStats,
48
+ } from "./score.js";
49
+
50
+ export { estimateWorkspaceLoc, MAX_FILES_WALKED } from "./workspace-walker.js";
51
+ export type { WorkspaceWalkResult } from "./workspace-walker.js";
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Aggregate project score engine.
3
+ *
4
+ * Given a fully resolved configuration, the live SARIF store, and a
5
+ * workspace LOC walker, this module produces a single immutable
6
+ * `ProjectScore` snapshot describing the entire project's quality
7
+ * posture across three dimensions:
8
+ *
9
+ * - **Maintainability** — derived from the Technical Debt Ratio (TDR)
10
+ * using the existing `metrics/tdr.ts` engine.
11
+ * - **Reliability** — derived from the worst non-security finding.
12
+ * - **Security** — derived from the worst security finding.
13
+ *
14
+ * Each dimension produces a letter grade A..E and the `overall` field
15
+ * collapses them by taking the worst grade. The `passes` field tells
16
+ * the caller whether the overall grade is within the configured
17
+ * `tdrMaxRating` tolerance — handy for the Stop quality gate and the
18
+ * `score_project` MCP tool.
19
+ *
20
+ * Security vs reliability is determined by a heuristic on the `ruleId`:
21
+ * any rule whose identifier matches a security keyword (`sec`, `sql`,
22
+ * `xss`, `csrf`, `injection`, `crypt`, `auth`, `secret`, `password`,
23
+ * `cve`, `vuln`) is treated as security. Everything else is reliability.
24
+ * This is intentionally coarse: adapters that stamp a richer SARIF
25
+ * taxonomy (e.g. `properties.tags = ["security"]`) could replace this
26
+ * classifier with an exact match, but the regex is sufficient for the
27
+ * scanners this plugin ships with.
28
+ *
29
+ * The score engine is pure: it does no I/O, takes a `WorkspaceStats`
30
+ * value (which the caller produces from the bounded LOC walker), and
31
+ * returns a brand new score object on every call. Tests can construct
32
+ * a `SarifStore` in memory and verify the boundaries directly.
33
+ *
34
+ * @module metrics/score
35
+ */
36
+
37
+ import type { MaintainabilityRating } from "../config.js";
38
+ import type { SarifStore } from "../sarif/sarif-store.js";
39
+ import { classifyTdr, ratingIsWorseThan } from "./tdr.js";
40
+
41
+ /**
42
+ * Letter rating shared by every dimension. The same A..E scale used by
43
+ * SonarQube's reliability and security ratings, where A is best and E
44
+ * is unmaintainable / blocker-class.
45
+ */
46
+ export type SeverityRating = MaintainabilityRating;
47
+
48
+ /**
49
+ * Workspace size statistics produced by an external bounded walker.
50
+ * The score engine does not walk the disk itself — pass these in.
51
+ */
52
+ export interface WorkspaceStats {
53
+ /** Total physical lines of code under the workspace root. */
54
+ readonly physicalLoc: number;
55
+ /** Number of files visited by the walker. */
56
+ readonly fileCount: number;
57
+ }
58
+
59
+ /**
60
+ * Per-dimension breakdown.
61
+ */
62
+ export interface DimensionScore {
63
+ readonly rating: SeverityRating;
64
+ readonly findings: number;
65
+ readonly errorFindings: number;
66
+ readonly warningFindings: number;
67
+ readonly noteFindings: number;
68
+ }
69
+
70
+ /**
71
+ * Per-finding-level summary plus per-tool counts.
72
+ */
73
+ export interface FindingsSummary {
74
+ readonly total: number;
75
+ readonly error: number;
76
+ readonly warning: number;
77
+ readonly note: number;
78
+ readonly byTool: Readonly<Record<string, number>>;
79
+ readonly byFile: Readonly<Record<string, number>>;
80
+ }
81
+
82
+ /**
83
+ * Maintainability dimension expressed as a TDR percentage.
84
+ */
85
+ export interface MaintainabilityScore {
86
+ readonly rating: MaintainabilityRating;
87
+ readonly tdrPercent: number;
88
+ readonly remediationMinutes: number;
89
+ readonly developmentCostMinutes: number;
90
+ }
91
+
92
+ /**
93
+ * Pointer to where the consolidated report can be found.
94
+ */
95
+ export interface ScoreLocation {
96
+ /** Local dashboard URL when the HTTP server is running, otherwise `null`. */
97
+ readonly dashboardUrl: string | null;
98
+ /** Absolute path to the consolidated SARIF document on disk. */
99
+ readonly sarifReportPath: string;
100
+ }
101
+
102
+ /**
103
+ * The full project score snapshot. Returned from {@link computeProjectScore}.
104
+ */
105
+ export interface ProjectScore {
106
+ readonly generatedAt: string;
107
+ readonly workspaceRoot: string;
108
+ readonly loc: { readonly physical: number; readonly files: number };
109
+ readonly findings: FindingsSummary;
110
+ readonly maintainability: MaintainabilityScore;
111
+ readonly reliability: DimensionScore;
112
+ readonly security: DimensionScore;
113
+ readonly overall: {
114
+ readonly rating: SeverityRating;
115
+ /** True when `overall.rating` is no worse than `policyRating`. */
116
+ readonly passes: boolean;
117
+ /** Echoed from the configured policy. */
118
+ readonly policyRating: MaintainabilityRating;
119
+ };
120
+ readonly location: ScoreLocation;
121
+ }
122
+
123
+ /**
124
+ * Inputs accepted by {@link computeProjectScore}.
125
+ */
126
+ export interface ComputeProjectScoreInput {
127
+ readonly workspaceRoot: string;
128
+ readonly minutesPerLoc: number;
129
+ readonly tdrMaxRating: MaintainabilityRating;
130
+ readonly workspace: WorkspaceStats;
131
+ readonly sarifStore: SarifStore;
132
+ readonly dashboardUrl: string | null;
133
+ readonly sarifReportPath: string;
134
+ }
135
+
136
+ /**
137
+ * Pattern that classifies a rule identifier as security-relevant.
138
+ * Matches case-insensitively against the rule id text. Intentionally
139
+ * permissive — false positives in classification are recoverable, but
140
+ * a missed security finding being graded as reliability is not.
141
+ */
142
+ const SECURITY_RULE_PATTERN =
143
+ /(sec|sql|xss|csrf|ssrf|injection|crypt|auth|secret|password|token|cve|vuln|jwt|cors|rce|deserial|prototype-pollution)/i;
144
+
145
+ /**
146
+ * Compute the full project score. Pure function — no side effects.
147
+ *
148
+ * @param input Aggregated inputs.
149
+ * @returns A {@link ProjectScore} ready to be serialized.
150
+ */
151
+ export function computeProjectScore(input: ComputeProjectScoreInput): ProjectScore {
152
+ const findingsList = input.sarifStore.list();
153
+
154
+ // ---- Findings summary ----
155
+ /** @type {Record<string, number>} */
156
+ const byTool: Record<string, number> = {};
157
+ const byFile: Record<string, number> = {};
158
+ let errorCount = 0;
159
+ let warningCount = 0;
160
+ let noteCount = 0;
161
+ let remediationMinutes = 0;
162
+
163
+ /** Findings split by classification. */
164
+ const securityFindings: Array<{ level: string }> = [];
165
+ const reliabilityFindings: Array<{ level: string }> = [];
166
+
167
+ for (const finding of findingsList) {
168
+ if (finding.level === "error") errorCount += 1;
169
+ else if (finding.level === "warning") warningCount += 1;
170
+ else if (finding.level === "note") noteCount += 1;
171
+
172
+ byTool[finding.sourceTool] = (byTool[finding.sourceTool] ?? 0) + 1;
173
+ byFile[finding.location.uri] = (byFile[finding.location.uri] ?? 0) + 1;
174
+
175
+ const effort =
176
+ typeof finding.properties?.effortMinutes === "number"
177
+ ? finding.properties.effortMinutes
178
+ : 0;
179
+ remediationMinutes += effort;
180
+
181
+ if (SECURITY_RULE_PATTERN.test(finding.ruleId)) {
182
+ securityFindings.push({ level: finding.level });
183
+ } else {
184
+ reliabilityFindings.push({ level: finding.level });
185
+ }
186
+ }
187
+
188
+ const findings: FindingsSummary = {
189
+ total: findingsList.length,
190
+ error: errorCount,
191
+ warning: warningCount,
192
+ note: noteCount,
193
+ byTool,
194
+ byFile,
195
+ };
196
+
197
+ // ---- Maintainability (TDR) ----
198
+ // Guard against an empty workspace; the TDR formula divides by LOC.
199
+ const safeLoc = Math.max(input.workspace.physicalLoc, 1);
200
+ const developmentCostMinutes = input.minutesPerLoc * safeLoc;
201
+ const tdrPercent = (remediationMinutes / developmentCostMinutes) * 100;
202
+ const tdrRating = classifyTdr(tdrPercent);
203
+
204
+ const maintainability: MaintainabilityScore = {
205
+ rating: tdrRating,
206
+ tdrPercent: Number(tdrPercent.toFixed(4)),
207
+ remediationMinutes,
208
+ developmentCostMinutes,
209
+ };
210
+
211
+ // ---- Reliability and security dimensions ----
212
+ const reliability = scoreDimension(reliabilityFindings);
213
+ const security = scoreDimension(securityFindings);
214
+
215
+ // ---- Overall = the worst of the three ----
216
+ const overallRating = worstOf(maintainability.rating, reliability.rating, security.rating);
217
+ const passes = !ratingIsWorseThan(overallRating, input.tdrMaxRating);
218
+
219
+ return {
220
+ generatedAt: new Date().toISOString(),
221
+ workspaceRoot: input.workspaceRoot,
222
+ loc: { physical: input.workspace.physicalLoc, files: input.workspace.fileCount },
223
+ findings,
224
+ maintainability,
225
+ reliability,
226
+ security,
227
+ overall: {
228
+ rating: overallRating,
229
+ passes,
230
+ policyRating: input.tdrMaxRating,
231
+ },
232
+ location: {
233
+ dashboardUrl: input.dashboardUrl,
234
+ sarifReportPath: input.sarifReportPath,
235
+ },
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Score a single dimension (reliability or security) from its findings.
241
+ *
242
+ * The mapping is intentionally coarse and maps directly from SARIF
243
+ * levels to letter ratings:
244
+ *
245
+ * - 0 findings → A
246
+ * - only `note` findings → B
247
+ * - 1+ `warning`, 0 `error` → C
248
+ * - 1–2 `error` → D
249
+ * - 3+ `error` → E
250
+ *
251
+ * Projects that stamp explicit blocker / major / minor categories on
252
+ * their SARIF properties can wrap this function with their own
253
+ * taxonomy-aware classifier.
254
+ *
255
+ * @param findings Findings classified into this dimension.
256
+ */
257
+ function scoreDimension(findings: ReadonlyArray<{ level: string }>): DimensionScore {
258
+ let errorCount = 0;
259
+ let warningCount = 0;
260
+ let noteCount = 0;
261
+ for (const f of findings) {
262
+ if (f.level === "error") errorCount += 1;
263
+ else if (f.level === "warning") warningCount += 1;
264
+ else if (f.level === "note") noteCount += 1;
265
+ }
266
+ let rating: SeverityRating;
267
+ if (errorCount >= 3) rating = "E";
268
+ else if (errorCount >= 1) rating = "D";
269
+ else if (warningCount >= 1) rating = "C";
270
+ else if (noteCount >= 1) rating = "B";
271
+ else rating = "A";
272
+
273
+ return {
274
+ rating,
275
+ findings: findings.length,
276
+ errorFindings: errorCount,
277
+ warningFindings: warningCount,
278
+ noteFindings: noteCount,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Return the worst (alphabetically highest) of an arbitrary number of
284
+ * letter ratings. Used to collapse the three dimension ratings into the
285
+ * overall project rating.
286
+ *
287
+ * @param ratings Two or more letter ratings.
288
+ * @returns The worst rating.
289
+ */
290
+ function worstOf(...ratings: ReadonlyArray<SeverityRating>): SeverityRating {
291
+ let worst: SeverityRating = "A";
292
+ for (const r of ratings) {
293
+ if (ratingIsWorseThan(r, worst)) worst = r;
294
+ }
295
+ return worst;
296
+ }
297
+
298
+ /**
299
+ * Render a project score as a compact Markdown summary suitable for
300
+ * display directly in a chat session. Keep it under ~30 lines so it
301
+ * does not dominate the conversation context.
302
+ *
303
+ * @param score The score to render.
304
+ */
305
+ export function renderProjectScoreMarkdown(score: ProjectScore): string {
306
+ const verdict = score.overall.passes ? "✅ passes policy" : "❌ FAILS policy";
307
+ const dashboardLine = score.location.dashboardUrl
308
+ ? `📊 Dashboard: ${score.location.dashboardUrl}`
309
+ : `📊 Dashboard: <not running — start the MCP server to enable>`;
310
+
311
+ return [
312
+ `## claude-crap :: project score`,
313
+ ``,
314
+ `**Overall: ${score.overall.rating}** (${verdict}, policy ceiling = ${score.overall.policyRating})`,
315
+ ``,
316
+ `| Dimension | Rating | Detail |`,
317
+ `| --------------- | :----: | --------------------------------------------------- |`,
318
+ `| Maintainability | ${score.maintainability.rating} | TDR ${score.maintainability.tdrPercent}% (${score.maintainability.remediationMinutes} min over ${score.loc.physical} LOC) |`,
319
+ `| Reliability | ${score.reliability.rating} | ${score.reliability.errorFindings} error · ${score.reliability.warningFindings} warning · ${score.reliability.noteFindings} note |`,
320
+ `| Security | ${score.security.rating} | ${score.security.errorFindings} error · ${score.security.warningFindings} warning · ${score.security.noteFindings} note |`,
321
+ ``,
322
+ `Workspace: **${score.loc.physical} LOC** across **${score.loc.files} files**`,
323
+ `Findings: **${score.findings.total} total** (${score.findings.error} error · ${score.findings.warning} warning · ${score.findings.note} note)`,
324
+ `Tools: ${Object.keys(score.findings.byTool).join(", ") || "<none ingested>"}`,
325
+ ``,
326
+ dashboardLine,
327
+ `📄 Report: ${score.location.sarifReportPath}`,
328
+ ].join("\n");
329
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Technical Debt Ratio (TDR) — deterministic computation and rating.
3
+ *
4
+ * The Technical Debt Ratio expresses how expensive it would be to remediate
5
+ * all known issues in a scope, relative to how much it would have cost to
6
+ * write the code in the first place. Formally (see docs/quality-gate.md):
7
+ *
8
+ * TDR = remediationCost / (costPerLine × totalLinesOfCode)
9
+ *
10
+ * Where the remediation cost is the sum (in minutes) of every linter /
11
+ * scanner / mutator finding's individual estimated effort, and the per-line
12
+ * cost is assumed to be a constant `minutesPerLoc` (industry default: 30
13
+ * minutes per line of code, including design, writing and review).
14
+ *
15
+ * The resulting ratio is converted to a percentage and mapped to a letter
16
+ * grade A..E. The thresholds are strict and non-negotiable:
17
+ *
18
+ * | Rating | TDR % | Meaning |
19
+ * |--------|--------------|-------------------------------------------|
20
+ * | A | 0..5% | Excellent — remediation cost is noise |
21
+ * | B | >5..10% | Low risk |
22
+ * | C | >10..20% | Moderate, watch closely |
23
+ * | D | >20..50% | Critical, remediation plan required |
24
+ * | E | >50% | Unmaintainable — halt feature work |
25
+ *
26
+ * Rating E always halts the workflow at the Stop quality gate, regardless
27
+ * of the configured `TDR_MAX_RATING` tolerance.
28
+ *
29
+ * @module metrics/tdr
30
+ */
31
+
32
+ import type { MaintainabilityRating } from "../config.js";
33
+
34
+ /**
35
+ * Inputs required to compute a Technical Debt Ratio over any scope
36
+ * (project, module, or file).
37
+ */
38
+ export interface TdrInput {
39
+ /** Sum of all finding remediation estimates, in minutes. Must be ≥ 0. */
40
+ readonly remediationMinutes: number;
41
+ /** Total lines of code in the scope. Must be > 0 (division denominator). */
42
+ readonly totalLinesOfCode: number;
43
+ /** Assumed development cost per LOC, in minutes. Must be > 0. */
44
+ readonly minutesPerLoc: number;
45
+ }
46
+
47
+ /**
48
+ * Result of a TDR computation with both the raw ratio and the letter grade.
49
+ */
50
+ export interface TdrResult {
51
+ /** Raw ratio (remediation / development), rounded to 6 decimals. */
52
+ readonly ratio: number;
53
+ /** Same ratio expressed as a percentage, rounded to 4 decimals. */
54
+ readonly percent: number;
55
+ /** Letter grade derived from `percent` via {@link classifyTdr}. */
56
+ readonly rating: MaintainabilityRating;
57
+ /** Remediation input, echoed for traceability. */
58
+ readonly remediationMinutes: number;
59
+ /** LOC input, echoed for traceability. */
60
+ readonly totalLinesOfCode: number;
61
+ /** Computed `minutesPerLoc × totalLinesOfCode`, useful for the dashboard. */
62
+ readonly developmentCostMinutes: number;
63
+ }
64
+
65
+ /** Canonical ordering used by {@link ratingToRank}. */
66
+ const RATING_ORDER: ReadonlyArray<MaintainabilityRating> = ["A", "B", "C", "D", "E"];
67
+
68
+ /**
69
+ * Convert a letter rating to its numeric rank (A=0, E=4). Useful when
70
+ * comparing two ratings without relying on lexical order.
71
+ *
72
+ * @param rating The rating letter.
73
+ * @returns Its rank in `[0, 4]`.
74
+ */
75
+ export function ratingToRank(rating: MaintainabilityRating): number {
76
+ return RATING_ORDER.indexOf(rating);
77
+ }
78
+
79
+ /**
80
+ * Return `true` when `actual` is strictly worse than `limit`, false otherwise.
81
+ * Used by the Stop quality gate to decide whether to block task completion.
82
+ *
83
+ * @param actual Rating currently achieved by the project.
84
+ * @param limit Maximum tolerated rating (worst allowed).
85
+ * @returns `true` if `actual` should trigger a block.
86
+ *
87
+ * @example
88
+ * ratingIsWorseThan("D", "C") // → true
89
+ * ratingIsWorseThan("B", "C") // → false
90
+ * ratingIsWorseThan("C", "C") // → false (equal, not worse)
91
+ */
92
+ export function ratingIsWorseThan(
93
+ actual: MaintainabilityRating,
94
+ limit: MaintainabilityRating,
95
+ ): boolean {
96
+ return ratingToRank(actual) > ratingToRank(limit);
97
+ }
98
+
99
+ /**
100
+ * Map a TDR percentage to its letter rating. The boundaries are inclusive
101
+ * on the upper end (5% is still an A, 10% is still a B, etc.).
102
+ *
103
+ * @param percent TDR expressed as a percentage. Must be ≥ 0.
104
+ * @returns Letter rating A..E.
105
+ * @throws When `percent` is negative or not finite.
106
+ */
107
+ export function classifyTdr(percent: number): MaintainabilityRating {
108
+ if (!Number.isFinite(percent) || percent < 0) {
109
+ throw new Error(`[tdr] percent is invalid: ${percent}`);
110
+ }
111
+ if (percent <= 5) return "A";
112
+ if (percent <= 10) return "B";
113
+ if (percent <= 20) return "C";
114
+ if (percent <= 50) return "D";
115
+ return "E";
116
+ }
117
+
118
+ /**
119
+ * Compute the Technical Debt Ratio for a scope and return the full result.
120
+ * This function is pure and deterministic.
121
+ *
122
+ * @param input Remediation minutes, total LOC and the cost-per-line assumption.
123
+ * @returns A {@link TdrResult} ready to be serialized to SARIF properties.
124
+ * @throws When any numeric input is out of range.
125
+ *
126
+ * @example
127
+ * // 240 minutes of remediation across 500 LOC at 30 min/LOC
128
+ * computeTdr({ remediationMinutes: 240, totalLinesOfCode: 500, minutesPerLoc: 30 })
129
+ * // → { ratio: 0.016, percent: 1.6, rating: "A", ... }
130
+ */
131
+ export function computeTdr(input: TdrInput): TdrResult {
132
+ if (input.totalLinesOfCode <= 0) {
133
+ throw new Error(`[tdr] totalLinesOfCode must be > 0, got ${input.totalLinesOfCode}`);
134
+ }
135
+ if (input.minutesPerLoc <= 0) {
136
+ throw new Error(`[tdr] minutesPerLoc must be > 0, got ${input.minutesPerLoc}`);
137
+ }
138
+ if (input.remediationMinutes < 0) {
139
+ throw new Error(`[tdr] remediationMinutes must be ≥ 0, got ${input.remediationMinutes}`);
140
+ }
141
+
142
+ const developmentCostMinutes = input.minutesPerLoc * input.totalLinesOfCode;
143
+ const ratio = input.remediationMinutes / developmentCostMinutes;
144
+ const percent = ratio * 100;
145
+ const rating = classifyTdr(percent);
146
+
147
+ return {
148
+ ratio: Number(ratio.toFixed(6)),
149
+ percent: Number(percent.toFixed(4)),
150
+ rating,
151
+ remediationMinutes: input.remediationMinutes,
152
+ totalLinesOfCode: input.totalLinesOfCode,
153
+ developmentCostMinutes,
154
+ };
155
+ }