@aiready/testability 0.4.16 → 0.4.18
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 +10 -10
- package/.turbo/turbo-test.log +4 -4
- package/README.md +6 -0
- package/dist/chunk-P53QYYIW.mjs +258 -0
- package/dist/chunk-QOIBI5E7.mjs +262 -0
- package/dist/chunk-RQRAKTO6.mjs +251 -0
- package/dist/chunk-U2VGBMYR.mjs +259 -0
- package/dist/chunk-UT24GZ66.mjs +254 -0
- package/dist/chunk-VDC6AKUD.mjs +257 -0
- package/dist/chunk-VNU3SQOC.mjs +265 -0
- package/dist/cli.js +63 -144
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +0 -12
- package/dist/index.d.ts +0 -12
- package/dist/index.js +63 -144
- package/dist/index.mjs +1 -1
- package/package.json +2 -2
- package/src/analyzer.ts +70 -216
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/testability@0.4.
|
|
3
|
+
> @aiready/testability@0.4.17 build /Users/pengcao/projects/aiready/packages/testability
|
|
4
4
|
> tsup src/index.ts src/cli.ts --format cjs,esm --dts
|
|
5
5
|
|
|
6
6
|
[34mCLI[39m Building entry: src/cli.ts, src/index.ts
|
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
[34mCLI[39m Target: es2020
|
|
10
10
|
[34mCJS[39m Build start
|
|
11
11
|
[34mESM[39m Build start
|
|
12
|
-
[
|
|
13
|
-
[
|
|
12
|
+
[32mCJS[39m [1mdist/index.js [22m[32m10.73 KB[39m
|
|
13
|
+
[32mCJS[39m [1mdist/cli.js [22m[32m15.61 KB[39m
|
|
14
|
+
[32mCJS[39m ⚡️ Build success in 62ms
|
|
14
15
|
[32mESM[39m [1mdist/index.mjs [22m[32m1.28 KB[39m
|
|
15
|
-
[32mESM[39m
|
|
16
|
-
[
|
|
17
|
-
[
|
|
18
|
-
[32mCJS[39m ⚡️ Build success in 142ms
|
|
16
|
+
[32mESM[39m [1mdist/cli.mjs [22m[32m5.75 KB[39m
|
|
17
|
+
[32mESM[39m [1mdist/chunk-QOIBI5E7.mjs [22m[32m8.31 KB[39m
|
|
18
|
+
[32mESM[39m ⚡️ Build success in 63ms
|
|
19
19
|
DTS Build start
|
|
20
|
-
DTS ⚡️ Build success in
|
|
20
|
+
DTS ⚡️ Build success in 4193ms
|
|
21
21
|
DTS dist/cli.d.ts 20.00 B
|
|
22
|
-
DTS dist/index.d.ts 2.
|
|
22
|
+
DTS dist/index.d.ts 2.41 KB
|
|
23
23
|
DTS dist/cli.d.mts 20.00 B
|
|
24
|
-
DTS dist/index.d.mts 2.
|
|
24
|
+
DTS dist/index.d.mts 2.41 KB
|
package/.turbo/turbo-test.log
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/testability@0.4.
|
|
3
|
+
> @aiready/testability@0.4.17 test /Users/pengcao/projects/aiready/packages/testability
|
|
4
4
|
> vitest run
|
|
5
5
|
|
|
6
6
|
[?25l
|
|
7
7
|
[1m[46m RUN [49m[22m [36mv4.0.18 [39m[90m/Users/pengcao/projects/aiready/packages/testability[39m
|
|
8
8
|
|
|
9
|
-
[32m✓[39m src/__tests__/analyzer.test.ts [2m([22m[2m3 tests[22m[2m)[22m[32m
|
|
9
|
+
[32m✓[39m src/__tests__/analyzer.test.ts [2m([22m[2m3 tests[22m[2m)[22m[32m 83[2mms[22m[39m
|
|
10
10
|
|
|
11
11
|
[2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
12
12
|
[2m Tests [22m [1m[32m3 passed[39m[22m[90m (3)[39m
|
|
13
|
-
[2m Start at [22m
|
|
14
|
-
[2m Duration [22m 1.
|
|
13
|
+
[2m Start at [22m 17:27:57
|
|
14
|
+
[2m Duration [22m 1.53s[2m (transform 574ms, setup 0ms, import 1.10s, tests 83ms, environment 0ms)[22m
|
|
15
15
|
|
|
16
16
|
[?25h
|
package/README.md
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
|
|
10
10
|
The "Verify" loop is the most expensive part of the AI agent workflow. Codebases with high global state, missing dependency injection, or poor test coverage force agents into long, expensive retry loops. The **Testability Index** quantifies these frictions.
|
|
11
11
|
|
|
12
|
+
### Language Support
|
|
13
|
+
|
|
14
|
+
- **Full Support:** TypeScript, JavaScript, Python, Java, Go, C#
|
|
15
|
+
- **Capabilities:** Purity analysis, global state detection, DI pattern recognition.
|
|
16
|
+
toxicology
|
|
17
|
+
|
|
12
18
|
## 🏛️ Architecture
|
|
13
19
|
|
|
14
20
|
```
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// src/analyzer.ts
|
|
2
|
+
import {
|
|
3
|
+
scanFiles,
|
|
4
|
+
calculateTestabilityIndex,
|
|
5
|
+
Severity,
|
|
6
|
+
IssueType,
|
|
7
|
+
emitProgress,
|
|
8
|
+
getParser
|
|
9
|
+
} from "@aiready/core";
|
|
10
|
+
import { readFileSync, existsSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
async function analyzeFileTestability(filePath) {
|
|
13
|
+
const result = {
|
|
14
|
+
pureFunctions: 0,
|
|
15
|
+
totalFunctions: 0,
|
|
16
|
+
injectionPatterns: 0,
|
|
17
|
+
totalClasses: 0,
|
|
18
|
+
bloatedInterfaces: 0,
|
|
19
|
+
totalInterfaces: 0,
|
|
20
|
+
externalStateMutations: 0
|
|
21
|
+
};
|
|
22
|
+
const parser = getParser(filePath);
|
|
23
|
+
if (!parser) return result;
|
|
24
|
+
let code;
|
|
25
|
+
try {
|
|
26
|
+
code = readFileSync(filePath, "utf-8");
|
|
27
|
+
} catch {
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
await parser.initialize();
|
|
32
|
+
const parseResult = parser.parse(code, filePath);
|
|
33
|
+
for (const exp of parseResult.exports) {
|
|
34
|
+
if (exp.type === "function") {
|
|
35
|
+
result.totalFunctions++;
|
|
36
|
+
if (exp.isPure) result.pureFunctions++;
|
|
37
|
+
if (exp.hasSideEffects) result.externalStateMutations++;
|
|
38
|
+
}
|
|
39
|
+
if (exp.type === "class") {
|
|
40
|
+
result.totalClasses++;
|
|
41
|
+
if (exp.parameters && exp.parameters.length > 0) {
|
|
42
|
+
result.injectionPatterns++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (exp.type === "interface" || exp.type === "class") {
|
|
46
|
+
if (exp.type === "interface") result.totalInterfaces++;
|
|
47
|
+
const total = (exp.methodCount || 0) + (exp.propertyCount || 0);
|
|
48
|
+
if (total > 5) {
|
|
49
|
+
result.bloatedInterfaces++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.warn(`Testability: Failed to parse ${filePath}: ${error}`);
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
function detectTestFramework(rootDir) {
|
|
59
|
+
const manifests = [
|
|
60
|
+
{
|
|
61
|
+
file: "package.json",
|
|
62
|
+
deps: ["jest", "vitest", "mocha", "mocha", "jasmine", "ava", "tap"]
|
|
63
|
+
},
|
|
64
|
+
{ file: "requirements.txt", deps: ["pytest", "unittest", "nose"] },
|
|
65
|
+
{ file: "pyproject.toml", deps: ["pytest"] },
|
|
66
|
+
{ file: "pom.xml", deps: ["junit", "testng"] },
|
|
67
|
+
{ file: "build.gradle", deps: ["junit", "testng"] },
|
|
68
|
+
{ file: "go.mod", deps: ["testing"] }
|
|
69
|
+
// go testing is built-in
|
|
70
|
+
];
|
|
71
|
+
for (const m of manifests) {
|
|
72
|
+
const p = join(rootDir, m.file);
|
|
73
|
+
if (existsSync(p)) {
|
|
74
|
+
if (m.file === "go.mod") return true;
|
|
75
|
+
try {
|
|
76
|
+
const content = readFileSync(p, "utf-8");
|
|
77
|
+
if (m.deps.some((d) => content.includes(d))) return true;
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
var TEST_PATTERNS = [
|
|
85
|
+
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
86
|
+
/_test\.go$/,
|
|
87
|
+
/test_.*\.py$/,
|
|
88
|
+
/.*_test\.py$/,
|
|
89
|
+
/.*Test\.java$/,
|
|
90
|
+
/.*Tests\.cs$/,
|
|
91
|
+
/__tests__\//,
|
|
92
|
+
/\/tests?\//,
|
|
93
|
+
/\/e2e\//,
|
|
94
|
+
/\/fixtures\//
|
|
95
|
+
];
|
|
96
|
+
function isTestFile(filePath, extra) {
|
|
97
|
+
if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
|
|
98
|
+
if (extra) return extra.some((p) => filePath.includes(p));
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
async function analyzeTestability(options) {
|
|
102
|
+
const allFiles = await scanFiles({
|
|
103
|
+
...options,
|
|
104
|
+
include: options.include || ["**/*.{ts,tsx,js,jsx,py,java,cs,go}"],
|
|
105
|
+
includeTests: true
|
|
106
|
+
});
|
|
107
|
+
const sourceFiles = allFiles.filter(
|
|
108
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
109
|
+
);
|
|
110
|
+
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
111
|
+
const aggregated = {
|
|
112
|
+
pureFunctions: 0,
|
|
113
|
+
totalFunctions: 0,
|
|
114
|
+
injectionPatterns: 0,
|
|
115
|
+
totalClasses: 0,
|
|
116
|
+
bloatedInterfaces: 0,
|
|
117
|
+
totalInterfaces: 0,
|
|
118
|
+
externalStateMutations: 0
|
|
119
|
+
};
|
|
120
|
+
let processed = 0;
|
|
121
|
+
for (const f of sourceFiles) {
|
|
122
|
+
processed++;
|
|
123
|
+
emitProgress(
|
|
124
|
+
processed,
|
|
125
|
+
sourceFiles.length,
|
|
126
|
+
"testability",
|
|
127
|
+
"analyzing files",
|
|
128
|
+
options.onProgress
|
|
129
|
+
);
|
|
130
|
+
const a = await analyzeFileTestability(f);
|
|
131
|
+
for (const key of Object.keys(aggregated)) {
|
|
132
|
+
aggregated[key] += a[key];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const hasTestFramework = detectTestFramework(options.rootDir);
|
|
136
|
+
const indexResult = calculateTestabilityIndex({
|
|
137
|
+
testFiles: testFiles.length,
|
|
138
|
+
sourceFiles: sourceFiles.length,
|
|
139
|
+
pureFunctions: aggregated.pureFunctions,
|
|
140
|
+
totalFunctions: Math.max(1, aggregated.totalFunctions),
|
|
141
|
+
injectionPatterns: aggregated.injectionPatterns,
|
|
142
|
+
totalClasses: Math.max(1, aggregated.totalClasses),
|
|
143
|
+
bloatedInterfaces: aggregated.bloatedInterfaces,
|
|
144
|
+
totalInterfaces: Math.max(1, aggregated.totalInterfaces),
|
|
145
|
+
externalStateMutations: aggregated.externalStateMutations,
|
|
146
|
+
hasTestFramework
|
|
147
|
+
});
|
|
148
|
+
const issues = [];
|
|
149
|
+
const minCoverage = options.minCoverageRatio ?? 0.3;
|
|
150
|
+
const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
151
|
+
if (!hasTestFramework) {
|
|
152
|
+
issues.push({
|
|
153
|
+
type: IssueType.LowTestability,
|
|
154
|
+
dimension: "framework",
|
|
155
|
+
severity: Severity.Critical,
|
|
156
|
+
message: "No major testing framework detected \u2014 AI changes cannot be safely verified.",
|
|
157
|
+
location: { file: options.rootDir, line: 0 },
|
|
158
|
+
suggestion: "Add a testing framework (e.g., Jest, Pytest, JUnit) to enable automated verification."
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (actualRatio < minCoverage) {
|
|
162
|
+
const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
163
|
+
issues.push({
|
|
164
|
+
type: IssueType.LowTestability,
|
|
165
|
+
dimension: "test-coverage",
|
|
166
|
+
severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
|
|
167
|
+
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
168
|
+
location: { file: options.rootDir, line: 0 },
|
|
169
|
+
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (indexResult.dimensions.purityScore < 50) {
|
|
173
|
+
issues.push({
|
|
174
|
+
type: IssueType.LowTestability,
|
|
175
|
+
dimension: "purity",
|
|
176
|
+
severity: Severity.Major,
|
|
177
|
+
message: `Only ${indexResult.dimensions.purityScore}% of functions appear pure \u2014 side-effectful code is harder for AI to verify safely.`,
|
|
178
|
+
location: { file: options.rootDir, line: 0 },
|
|
179
|
+
suggestion: "Refactor complex side-effectful logic into pure functions where possible."
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
summary: {
|
|
184
|
+
sourceFiles: sourceFiles.length,
|
|
185
|
+
testFiles: testFiles.length,
|
|
186
|
+
coverageRatio: Math.round(actualRatio * 100) / 100,
|
|
187
|
+
score: indexResult.score,
|
|
188
|
+
rating: indexResult.rating,
|
|
189
|
+
aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
|
|
190
|
+
dimensions: indexResult.dimensions
|
|
191
|
+
},
|
|
192
|
+
issues,
|
|
193
|
+
rawData: {
|
|
194
|
+
sourceFiles: sourceFiles.length,
|
|
195
|
+
testFiles: testFiles.length,
|
|
196
|
+
...aggregated,
|
|
197
|
+
hasTestFramework
|
|
198
|
+
},
|
|
199
|
+
recommendations: indexResult.recommendations
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/scoring.ts
|
|
204
|
+
import { ToolName } from "@aiready/core";
|
|
205
|
+
function calculateTestabilityScore(report) {
|
|
206
|
+
const { summary, rawData, recommendations } = report;
|
|
207
|
+
const factors = [
|
|
208
|
+
{
|
|
209
|
+
name: "Test Coverage",
|
|
210
|
+
impact: Math.round(summary.dimensions.testCoverageRatio - 50),
|
|
211
|
+
description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "Function Purity",
|
|
215
|
+
impact: Math.round(summary.dimensions.purityScore - 50),
|
|
216
|
+
description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "Dependency Injection",
|
|
220
|
+
impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
|
|
221
|
+
description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "Interface Focus",
|
|
225
|
+
impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
|
|
226
|
+
description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "Observability",
|
|
230
|
+
impact: Math.round(summary.dimensions.observabilityScore - 50),
|
|
231
|
+
description: `${rawData.externalStateMutations} functions mutate external state`
|
|
232
|
+
}
|
|
233
|
+
];
|
|
234
|
+
const recs = recommendations.map(
|
|
235
|
+
(action) => ({
|
|
236
|
+
action,
|
|
237
|
+
estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
|
|
238
|
+
priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
return {
|
|
242
|
+
toolName: ToolName.TestabilityIndex,
|
|
243
|
+
score: summary.score,
|
|
244
|
+
rawMetrics: {
|
|
245
|
+
...rawData,
|
|
246
|
+
rating: summary.rating,
|
|
247
|
+
aiChangeSafetyRating: summary.aiChangeSafetyRating,
|
|
248
|
+
coverageRatio: summary.coverageRatio
|
|
249
|
+
},
|
|
250
|
+
factors,
|
|
251
|
+
recommendations: recs
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export {
|
|
256
|
+
analyzeTestability,
|
|
257
|
+
calculateTestabilityScore
|
|
258
|
+
};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// src/analyzer.ts
|
|
2
|
+
import {
|
|
3
|
+
scanFiles,
|
|
4
|
+
calculateTestabilityIndex,
|
|
5
|
+
Severity,
|
|
6
|
+
IssueType,
|
|
7
|
+
emitProgress,
|
|
8
|
+
getParser
|
|
9
|
+
} from "@aiready/core";
|
|
10
|
+
import { readFileSync, existsSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
async function analyzeFileTestability(filePath) {
|
|
13
|
+
const result = {
|
|
14
|
+
pureFunctions: 0,
|
|
15
|
+
totalFunctions: 0,
|
|
16
|
+
injectionPatterns: 0,
|
|
17
|
+
totalClasses: 0,
|
|
18
|
+
bloatedInterfaces: 0,
|
|
19
|
+
totalInterfaces: 0,
|
|
20
|
+
externalStateMutations: 0
|
|
21
|
+
};
|
|
22
|
+
const parser = getParser(filePath);
|
|
23
|
+
if (!parser) return result;
|
|
24
|
+
let code;
|
|
25
|
+
try {
|
|
26
|
+
code = readFileSync(filePath, "utf-8");
|
|
27
|
+
} catch {
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
await parser.initialize();
|
|
32
|
+
const parseResult = parser.parse(code, filePath);
|
|
33
|
+
for (const exp of parseResult.exports) {
|
|
34
|
+
if (exp.type === "function") {
|
|
35
|
+
result.totalFunctions++;
|
|
36
|
+
if (exp.isPure) result.pureFunctions++;
|
|
37
|
+
if (exp.hasSideEffects) result.externalStateMutations++;
|
|
38
|
+
}
|
|
39
|
+
if (exp.type === "class") {
|
|
40
|
+
result.totalClasses++;
|
|
41
|
+
if (exp.parameters && exp.parameters.length > 0) {
|
|
42
|
+
result.injectionPatterns++;
|
|
43
|
+
}
|
|
44
|
+
const total = (exp.methodCount || 0) + (exp.propertyCount || 0);
|
|
45
|
+
if (total > 5) {
|
|
46
|
+
result.bloatedInterfaces++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (exp.type === "interface") {
|
|
50
|
+
result.totalInterfaces++;
|
|
51
|
+
const total = (exp.methodCount || 0) + (exp.propertyCount || 0);
|
|
52
|
+
if (total > 5) {
|
|
53
|
+
result.bloatedInterfaces++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.warn(`Testability: Failed to parse ${filePath}: ${error}`);
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
function detectTestFramework(rootDir) {
|
|
63
|
+
const manifests = [
|
|
64
|
+
{
|
|
65
|
+
file: "package.json",
|
|
66
|
+
deps: ["jest", "vitest", "mocha", "mocha", "jasmine", "ava", "tap"]
|
|
67
|
+
},
|
|
68
|
+
{ file: "requirements.txt", deps: ["pytest", "unittest", "nose"] },
|
|
69
|
+
{ file: "pyproject.toml", deps: ["pytest"] },
|
|
70
|
+
{ file: "pom.xml", deps: ["junit", "testng"] },
|
|
71
|
+
{ file: "build.gradle", deps: ["junit", "testng"] },
|
|
72
|
+
{ file: "go.mod", deps: ["testing"] }
|
|
73
|
+
// go testing is built-in
|
|
74
|
+
];
|
|
75
|
+
for (const m of manifests) {
|
|
76
|
+
const p = join(rootDir, m.file);
|
|
77
|
+
if (existsSync(p)) {
|
|
78
|
+
if (m.file === "go.mod") return true;
|
|
79
|
+
try {
|
|
80
|
+
const content = readFileSync(p, "utf-8");
|
|
81
|
+
if (m.deps.some((d) => content.includes(d))) return true;
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
var TEST_PATTERNS = [
|
|
89
|
+
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
90
|
+
/_test\.go$/,
|
|
91
|
+
/test_.*\.py$/,
|
|
92
|
+
/.*_test\.py$/,
|
|
93
|
+
/.*Test\.java$/,
|
|
94
|
+
/.*Tests\.cs$/,
|
|
95
|
+
/__tests__\//,
|
|
96
|
+
/\/tests?\//,
|
|
97
|
+
/\/e2e\//,
|
|
98
|
+
/\/fixtures\//
|
|
99
|
+
];
|
|
100
|
+
function isTestFile(filePath, extra) {
|
|
101
|
+
if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
|
|
102
|
+
if (extra) return extra.some((p) => filePath.includes(p));
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
async function analyzeTestability(options) {
|
|
106
|
+
const allFiles = await scanFiles({
|
|
107
|
+
...options,
|
|
108
|
+
include: options.include || ["**/*.{ts,tsx,js,jsx,py,java,cs,go}"],
|
|
109
|
+
includeTests: true
|
|
110
|
+
});
|
|
111
|
+
const sourceFiles = allFiles.filter(
|
|
112
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
113
|
+
);
|
|
114
|
+
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
115
|
+
const aggregated = {
|
|
116
|
+
pureFunctions: 0,
|
|
117
|
+
totalFunctions: 0,
|
|
118
|
+
injectionPatterns: 0,
|
|
119
|
+
totalClasses: 0,
|
|
120
|
+
bloatedInterfaces: 0,
|
|
121
|
+
totalInterfaces: 0,
|
|
122
|
+
externalStateMutations: 0
|
|
123
|
+
};
|
|
124
|
+
let processed = 0;
|
|
125
|
+
for (const f of sourceFiles) {
|
|
126
|
+
processed++;
|
|
127
|
+
emitProgress(
|
|
128
|
+
processed,
|
|
129
|
+
sourceFiles.length,
|
|
130
|
+
"testability",
|
|
131
|
+
"analyzing files",
|
|
132
|
+
options.onProgress
|
|
133
|
+
);
|
|
134
|
+
const a = await analyzeFileTestability(f);
|
|
135
|
+
for (const key of Object.keys(aggregated)) {
|
|
136
|
+
aggregated[key] += a[key];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const hasTestFramework = detectTestFramework(options.rootDir);
|
|
140
|
+
const indexResult = calculateTestabilityIndex({
|
|
141
|
+
testFiles: testFiles.length,
|
|
142
|
+
sourceFiles: sourceFiles.length,
|
|
143
|
+
pureFunctions: aggregated.pureFunctions,
|
|
144
|
+
totalFunctions: Math.max(1, aggregated.totalFunctions),
|
|
145
|
+
injectionPatterns: aggregated.injectionPatterns,
|
|
146
|
+
totalClasses: Math.max(1, aggregated.totalClasses),
|
|
147
|
+
bloatedInterfaces: aggregated.bloatedInterfaces,
|
|
148
|
+
totalInterfaces: Math.max(1, aggregated.totalInterfaces),
|
|
149
|
+
externalStateMutations: aggregated.externalStateMutations,
|
|
150
|
+
hasTestFramework
|
|
151
|
+
});
|
|
152
|
+
const issues = [];
|
|
153
|
+
const minCoverage = options.minCoverageRatio ?? 0.3;
|
|
154
|
+
const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
155
|
+
if (!hasTestFramework) {
|
|
156
|
+
issues.push({
|
|
157
|
+
type: IssueType.LowTestability,
|
|
158
|
+
dimension: "framework",
|
|
159
|
+
severity: Severity.Critical,
|
|
160
|
+
message: "No major testing framework detected \u2014 AI changes cannot be safely verified.",
|
|
161
|
+
location: { file: options.rootDir, line: 0 },
|
|
162
|
+
suggestion: "Add a testing framework (e.g., Jest, Pytest, JUnit) to enable automated verification."
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (actualRatio < minCoverage) {
|
|
166
|
+
const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
167
|
+
issues.push({
|
|
168
|
+
type: IssueType.LowTestability,
|
|
169
|
+
dimension: "test-coverage",
|
|
170
|
+
severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
|
|
171
|
+
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
172
|
+
location: { file: options.rootDir, line: 0 },
|
|
173
|
+
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (indexResult.dimensions.purityScore < 50) {
|
|
177
|
+
issues.push({
|
|
178
|
+
type: IssueType.LowTestability,
|
|
179
|
+
dimension: "purity",
|
|
180
|
+
severity: Severity.Major,
|
|
181
|
+
message: `Only ${indexResult.dimensions.purityScore}% of functions appear pure \u2014 side-effectful code is harder for AI to verify safely.`,
|
|
182
|
+
location: { file: options.rootDir, line: 0 },
|
|
183
|
+
suggestion: "Refactor complex side-effectful logic into pure functions where possible."
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
summary: {
|
|
188
|
+
sourceFiles: sourceFiles.length,
|
|
189
|
+
testFiles: testFiles.length,
|
|
190
|
+
coverageRatio: Math.round(actualRatio * 100) / 100,
|
|
191
|
+
score: indexResult.score,
|
|
192
|
+
rating: indexResult.rating,
|
|
193
|
+
aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
|
|
194
|
+
dimensions: indexResult.dimensions
|
|
195
|
+
},
|
|
196
|
+
issues,
|
|
197
|
+
rawData: {
|
|
198
|
+
sourceFiles: sourceFiles.length,
|
|
199
|
+
testFiles: testFiles.length,
|
|
200
|
+
...aggregated,
|
|
201
|
+
hasTestFramework
|
|
202
|
+
},
|
|
203
|
+
recommendations: indexResult.recommendations
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/scoring.ts
|
|
208
|
+
import { ToolName } from "@aiready/core";
|
|
209
|
+
function calculateTestabilityScore(report) {
|
|
210
|
+
const { summary, rawData, recommendations } = report;
|
|
211
|
+
const factors = [
|
|
212
|
+
{
|
|
213
|
+
name: "Test Coverage",
|
|
214
|
+
impact: Math.round(summary.dimensions.testCoverageRatio - 50),
|
|
215
|
+
description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "Function Purity",
|
|
219
|
+
impact: Math.round(summary.dimensions.purityScore - 50),
|
|
220
|
+
description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "Dependency Injection",
|
|
224
|
+
impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
|
|
225
|
+
description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "Interface Focus",
|
|
229
|
+
impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
|
|
230
|
+
description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "Observability",
|
|
234
|
+
impact: Math.round(summary.dimensions.observabilityScore - 50),
|
|
235
|
+
description: `${rawData.externalStateMutations} functions mutate external state`
|
|
236
|
+
}
|
|
237
|
+
];
|
|
238
|
+
const recs = recommendations.map(
|
|
239
|
+
(action) => ({
|
|
240
|
+
action,
|
|
241
|
+
estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
|
|
242
|
+
priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
return {
|
|
246
|
+
toolName: ToolName.TestabilityIndex,
|
|
247
|
+
score: summary.score,
|
|
248
|
+
rawMetrics: {
|
|
249
|
+
...rawData,
|
|
250
|
+
rating: summary.rating,
|
|
251
|
+
aiChangeSafetyRating: summary.aiChangeSafetyRating,
|
|
252
|
+
coverageRatio: summary.coverageRatio
|
|
253
|
+
},
|
|
254
|
+
factors,
|
|
255
|
+
recommendations: recs
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export {
|
|
260
|
+
analyzeTestability,
|
|
261
|
+
calculateTestabilityScore
|
|
262
|
+
};
|