@aiready/testability 0.1.5 → 0.1.6
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/.turbo/turbo-build.log +8 -8
- package/.turbo/turbo-test.log +4 -6
- package/dist/chunk-YLYLRZRS.mjs +363 -0
- package/dist/cli.js +76 -22
- package/dist/cli.mjs +46 -13
- package/dist/index.js +36 -11
- package/dist/index.mjs +1 -1
- package/package.json +3 -3
- package/src/__tests__/analyzer.test.ts +18 -6
- package/src/analyzer.ts +103 -41
- package/src/cli.ts +80 -29
- package/src/scoring.ts +14 -8
- package/src/types.ts +7 -1
package/dist/cli.mjs
CHANGED
|
@@ -2,16 +2,24 @@
|
|
|
2
2
|
import {
|
|
3
3
|
analyzeTestability,
|
|
4
4
|
calculateTestabilityScore
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-YLYLRZRS.mjs";
|
|
6
6
|
|
|
7
7
|
// src/cli.ts
|
|
8
8
|
import { Command } from "commander";
|
|
9
9
|
import chalk from "chalk";
|
|
10
10
|
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
11
11
|
import { dirname } from "path";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
loadConfig,
|
|
14
|
+
mergeConfigWithDefaults,
|
|
15
|
+
resolveOutputPath
|
|
16
|
+
} from "@aiready/core";
|
|
13
17
|
var program = new Command();
|
|
14
|
-
program.name("aiready-testability").description(
|
|
18
|
+
program.name("aiready-testability").description(
|
|
19
|
+
"Measure how safely AI-generated changes can be verified in your codebase"
|
|
20
|
+
).version("0.1.0").addHelpText(
|
|
21
|
+
"after",
|
|
22
|
+
`
|
|
15
23
|
DIMENSIONS MEASURED:
|
|
16
24
|
Test Coverage Ratio of test files to source files
|
|
17
25
|
Function Purity Pure functions are trivially AI-testable
|
|
@@ -29,7 +37,15 @@ EXAMPLES:
|
|
|
29
37
|
aiready-testability . # Full analysis
|
|
30
38
|
aiready-testability src/ --output json # JSON report
|
|
31
39
|
aiready-testability . --min-coverage 0.5 # Stricter 50% threshold
|
|
32
|
-
`
|
|
40
|
+
`
|
|
41
|
+
).argument("<directory>", "Directory to analyze").option(
|
|
42
|
+
"--min-coverage <ratio>",
|
|
43
|
+
"Minimum acceptable test/source ratio (default: 0.3)",
|
|
44
|
+
"0.3"
|
|
45
|
+
).option(
|
|
46
|
+
"--test-patterns <patterns>",
|
|
47
|
+
"Additional test file patterns (comma-separated)"
|
|
48
|
+
).option("--include <patterns>", "File patterns to include (comma-separated)").option("--exclude <patterns>", "File patterns to exclude (comma-separated)").option("-o, --output <format>", "Output format: console|json", "console").option("--output-file <path>", "Output file path (for json)").action(async (directory, options) => {
|
|
33
49
|
console.log(chalk.blue("\u{1F9EA} Analyzing testability...\n"));
|
|
34
50
|
const startTime = Date.now();
|
|
35
51
|
const config = await loadConfig(directory);
|
|
@@ -98,18 +114,32 @@ function displayConsoleReport(report, scoring, elapsed) {
|
|
|
98
114
|
const safetyRating = summary.aiChangeSafetyRating;
|
|
99
115
|
console.log(chalk.bold("\n\u{1F9EA} Testability Analysis\n"));
|
|
100
116
|
if (safetyRating === "blind-risk") {
|
|
101
|
-
console.log(
|
|
102
|
-
|
|
103
|
-
|
|
117
|
+
console.log(
|
|
118
|
+
chalk.bgRed.white.bold(
|
|
119
|
+
" \u{1F480} BLIND RISK \u2014 NO TESTS DETECTED. AI-GENERATED CHANGES CANNOT BE VERIFIED. "
|
|
120
|
+
)
|
|
121
|
+
);
|
|
104
122
|
console.log();
|
|
105
123
|
} else if (safetyRating === "high-risk") {
|
|
106
|
-
console.log(
|
|
124
|
+
console.log(
|
|
125
|
+
chalk.red.bold(
|
|
126
|
+
` \u{1F534} HIGH RISK \u2014 Insufficient test coverage. AI changes may introduce silent bugs.`
|
|
127
|
+
)
|
|
128
|
+
);
|
|
107
129
|
console.log();
|
|
108
130
|
}
|
|
109
|
-
console.log(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
console.log(
|
|
131
|
+
console.log(
|
|
132
|
+
`AI Change Safety: ${safetyColor(safetyRating)(`${safetyIcon(safetyRating)} ${safetyRating.toUpperCase()}`)}`
|
|
133
|
+
);
|
|
134
|
+
console.log(
|
|
135
|
+
`Score: ${chalk.bold(summary.score + "/100")} (${summary.rating})`
|
|
136
|
+
);
|
|
137
|
+
console.log(
|
|
138
|
+
`Source Files: ${chalk.cyan(rawData.sourceFiles)} Test Files: ${chalk.cyan(rawData.testFiles)}`
|
|
139
|
+
);
|
|
140
|
+
console.log(
|
|
141
|
+
`Coverage Ratio: ${chalk.bold(Math.round(summary.coverageRatio * 100) + "%")}`
|
|
142
|
+
);
|
|
113
143
|
console.log(`Analysis Time: ${chalk.gray(elapsed + "s")}
|
|
114
144
|
`);
|
|
115
145
|
console.log(chalk.bold("\u{1F4D0} Dimension Scores\n"));
|
|
@@ -129,7 +159,10 @@ function displayConsoleReport(report, scoring, elapsed) {
|
|
|
129
159
|
for (const issue of issues) {
|
|
130
160
|
const sev = issue.severity === "critical" ? chalk.red : issue.severity === "major" ? chalk.yellow : chalk.blue;
|
|
131
161
|
console.log(`${sev(issue.severity.toUpperCase())} ${issue.message}`);
|
|
132
|
-
if (issue.suggestion)
|
|
162
|
+
if (issue.suggestion)
|
|
163
|
+
console.log(
|
|
164
|
+
` ${chalk.dim("\u2192")} ${chalk.italic(issue.suggestion)}`
|
|
165
|
+
);
|
|
133
166
|
console.log();
|
|
134
167
|
}
|
|
135
168
|
}
|
package/dist/index.js
CHANGED
|
@@ -31,7 +31,14 @@ var import_path = require("path");
|
|
|
31
31
|
var import_typescript_estree = require("@typescript-eslint/typescript-estree");
|
|
32
32
|
var import_core = require("@aiready/core");
|
|
33
33
|
var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
34
|
-
var DEFAULT_EXCLUDES = [
|
|
34
|
+
var DEFAULT_EXCLUDES = [
|
|
35
|
+
"node_modules",
|
|
36
|
+
"dist",
|
|
37
|
+
".git",
|
|
38
|
+
"coverage",
|
|
39
|
+
".turbo",
|
|
40
|
+
"build"
|
|
41
|
+
];
|
|
35
42
|
var TEST_PATTERNS = [
|
|
36
43
|
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
37
44
|
/__tests__\//,
|
|
@@ -107,8 +114,12 @@ function isPureFunction(fn) {
|
|
|
107
114
|
let hasSideEffect = false;
|
|
108
115
|
function walk(node) {
|
|
109
116
|
if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
|
|
110
|
-
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
111
|
-
|
|
117
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
118
|
+
hasSideEffect = true;
|
|
119
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(
|
|
120
|
+
node.callee.object.name
|
|
121
|
+
))
|
|
122
|
+
hasSideEffect = true;
|
|
112
123
|
for (const key of Object.keys(node)) {
|
|
113
124
|
if (key === "parent") continue;
|
|
114
125
|
const child = node[key];
|
|
@@ -132,7 +143,8 @@ function hasExternalStateMutation(fn) {
|
|
|
132
143
|
let found = false;
|
|
133
144
|
function walk(node) {
|
|
134
145
|
if (found) return;
|
|
135
|
-
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
146
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
147
|
+
found = true;
|
|
136
148
|
for (const key of Object.keys(node)) {
|
|
137
149
|
if (key === "parent") continue;
|
|
138
150
|
const child = node[key];
|
|
@@ -207,7 +219,16 @@ function detectTestFramework(rootDir) {
|
|
|
207
219
|
...pkg.dependencies ?? {},
|
|
208
220
|
...pkg.devDependencies ?? {}
|
|
209
221
|
};
|
|
210
|
-
const testFrameworks = [
|
|
222
|
+
const testFrameworks = [
|
|
223
|
+
"jest",
|
|
224
|
+
"vitest",
|
|
225
|
+
"mocha",
|
|
226
|
+
"jasmine",
|
|
227
|
+
"ava",
|
|
228
|
+
"tap",
|
|
229
|
+
"pytest",
|
|
230
|
+
"unittest"
|
|
231
|
+
];
|
|
211
232
|
return testFrameworks.some((fw) => allDeps[fw]);
|
|
212
233
|
} catch {
|
|
213
234
|
return false;
|
|
@@ -215,7 +236,9 @@ function detectTestFramework(rootDir) {
|
|
|
215
236
|
}
|
|
216
237
|
async function analyzeTestability(options) {
|
|
217
238
|
const allFiles = collectFiles(options.rootDir, options);
|
|
218
|
-
const sourceFiles = allFiles.filter(
|
|
239
|
+
const sourceFiles = allFiles.filter(
|
|
240
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
241
|
+
);
|
|
219
242
|
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
220
243
|
const aggregated = {
|
|
221
244
|
pureFunctions: 0,
|
|
@@ -340,11 +363,13 @@ function calculateTestabilityScore(report) {
|
|
|
340
363
|
description: `${rawData.externalStateMutations} functions mutate external state`
|
|
341
364
|
}
|
|
342
365
|
];
|
|
343
|
-
const recs = recommendations.map(
|
|
344
|
-
action
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
366
|
+
const recs = recommendations.map(
|
|
367
|
+
(action) => ({
|
|
368
|
+
action,
|
|
369
|
+
estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
|
|
370
|
+
priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
|
|
371
|
+
})
|
|
372
|
+
);
|
|
348
373
|
return {
|
|
349
374
|
toolName: "testability",
|
|
350
375
|
score: summary.score,
|
package/dist/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiready/testability",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Measures how safely and verifiably AI-generated changes can be made to your codebase",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"@typescript-eslint/typescript-estree": "^8.53.0",
|
|
40
40
|
"chalk": "^5.3.0",
|
|
41
41
|
"commander": "^14.0.0",
|
|
42
|
-
"glob": "^
|
|
43
|
-
"@aiready/core": "0.9.
|
|
42
|
+
"glob": "^13.0.0",
|
|
43
|
+
"@aiready/core": "0.9.33"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^24.0.0",
|
|
@@ -26,8 +26,14 @@ describe('Testability Analyzer', () => {
|
|
|
26
26
|
describe('Test Coverage Ratio', () => {
|
|
27
27
|
it('should calculate ratio of test files to source files', async () => {
|
|
28
28
|
createTestFile('src/math.ts', 'export const add = (a, b) => a + b;');
|
|
29
|
-
createTestFile(
|
|
30
|
-
|
|
29
|
+
createTestFile(
|
|
30
|
+
'src/math.test.ts',
|
|
31
|
+
'import { add } from "./math"; test("add", () => {});'
|
|
32
|
+
);
|
|
33
|
+
createTestFile(
|
|
34
|
+
'src/string.ts',
|
|
35
|
+
'export const upper = (s) => s.toUpperCase();'
|
|
36
|
+
);
|
|
31
37
|
|
|
32
38
|
const report = await analyzeTestability({ rootDir: tmpDir });
|
|
33
39
|
|
|
@@ -38,7 +44,9 @@ describe('Testability Analyzer', () => {
|
|
|
38
44
|
|
|
39
45
|
describe('Pure Functions and State Mutations', () => {
|
|
40
46
|
it('should detect state mutations inside functions', async () => {
|
|
41
|
-
createTestFile(
|
|
47
|
+
createTestFile(
|
|
48
|
+
'src/mutations.ts',
|
|
49
|
+
`
|
|
42
50
|
const globalState = { value: 0 };
|
|
43
51
|
|
|
44
52
|
export function impureAdd(a: number) {
|
|
@@ -49,7 +57,8 @@ describe('Testability Analyzer', () => {
|
|
|
49
57
|
export function pureAdd(a: number, b: number) {
|
|
50
58
|
return a + b;
|
|
51
59
|
}
|
|
52
|
-
`
|
|
60
|
+
`
|
|
61
|
+
);
|
|
53
62
|
|
|
54
63
|
const report = await analyzeTestability({ rootDir: tmpDir });
|
|
55
64
|
|
|
@@ -60,7 +69,9 @@ describe('Testability Analyzer', () => {
|
|
|
60
69
|
|
|
61
70
|
describe('Bloated Interfaces', () => {
|
|
62
71
|
it('should detect interfaces with too many methods', async () => {
|
|
63
|
-
createTestFile(
|
|
72
|
+
createTestFile(
|
|
73
|
+
'src/interfaces.ts',
|
|
74
|
+
`
|
|
64
75
|
export interface BloatedService {
|
|
65
76
|
m1(): void;
|
|
66
77
|
m2(): void;
|
|
@@ -74,7 +85,8 @@ describe('Testability Analyzer', () => {
|
|
|
74
85
|
m10(): void;
|
|
75
86
|
m11(): void;
|
|
76
87
|
}
|
|
77
|
-
`
|
|
88
|
+
`
|
|
89
|
+
);
|
|
78
90
|
|
|
79
91
|
const report = await analyzeTestability({ rootDir: tmpDir });
|
|
80
92
|
|
package/src/analyzer.ts
CHANGED
|
@@ -15,14 +15,25 @@ import { join, extname, basename } from 'path';
|
|
|
15
15
|
import { parse } from '@typescript-eslint/typescript-estree';
|
|
16
16
|
import type { TSESTree } from '@typescript-eslint/types';
|
|
17
17
|
import { calculateTestabilityIndex } from '@aiready/core';
|
|
18
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
TestabilityOptions,
|
|
20
|
+
TestabilityIssue,
|
|
21
|
+
TestabilityReport,
|
|
22
|
+
} from './types';
|
|
19
23
|
|
|
20
24
|
// ---------------------------------------------------------------------------
|
|
21
25
|
// File classification
|
|
22
26
|
// ---------------------------------------------------------------------------
|
|
23
27
|
|
|
24
28
|
const SRC_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
|
|
25
|
-
const DEFAULT_EXCLUDES = [
|
|
29
|
+
const DEFAULT_EXCLUDES = [
|
|
30
|
+
'node_modules',
|
|
31
|
+
'dist',
|
|
32
|
+
'.git',
|
|
33
|
+
'coverage',
|
|
34
|
+
'.turbo',
|
|
35
|
+
'build',
|
|
36
|
+
];
|
|
26
37
|
const TEST_PATTERNS = [
|
|
27
38
|
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
28
39
|
/__tests__\//,
|
|
@@ -32,8 +43,8 @@ const TEST_PATTERNS = [
|
|
|
32
43
|
];
|
|
33
44
|
|
|
34
45
|
function isTestFile(filePath: string, extra?: string[]): boolean {
|
|
35
|
-
if (TEST_PATTERNS.some(p => p.test(filePath))) return true;
|
|
36
|
-
if (extra) return extra.some(p => filePath.includes(p));
|
|
46
|
+
if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
|
|
47
|
+
if (extra) return extra.some((p) => filePath.includes(p));
|
|
37
48
|
return false;
|
|
38
49
|
}
|
|
39
50
|
|
|
@@ -44,7 +55,7 @@ function isSourceFile(filePath: string): boolean {
|
|
|
44
55
|
function collectFiles(
|
|
45
56
|
dir: string,
|
|
46
57
|
options: TestabilityOptions,
|
|
47
|
-
depth = 0
|
|
58
|
+
depth = 0
|
|
48
59
|
): string[] {
|
|
49
60
|
if (depth > (options.maxDepth ?? 20)) return [];
|
|
50
61
|
const excludes = [...DEFAULT_EXCLUDES, ...(options.exclude ?? [])];
|
|
@@ -56,14 +67,18 @@ function collectFiles(
|
|
|
56
67
|
return files;
|
|
57
68
|
}
|
|
58
69
|
for (const entry of entries) {
|
|
59
|
-
if (excludes.some(ex => entry === ex || entry.includes(ex))) continue;
|
|
70
|
+
if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
|
|
60
71
|
const full = join(dir, entry);
|
|
61
72
|
let stat;
|
|
62
|
-
try {
|
|
73
|
+
try {
|
|
74
|
+
stat = statSync(full);
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
63
78
|
if (stat.isDirectory()) {
|
|
64
79
|
files.push(...collectFiles(full, options, depth + 1));
|
|
65
80
|
} else if (stat.isFile() && isSourceFile(full)) {
|
|
66
|
-
if (!options.include || options.include.some(p => full.includes(p))) {
|
|
81
|
+
if (!options.include || options.include.some((p) => full.includes(p))) {
|
|
67
82
|
files.push(full);
|
|
68
83
|
}
|
|
69
84
|
}
|
|
@@ -85,20 +100,27 @@ interface FileAnalysis {
|
|
|
85
100
|
externalStateMutations: number;
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
function countMethodsInInterface(
|
|
103
|
+
function countMethodsInInterface(
|
|
104
|
+
node: TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeAliasDeclaration
|
|
105
|
+
): number {
|
|
89
106
|
// Count method signatures
|
|
90
107
|
if (node.type === 'TSInterfaceDeclaration') {
|
|
91
|
-
return node.body.body.filter(
|
|
92
|
-
m.type === 'TSMethodSignature' || m.type === 'TSPropertySignature'
|
|
108
|
+
return node.body.body.filter(
|
|
109
|
+
(m) => m.type === 'TSMethodSignature' || m.type === 'TSPropertySignature'
|
|
93
110
|
).length;
|
|
94
111
|
}
|
|
95
|
-
if (
|
|
112
|
+
if (
|
|
113
|
+
node.type === 'TSTypeAliasDeclaration' &&
|
|
114
|
+
node.typeAnnotation.type === 'TSTypeLiteral'
|
|
115
|
+
) {
|
|
96
116
|
return node.typeAnnotation.members.length;
|
|
97
117
|
}
|
|
98
118
|
return 0;
|
|
99
119
|
}
|
|
100
120
|
|
|
101
|
-
function hasDependencyInjection(
|
|
121
|
+
function hasDependencyInjection(
|
|
122
|
+
node: TSESTree.ClassDeclaration | TSESTree.ClassExpression
|
|
123
|
+
): boolean {
|
|
102
124
|
// Look for a constructor with typed parameters (the most common DI pattern)
|
|
103
125
|
for (const member of node.body.body) {
|
|
104
126
|
if (
|
|
@@ -109,10 +131,12 @@ function hasDependencyInjection(node: TSESTree.ClassDeclaration | TSESTree.Class
|
|
|
109
131
|
const fn = member.value;
|
|
110
132
|
if (fn.params && fn.params.length > 0) {
|
|
111
133
|
// If constructor takes parameters that are typed class/interface references, that's DI
|
|
112
|
-
const typedParams = fn.params.filter(p => {
|
|
134
|
+
const typedParams = fn.params.filter((p) => {
|
|
113
135
|
const param = p as any;
|
|
114
|
-
return
|
|
115
|
-
param.
|
|
136
|
+
return (
|
|
137
|
+
param.typeAnnotation != null ||
|
|
138
|
+
param.parameter?.typeAnnotation != null
|
|
139
|
+
);
|
|
116
140
|
});
|
|
117
141
|
if (typedParams.length > 0) return true;
|
|
118
142
|
}
|
|
@@ -122,7 +146,10 @@ function hasDependencyInjection(node: TSESTree.ClassDeclaration | TSESTree.Class
|
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
function isPureFunction(
|
|
125
|
-
fn:
|
|
149
|
+
fn:
|
|
150
|
+
| TSESTree.FunctionDeclaration
|
|
151
|
+
| TSESTree.FunctionExpression
|
|
152
|
+
| TSESTree.ArrowFunctionExpression
|
|
126
153
|
): boolean {
|
|
127
154
|
let hasReturn = false;
|
|
128
155
|
let hasSideEffect = false;
|
|
@@ -132,14 +159,18 @@ function isPureFunction(
|
|
|
132
159
|
if (
|
|
133
160
|
node.type === 'AssignmentExpression' &&
|
|
134
161
|
node.left.type === 'MemberExpression'
|
|
135
|
-
)
|
|
162
|
+
)
|
|
163
|
+
hasSideEffect = true;
|
|
136
164
|
// Calls to console, process, global objects
|
|
137
165
|
if (
|
|
138
166
|
node.type === 'CallExpression' &&
|
|
139
167
|
node.callee.type === 'MemberExpression' &&
|
|
140
168
|
node.callee.object.type === 'Identifier' &&
|
|
141
|
-
['console', 'process', 'window', 'document', 'fs'].includes(
|
|
142
|
-
|
|
169
|
+
['console', 'process', 'window', 'document', 'fs'].includes(
|
|
170
|
+
node.callee.object.name
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
hasSideEffect = true;
|
|
143
174
|
|
|
144
175
|
// Recurse
|
|
145
176
|
for (const key of Object.keys(node)) {
|
|
@@ -147,7 +178,7 @@ function isPureFunction(
|
|
|
147
178
|
const child = (node as any)[key];
|
|
148
179
|
if (child && typeof child === 'object') {
|
|
149
180
|
if (Array.isArray(child)) {
|
|
150
|
-
child.forEach(c => c?.type && walk(c));
|
|
181
|
+
child.forEach((c) => c?.type && walk(c));
|
|
151
182
|
} else if (child.type) {
|
|
152
183
|
walk(child);
|
|
153
184
|
}
|
|
@@ -156,7 +187,7 @@ function isPureFunction(
|
|
|
156
187
|
}
|
|
157
188
|
|
|
158
189
|
if (fn.body?.type === 'BlockStatement') {
|
|
159
|
-
fn.body.body.forEach(s => walk(s));
|
|
190
|
+
fn.body.body.forEach((s) => walk(s));
|
|
160
191
|
} else if (fn.body) {
|
|
161
192
|
hasReturn = true; // arrow expression body
|
|
162
193
|
}
|
|
@@ -165,7 +196,10 @@ function isPureFunction(
|
|
|
165
196
|
}
|
|
166
197
|
|
|
167
198
|
function hasExternalStateMutation(
|
|
168
|
-
fn:
|
|
199
|
+
fn:
|
|
200
|
+
| TSESTree.FunctionDeclaration
|
|
201
|
+
| TSESTree.FunctionExpression
|
|
202
|
+
| TSESTree.ArrowFunctionExpression
|
|
169
203
|
): boolean {
|
|
170
204
|
let found = false;
|
|
171
205
|
function walk(node: TSESTree.Node) {
|
|
@@ -173,17 +207,18 @@ function hasExternalStateMutation(
|
|
|
173
207
|
if (
|
|
174
208
|
node.type === 'AssignmentExpression' &&
|
|
175
209
|
node.left.type === 'MemberExpression'
|
|
176
|
-
)
|
|
210
|
+
)
|
|
211
|
+
found = true;
|
|
177
212
|
for (const key of Object.keys(node)) {
|
|
178
213
|
if (key === 'parent') continue;
|
|
179
214
|
const child = (node as any)[key];
|
|
180
215
|
if (child && typeof child === 'object') {
|
|
181
|
-
if (Array.isArray(child)) child.forEach(c => c?.type && walk(c));
|
|
216
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && walk(c));
|
|
182
217
|
else if (child.type) walk(child);
|
|
183
218
|
}
|
|
184
219
|
}
|
|
185
220
|
}
|
|
186
|
-
if (fn.body?.type === 'BlockStatement') fn.body.body.forEach(s => walk(s));
|
|
221
|
+
if (fn.body?.type === 'BlockStatement') fn.body.body.forEach((s) => walk(s));
|
|
187
222
|
return found;
|
|
188
223
|
}
|
|
189
224
|
|
|
@@ -199,7 +234,11 @@ function analyzeFileTestability(filePath: string): FileAnalysis {
|
|
|
199
234
|
};
|
|
200
235
|
|
|
201
236
|
let code: string;
|
|
202
|
-
try {
|
|
237
|
+
try {
|
|
238
|
+
code = readFileSync(filePath, 'utf-8');
|
|
239
|
+
} catch {
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
203
242
|
|
|
204
243
|
let ast: TSESTree.Program;
|
|
205
244
|
try {
|
|
@@ -208,7 +247,9 @@ function analyzeFileTestability(filePath: string): FileAnalysis {
|
|
|
208
247
|
range: false,
|
|
209
248
|
loc: false,
|
|
210
249
|
});
|
|
211
|
-
} catch {
|
|
250
|
+
} catch {
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
212
253
|
|
|
213
254
|
function visit(node: TSESTree.Node) {
|
|
214
255
|
if (
|
|
@@ -226,7 +267,10 @@ function analyzeFileTestability(filePath: string): FileAnalysis {
|
|
|
226
267
|
if (hasDependencyInjection(node)) result.injectionPatterns++;
|
|
227
268
|
}
|
|
228
269
|
|
|
229
|
-
if (
|
|
270
|
+
if (
|
|
271
|
+
node.type === 'TSInterfaceDeclaration' ||
|
|
272
|
+
node.type === 'TSTypeAliasDeclaration'
|
|
273
|
+
) {
|
|
230
274
|
result.totalInterfaces++;
|
|
231
275
|
const methodCount = countMethodsInInterface(node as any);
|
|
232
276
|
if (methodCount > 10) result.bloatedInterfaces++;
|
|
@@ -237,7 +281,7 @@ function analyzeFileTestability(filePath: string): FileAnalysis {
|
|
|
237
281
|
if (key === 'parent') continue;
|
|
238
282
|
const child = (node as any)[key];
|
|
239
283
|
if (child && typeof child === 'object') {
|
|
240
|
-
if (Array.isArray(child)) child.forEach(c => c?.type && visit(c));
|
|
284
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && visit(c));
|
|
241
285
|
else if (child.type) visit(child);
|
|
242
286
|
}
|
|
243
287
|
}
|
|
@@ -260,9 +304,20 @@ function detectTestFramework(rootDir: string): boolean {
|
|
|
260
304
|
...(pkg.dependencies ?? {}),
|
|
261
305
|
...(pkg.devDependencies ?? {}),
|
|
262
306
|
};
|
|
263
|
-
const testFrameworks = [
|
|
264
|
-
|
|
265
|
-
|
|
307
|
+
const testFrameworks = [
|
|
308
|
+
'jest',
|
|
309
|
+
'vitest',
|
|
310
|
+
'mocha',
|
|
311
|
+
'jasmine',
|
|
312
|
+
'ava',
|
|
313
|
+
'tap',
|
|
314
|
+
'pytest',
|
|
315
|
+
'unittest',
|
|
316
|
+
];
|
|
317
|
+
return testFrameworks.some((fw) => allDeps[fw]);
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
266
321
|
}
|
|
267
322
|
|
|
268
323
|
// ---------------------------------------------------------------------------
|
|
@@ -270,12 +325,14 @@ function detectTestFramework(rootDir: string): boolean {
|
|
|
270
325
|
// ---------------------------------------------------------------------------
|
|
271
326
|
|
|
272
327
|
export async function analyzeTestability(
|
|
273
|
-
options: TestabilityOptions
|
|
328
|
+
options: TestabilityOptions
|
|
274
329
|
): Promise<TestabilityReport> {
|
|
275
330
|
const allFiles = collectFiles(options.rootDir, options);
|
|
276
331
|
|
|
277
|
-
const sourceFiles = allFiles.filter(
|
|
278
|
-
|
|
332
|
+
const sourceFiles = allFiles.filter(
|
|
333
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
334
|
+
);
|
|
335
|
+
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
279
336
|
|
|
280
337
|
const aggregated: FileAnalysis = {
|
|
281
338
|
pureFunctions: 0,
|
|
@@ -312,21 +369,25 @@ export async function analyzeTestability(
|
|
|
312
369
|
// Build issues
|
|
313
370
|
const issues: TestabilityIssue[] = [];
|
|
314
371
|
const minCoverage = options.minCoverageRatio ?? 0.3;
|
|
315
|
-
const actualRatio =
|
|
372
|
+
const actualRatio =
|
|
373
|
+
sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
316
374
|
|
|
317
375
|
if (!hasTestFramework) {
|
|
318
376
|
issues.push({
|
|
319
377
|
type: 'low-testability',
|
|
320
378
|
dimension: 'framework',
|
|
321
379
|
severity: 'critical',
|
|
322
|
-
message:
|
|
380
|
+
message:
|
|
381
|
+
'No testing framework detected in package.json — AI changes cannot be verified at all.',
|
|
323
382
|
location: { file: options.rootDir, line: 0 },
|
|
324
|
-
suggestion:
|
|
383
|
+
suggestion:
|
|
384
|
+
'Add Jest, Vitest, or another testing framework as a devDependency.',
|
|
325
385
|
});
|
|
326
386
|
}
|
|
327
387
|
|
|
328
388
|
if (actualRatio < minCoverage) {
|
|
329
|
-
const needed =
|
|
389
|
+
const needed =
|
|
390
|
+
Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
330
391
|
issues.push({
|
|
331
392
|
type: 'low-testability',
|
|
332
393
|
dimension: 'test-coverage',
|
|
@@ -344,7 +405,8 @@ export async function analyzeTestability(
|
|
|
344
405
|
severity: 'major',
|
|
345
406
|
message: `Only ${indexResult.dimensions.purityScore}% of functions are pure — side-effectful functions require complex test setup.`,
|
|
346
407
|
location: { file: options.rootDir, line: 0 },
|
|
347
|
-
suggestion:
|
|
408
|
+
suggestion:
|
|
409
|
+
'Extract pure transformation logic from I/O and mutation code.',
|
|
348
410
|
});
|
|
349
411
|
}
|
|
350
412
|
|