@sebassdc/crap4ts 0.1.0

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.
package/dist/report.js ADDED
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectRunner = detectRunner;
4
+ exports.runCoverage = runCoverage;
5
+ exports.evaluateThresholds = evaluateThresholds;
6
+ exports.runReport = runReport;
7
+ const fs_1 = require("fs");
8
+ const path_1 = require("path");
9
+ const child_process_1 = require("child_process");
10
+ const core_1 = require("./core");
11
+ const coverage_1 = require("./coverage");
12
+ const crap_1 = require("./crap");
13
+ const VITEST_CONFIGS = ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mts', 'vitest.config.mjs'];
14
+ const JEST_CONFIGS = ['jest.config.ts', 'jest.config.js', 'jest.config.mjs'];
15
+ function hasJestInPackageJson() {
16
+ if (!(0, fs_1.existsSync)('package.json'))
17
+ return false;
18
+ try {
19
+ const pkg = JSON.parse((0, fs_1.readFileSync)('package.json', 'utf-8'));
20
+ return !!(pkg.devDependencies?.jest || pkg.dependencies?.jest);
21
+ }
22
+ catch {
23
+ console.warn("Unable to parse package.json while detecting the test runner. Fix package.json or use --runner to specify explicitly.");
24
+ return false;
25
+ }
26
+ }
27
+ function detectRunner() {
28
+ if (VITEST_CONFIGS.some(f => (0, fs_1.existsSync)(f)))
29
+ return 'vitest';
30
+ if (JEST_CONFIGS.some(f => (0, fs_1.existsSync)(f)))
31
+ return 'jest';
32
+ if (hasJestInPackageJson())
33
+ return 'jest';
34
+ return 'vitest';
35
+ }
36
+ function runCoverage(runner, timeoutMs) {
37
+ const cmd = runner === 'vitest'
38
+ ? ['npx', 'vitest', 'run', '--coverage']
39
+ : ['npx', 'jest', '--coverage', '--coverageReporters=json'];
40
+ const result = (0, child_process_1.spawnSync)(cmd[0], cmd.slice(1), { stdio: 'inherit', timeout: timeoutMs });
41
+ const timedOut = result.signal === 'SIGTERM' && result.status === null;
42
+ return { ok: result.status === 0, timedOut };
43
+ }
44
+ function evaluateThresholds(entries, opts) {
45
+ if (opts.failOnCrap != null) {
46
+ const violations = entries.filter(e => e.crap >= opts.failOnCrap);
47
+ if (violations.length > 0) {
48
+ return `CI failed: ${violations.length} function(s) exceed CRAP threshold of ${opts.failOnCrap}`;
49
+ }
50
+ }
51
+ if (opts.failOnComplexity != null) {
52
+ const violations = entries.filter(e => e.complexity >= opts.failOnComplexity);
53
+ if (violations.length > 0) {
54
+ return `CI failed: ${violations.length} function(s) exceed complexity threshold of ${opts.failOnComplexity}`;
55
+ }
56
+ }
57
+ if (opts.failOnCoverageBelow != null) {
58
+ const violations = entries.filter(e => e.coverage < opts.failOnCoverageBelow);
59
+ if (violations.length > 0) {
60
+ return `CI failed: ${violations.length} function(s) below coverage threshold of ${opts.failOnCoverageBelow}%`;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+ function findFiles(opts) {
66
+ if (!(0, fs_1.existsSync)(opts.srcDir)) {
67
+ console.error(`Source directory '${opts.srcDir}' not found. Use --src to specify a different directory.`);
68
+ return null;
69
+ }
70
+ const rawFiles = (0, core_1.findSourceFiles)(opts.srcDir);
71
+ const excludes = opts.excludes ?? [];
72
+ const allFiles = excludes.length > 0
73
+ ? rawFiles.filter(f => !excludes.some(ex => f.includes(ex)))
74
+ : rawFiles;
75
+ if (allFiles.length === 0) {
76
+ console.error(`No TypeScript files found in '${opts.srcDir}'. Verify your source directory contains .ts files.`);
77
+ return null;
78
+ }
79
+ const filtered = (0, core_1.filterSources)(allFiles, opts.filters);
80
+ if (filtered.length === 0) {
81
+ console.error(`No files match the filters: [${opts.filters.join(', ')}]. Check your filter arguments.`);
82
+ return null;
83
+ }
84
+ return filtered;
85
+ }
86
+ function cleanStaleCoverage(coverageDir) {
87
+ if ((0, fs_1.existsSync)(coverageDir)) {
88
+ try {
89
+ (0, fs_1.rmSync)(coverageDir, { recursive: true, force: true });
90
+ }
91
+ catch (e) {
92
+ console.warn(`Warning: failed to remove stale coverage dir ${coverageDir}: ${e.message}`);
93
+ }
94
+ }
95
+ }
96
+ function executeCoverage(opts) {
97
+ const { coverageDir, timeoutMs } = opts;
98
+ cleanStaleCoverage(coverageDir);
99
+ let ok;
100
+ let timedOut;
101
+ if (opts.coverageCommand) {
102
+ const result = (0, child_process_1.spawnSync)(opts.coverageCommand, [], { shell: true, stdio: 'inherit', timeout: timeoutMs });
103
+ timedOut = result.signal === 'SIGTERM' && result.status === null;
104
+ ok = result.status === 0;
105
+ }
106
+ else {
107
+ const runner = opts.runner ?? detectRunner();
108
+ ({ ok, timedOut } = runCoverage(runner, timeoutMs));
109
+ }
110
+ if (timedOut) {
111
+ console.error(`Coverage run timed out after ${timeoutMs / 1000}s.`);
112
+ return { ok: false };
113
+ }
114
+ if (!ok) {
115
+ console.error('Coverage run failed.');
116
+ return { ok: false };
117
+ }
118
+ if (!(0, fs_1.existsSync)(`${coverageDir}/coverage-final.json`)) {
119
+ console.error(`No coverage-final.json found in ${coverageDir}/.\n` +
120
+ `Configure your test runner to output Istanbul JSON coverage.\n` +
121
+ ` Vitest: add coverage.reporter: ['json'] in vitest.config.ts\n` +
122
+ ` Jest: add "json" to coverageReporters in jest.config`);
123
+ return { ok: false };
124
+ }
125
+ return { ok: true };
126
+ }
127
+ function formatOutput(entries, opts) {
128
+ if (opts.output === 'json') {
129
+ console.log((0, crap_1.formatJsonReport)(entries));
130
+ }
131
+ else if (opts.output === 'markdown') {
132
+ console.log((0, crap_1.formatMarkdownReport)(entries));
133
+ }
134
+ else if (opts.output === 'csv') {
135
+ console.log((0, crap_1.formatCsvReport)(entries));
136
+ }
137
+ else {
138
+ console.log((0, crap_1.formatReport)(entries));
139
+ }
140
+ }
141
+ async function runReport(opts) {
142
+ const files = findFiles(opts);
143
+ if (!files)
144
+ return 1;
145
+ const covResult = executeCoverage(opts);
146
+ if (!covResult.ok)
147
+ return 1;
148
+ const filesData = (0, coverage_1.parseCoverage)(opts.coverageDir);
149
+ const resolvedSrc = (0, path_1.resolve)(opts.srcDir);
150
+ const allEntries = files.flatMap(f => (0, core_1.analyzeFile)(f, filesData, resolvedSrc));
151
+ if (allEntries.length === 0) {
152
+ console.warn('No functions found. crap4ts analyzes top-level functions, arrow functions, and class methods.');
153
+ }
154
+ const sorted = (0, crap_1.sortByCrap)(allEntries);
155
+ const displayed = opts.top != null ? sorted.slice(0, opts.top) : sorted;
156
+ formatOutput(displayed, opts);
157
+ const failureMessage = evaluateThresholds(sorted, opts);
158
+ if (failureMessage) {
159
+ console.error(failureMessage);
160
+ return 1;
161
+ }
162
+ return 0;
163
+ }
@@ -0,0 +1,13 @@
1
+ export type Scope = 'global' | 'project';
2
+ export declare function bundledSkillPath(): string;
3
+ export declare function globalSkillDir(): string;
4
+ export declare function projectSkillDir(cwd?: string): string;
5
+ export declare function installSkill(opts: {
6
+ scope: Scope;
7
+ }): Promise<string>;
8
+ export declare function uninstallSkill(opts: {
9
+ scope: Scope;
10
+ }): Promise<boolean>;
11
+ export declare function showSkill(): string;
12
+ export declare function skillPath(scope: Scope): string;
13
+ export declare function runSkillCommand(argv: string[]): Promise<number>;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.bundledSkillPath = bundledSkillPath;
4
+ exports.globalSkillDir = globalSkillDir;
5
+ exports.projectSkillDir = projectSkillDir;
6
+ exports.installSkill = installSkill;
7
+ exports.uninstallSkill = uninstallSkill;
8
+ exports.showSkill = showSkill;
9
+ exports.skillPath = skillPath;
10
+ exports.runSkillCommand = runSkillCommand;
11
+ const fs_1 = require("fs");
12
+ const os_1 = require("os");
13
+ const path_1 = require("path");
14
+ function bundledSkillPath() {
15
+ const candidates = [
16
+ (0, path_1.join)(__dirname, 'skill', 'SKILL.md'),
17
+ (0, path_1.join)(__dirname, '..', 'src', 'skill', 'SKILL.md'),
18
+ ];
19
+ for (const c of candidates) {
20
+ if ((0, fs_1.existsSync)(c))
21
+ return (0, path_1.resolve)(c);
22
+ }
23
+ throw new Error(`crap4ts: bundled SKILL.md not found. Looked in:\n ${candidates.join('\n ')}`);
24
+ }
25
+ function globalSkillDir() {
26
+ return (0, path_1.join)((0, os_1.homedir)(), '.agents', 'skills', 'crap4ts');
27
+ }
28
+ function projectSkillDir(cwd = process.cwd()) {
29
+ return (0, path_1.join)(cwd, '.agents', 'skills', 'crap4ts');
30
+ }
31
+ function destDir(scope) {
32
+ return scope === 'global' ? globalSkillDir() : projectSkillDir();
33
+ }
34
+ async function installSkill(opts) {
35
+ const src = bundledSkillPath();
36
+ const dir = destDir(opts.scope);
37
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
38
+ const dest = (0, path_1.join)(dir, 'SKILL.md');
39
+ (0, fs_1.copyFileSync)(src, dest);
40
+ return dest;
41
+ }
42
+ async function uninstallSkill(opts) {
43
+ const dir = destDir(opts.scope);
44
+ const file = (0, path_1.join)(dir, 'SKILL.md');
45
+ if (!(0, fs_1.existsSync)(file))
46
+ return false;
47
+ (0, fs_1.rmSync)(file, { force: true });
48
+ try {
49
+ (0, fs_1.rmSync)(dir, { recursive: false });
50
+ }
51
+ catch { /* non-empty or already gone */ }
52
+ return true;
53
+ }
54
+ function showSkill() {
55
+ return (0, fs_1.readFileSync)(bundledSkillPath(), 'utf-8');
56
+ }
57
+ function skillPath(scope) {
58
+ return (0, path_1.join)(destDir(scope), 'SKILL.md');
59
+ }
60
+ function scopeFromArgs(argv) {
61
+ return argv.includes('--project') ? 'project' : 'global';
62
+ }
63
+ async function runSkillCommand(argv) {
64
+ const sub = argv[0];
65
+ const rest = argv.slice(1);
66
+ const scope = scopeFromArgs(rest);
67
+ switch (sub) {
68
+ case 'install': {
69
+ const dest = await installSkill({ scope });
70
+ console.log(`Installed crap4ts skill to ${dest}`);
71
+ return 0;
72
+ }
73
+ case 'uninstall': {
74
+ const removed = await uninstallSkill({ scope });
75
+ console.log(removed
76
+ ? `Removed crap4ts skill from ${skillPath(scope)}`
77
+ : `No crap4ts skill installed at ${skillPath(scope)}`);
78
+ return 0;
79
+ }
80
+ case 'show': {
81
+ console.log(showSkill());
82
+ return 0;
83
+ }
84
+ case 'path': {
85
+ console.log(skillPath(scope));
86
+ return 0;
87
+ }
88
+ default:
89
+ console.error(`Unknown skill subcommand: ${sub ?? '(none)'}\n` +
90
+ `Usage: crap4ts skill <install|uninstall|show|path> [--project]`);
91
+ return 1;
92
+ }
93
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@sebassdc/crap4ts",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "CRAP metric CLI for TypeScript: combines cyclomatic complexity and test coverage to find risky code, with a bundled cross-agent AI skill.",
8
+ "main": "dist/api.js",
9
+ "types": "dist/api.d.ts",
10
+ "exports": {
11
+ ".": "./dist/api.js",
12
+ "./cli": "./dist/index.js"
13
+ },
14
+ "bin": {
15
+ "crap4ts": "dist/index.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src/skill/SKILL.md",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "license": "MIT",
27
+ "keywords": [
28
+ "typescript",
29
+ "cli",
30
+ "testing",
31
+ "coverage",
32
+ "cyclomatic-complexity",
33
+ "code-quality",
34
+ "static-analysis",
35
+ "metrics",
36
+ "crap",
37
+ "ai-agent",
38
+ "skill"
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/sebassdc/crap4ts.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/sebassdc/crap4ts/issues"
46
+ },
47
+ "homepage": "https://github.com/sebassdc/crap4ts#readme",
48
+ "scripts": {
49
+ "build": "tsc",
50
+ "test": "vitest run",
51
+ "coverage": "vitest run --coverage",
52
+ "prepublishOnly": "npm run build"
53
+ },
54
+ "dependencies": {
55
+ "typescript": "^5.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^20.0.0",
59
+ "@vitest/coverage-v8": "^2.0.0",
60
+ "vitest": "^2.0.0"
61
+ }
62
+ }
@@ -0,0 +1,179 @@
1
+ ---
2
+ name: crap4ts
3
+ description: Use when the user asks for a CRAP report, cyclomatic complexity analysis, or code quality metrics on a TypeScript project
4
+ ---
5
+
6
+ # crap4ts — CRAP Metric for TypeScript
7
+
8
+ Computes the **CRAP** (Change Risk Anti-Pattern) score for every function and method in a TypeScript project. CRAP combines cyclomatic complexity with test coverage to identify functions that are both complex and under-tested.
9
+
10
+ ## Setup
11
+
12
+ The target project must use Vitest or Jest with Istanbul JSON coverage output.
13
+
14
+ **Vitest** — add to `vitest.config.ts`:
15
+ ```ts
16
+ coverage: {
17
+ provider: 'v8',
18
+ reporter: ['text', 'json'],
19
+ }
20
+ ```
21
+
22
+ **Jest** — add to `jest.config.ts`:
23
+ ```ts
24
+ coverageReporters: ['text', 'json']
25
+ ```
26
+
27
+ Install crap4ts from source:
28
+ ```bash
29
+ git clone https://github.com/sebassdc/crap4ts.git
30
+ cd crap4ts
31
+ npm install
32
+ npm run build
33
+ npm install -g .
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Run from the project root (where `src/` lives):
39
+
40
+ ```bash
41
+ crap4ts
42
+
43
+ # Filter to specific modules
44
+ crap4ts parser validator
45
+
46
+ # Exclude paths
47
+ crap4ts --exclude dist --exclude fixtures
48
+
49
+ # Custom source directory
50
+ crap4ts --src packages/core/src
51
+ ```
52
+
53
+ crap4ts automatically deletes stale coverage data, runs the test suite with coverage, and prints the report.
54
+
55
+ ### CLI Options
56
+
57
+ | Flag | Argument | Description | Default |
58
+ |------|----------|-------------|---------|
59
+ | `--src` | `<dir>` | Source directory to analyze | `src` |
60
+ | `--exclude` | `<pattern>` | Exclude paths containing pattern (repeatable) | none |
61
+ | `--output` | `text\|json\|markdown\|csv` | Output format | `text` |
62
+ | `--json` | — | Shorthand for `--output json` | — |
63
+ | `--runner` | `vitest\|jest` | Skip auto-detection, use specified runner | auto-detect |
64
+ | `--coverage-command` | `<cmd>` | Custom shell command for coverage generation | none |
65
+ | `--fail-on-crap` | `<n>` | Exit 1 if any function CRAP score >= n | none |
66
+ | `--fail-on-complexity` | `<n>` | Exit 1 if any function complexity >= n | none |
67
+ | `--fail-on-coverage-below` | `<n>` | Exit 1 if coverage < n% (0-100) | none |
68
+ | `--top` | `<n>` | Show only top N entries (thresholds still check all) | all |
69
+ | `--timeout` | `<seconds>` | Analysis timeout | `600` |
70
+ | `--config` | `<path>` | Load config from specific file | auto-discover |
71
+ | `--help, -h` | — | Show help | — |
72
+ | `--version, -v` | — | Show version | — |
73
+
74
+ ### Configuration File
75
+
76
+ crap4ts auto-discovers `crap4ts.config.json` or `.crap4tsrc.json` in the working directory. CLI flags override config values.
77
+
78
+ ```json
79
+ {
80
+ "src": "lib",
81
+ "exclude": ["dist", "fixtures"],
82
+ "output": "json",
83
+ "runner": "vitest",
84
+ "coverageCommand": "npm run test:api -- --coverage",
85
+ "failOnCrap": 30,
86
+ "failOnComplexity": 15,
87
+ "failOnCoverageBelow": 70,
88
+ "top": 10,
89
+ "timeout": 120
90
+ }
91
+ ```
92
+
93
+ ### Output Formats
94
+
95
+ **Text** (default) — sorted table:
96
+ ```
97
+ CRAP Report
98
+ ===========
99
+ Function Module CC Cov% CRAP
100
+ -------------------------------------------------------------------------------------
101
+ complexFn my.module 12 45.0% 130.2
102
+ simpleFn my.module 1 100.0% 1.0
103
+ ```
104
+
105
+ **JSON** (`--json` or `--output json`):
106
+ ```json
107
+ {
108
+ "tool": "crap4ts",
109
+ "entries": [
110
+ { "name": "complexFn", "module": "my.module", "complexity": 12, "coverage": 45, "crap": 130.2 }
111
+ ]
112
+ }
113
+ ```
114
+
115
+ **Markdown** (`--output markdown`) — pipe-table format.
116
+
117
+ **CSV** (`--output csv`) — header row + data rows.
118
+
119
+ ### CI Integration
120
+
121
+ Use `--fail-on-*` flags to enforce quality gates:
122
+
123
+ ```bash
124
+ crap4ts --fail-on-crap 30 --fail-on-complexity 15 --fail-on-coverage-below 70
125
+ ```
126
+
127
+ Exit codes: `0` success, `1` threshold violation or error, `2` usage error.
128
+
129
+ ### Custom Coverage Command
130
+
131
+ For non-standard setups, bypass runner auto-detection entirely:
132
+
133
+ ```bash
134
+ crap4ts --coverage-command "CI=1 yarn test --coverage --coverageReporters=json"
135
+ ```
136
+
137
+ ## Interpreting Scores
138
+
139
+ | CRAP Score | Meaning |
140
+ |-----------|---------|
141
+ | 1-5 | Clean — low complexity, well tested |
142
+ | 5-30 | Moderate — consider refactoring or adding tests |
143
+ | 30+ | Crappy — high complexity with poor coverage |
144
+
145
+ ## Programmatic API
146
+
147
+ ```typescript
148
+ import { generateReport } from '@sebassdc/crap4ts';
149
+
150
+ const { entries } = generateReport({
151
+ srcDir: 'src',
152
+ coverageDir: 'coverage',
153
+ filters: ['parser', 'validator'],
154
+ excludes: ['__mocks__', '.stories']
155
+ });
156
+
157
+ entries.forEach(e => console.log(`${e.name}: CRAP ${e.crap}`));
158
+ ```
159
+
160
+ Also exports: `extractFunctions`, `parseCoverage`, `coverageForRange`, `crapScore`, `sortByCrap`, `formatReport`, `formatJsonReport`, `formatMarkdownReport`, `formatCsvReport`, `findSourceFiles`, `filterSources`, `analyzeFile`, `sourceToModule`, `normalizePath`.
161
+
162
+ ## How It Works
163
+
164
+ 1. Deletes stale `coverage/` directory
165
+ 2. Detects test runner (Vitest or Jest) from config files, or uses `--runner` / `--coverage-command`
166
+ 3. Runs tests with coverage to generate `coverage/coverage-final.json`
167
+ 4. Finds all `.ts` / `.tsx` files under the source directory
168
+ 5. Extracts top-level functions, class methods/getters/setters, and object literal methods using the TypeScript compiler API
169
+ 6. Computes cyclomatic complexity:
170
+ - `if`/`else if`, ternary: +1 each
171
+ - `for`, `for...of`, `for...in`, `while`, `do...while`: +1 each
172
+ - `catch` handler: +1 each
173
+ - `&&`, `||`, `??`: +1 per operator
174
+ - `case` in `switch`: +1 each (`default` excluded)
175
+ - Nested functions and classes are skipped
176
+ 7. Reads `coverage/coverage-final.json` for per-statement coverage data
177
+ 8. Applies CRAP formula: `CC` x `(1 - cov)^3 + CC`
178
+ 9. Applies filters, exclusions, and `--top` limit
179
+ 10. Formats output and checks thresholds