@aiready/testability 0.2.2 → 0.2.3
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 +9 -9
- package/.turbo/turbo-test.log +4 -4
- package/dist/chunk-7MM2QISW.mjs +333 -0
- package/dist/chunk-QIOGSXKY.mjs +333 -0
- package/dist/cli.js +8 -8
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -8
- package/dist/index.mjs +1 -1
- package/package.json +2 -2
- package/src/analyzer.ts +9 -9
- package/src/types.ts +2 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/testability@0.2.
|
|
3
|
+
> @aiready/testability@0.2.2 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
|
-
[32mCJS[39m [1mdist/
|
|
13
|
-
[32mCJS[39m [1mdist/
|
|
14
|
-
[32mCJS[39m ⚡️ Build success in
|
|
12
|
+
[32mCJS[39m [1mdist/index.js [22m[32m12.28 KB[39m
|
|
13
|
+
[32mCJS[39m [1mdist/cli.js [22m[32m18.38 KB[39m
|
|
14
|
+
[32mCJS[39m ⚡️ Build success in 38ms
|
|
15
|
+
[32mESM[39m [1mdist/chunk-7MM2QISW.mjs [22m[32m11.02 KB[39m
|
|
15
16
|
[32mESM[39m [1mdist/index.mjs [22m[32m152.00 B[39m
|
|
16
17
|
[32mESM[39m [1mdist/cli.mjs [22m[32m5.75 KB[39m
|
|
17
|
-
[32mESM[39m
|
|
18
|
-
[32mESM[39m ⚡️ Build success in 47ms
|
|
18
|
+
[32mESM[39m ⚡️ Build success in 38ms
|
|
19
19
|
DTS Build start
|
|
20
|
-
DTS ⚡️ Build success in
|
|
20
|
+
DTS ⚡️ Build success in 2233ms
|
|
21
21
|
DTS dist/cli.d.ts 20.00 B
|
|
22
|
-
DTS dist/index.d.ts 2.
|
|
22
|
+
DTS dist/index.d.ts 2.66 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.66 KB
|
package/.turbo/turbo-test.log
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/testability@0.2.
|
|
3
|
+
> @aiready/testability@0.2.2 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 217[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 20:
|
|
14
|
-
[2m Duration [22m
|
|
13
|
+
[2m Start at [22m 20:01:55
|
|
14
|
+
[2m Duration [22m 2.14s[2m (transform 162ms, setup 0ms, import 1.21s, tests 217ms, environment 0ms)[22m
|
|
15
15
|
|
|
16
16
|
[?25h
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// src/analyzer.ts
|
|
2
|
+
import { scanFiles, calculateTestabilityIndex, Severity, IssueType } from "@aiready/core";
|
|
3
|
+
import { readFileSync, existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
6
|
+
function countMethodsInInterface(node) {
|
|
7
|
+
if (node.type === "TSInterfaceDeclaration") {
|
|
8
|
+
return node.body.body.filter(
|
|
9
|
+
(m) => m.type === "TSMethodSignature" || m.type === "TSPropertySignature"
|
|
10
|
+
).length;
|
|
11
|
+
}
|
|
12
|
+
if (node.type === "TSTypeAliasDeclaration" && node.typeAnnotation.type === "TSTypeLiteral") {
|
|
13
|
+
return node.typeAnnotation.members.length;
|
|
14
|
+
}
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
function hasDependencyInjection(node) {
|
|
18
|
+
for (const member of node.body.body) {
|
|
19
|
+
if (member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "constructor") {
|
|
20
|
+
const fn = member.value;
|
|
21
|
+
if (fn.params && fn.params.length > 0) {
|
|
22
|
+
const typedParams = fn.params.filter((p) => {
|
|
23
|
+
const param = p;
|
|
24
|
+
return param.typeAnnotation != null || param.parameter?.typeAnnotation != null;
|
|
25
|
+
});
|
|
26
|
+
if (typedParams.length > 0) return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function isPureFunction(fn) {
|
|
33
|
+
let hasReturn = false;
|
|
34
|
+
let hasSideEffect = false;
|
|
35
|
+
function walk(node) {
|
|
36
|
+
if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
|
|
37
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
38
|
+
hasSideEffect = true;
|
|
39
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(
|
|
40
|
+
node.callee.object.name
|
|
41
|
+
))
|
|
42
|
+
hasSideEffect = true;
|
|
43
|
+
for (const key of Object.keys(node)) {
|
|
44
|
+
if (key === "parent") continue;
|
|
45
|
+
const child = node[key];
|
|
46
|
+
if (child && typeof child === "object") {
|
|
47
|
+
if (Array.isArray(child)) {
|
|
48
|
+
child.forEach((c) => c?.type && walk(c));
|
|
49
|
+
} else if (child.type) {
|
|
50
|
+
walk(child);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (fn.body?.type === "BlockStatement") {
|
|
56
|
+
fn.body.body.forEach((s) => walk(s));
|
|
57
|
+
} else if (fn.body) {
|
|
58
|
+
hasReturn = true;
|
|
59
|
+
}
|
|
60
|
+
return hasReturn && !hasSideEffect;
|
|
61
|
+
}
|
|
62
|
+
function hasExternalStateMutation(fn) {
|
|
63
|
+
let found = false;
|
|
64
|
+
function walk(node) {
|
|
65
|
+
if (found) return;
|
|
66
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
67
|
+
found = true;
|
|
68
|
+
for (const key of Object.keys(node)) {
|
|
69
|
+
if (key === "parent") continue;
|
|
70
|
+
const child = node[key];
|
|
71
|
+
if (child && typeof child === "object") {
|
|
72
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && walk(c));
|
|
73
|
+
else if (child.type) walk(child);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (fn.body?.type === "BlockStatement") fn.body.body.forEach((s) => walk(s));
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
function analyzeFileTestability(filePath) {
|
|
81
|
+
const result = {
|
|
82
|
+
pureFunctions: 0,
|
|
83
|
+
totalFunctions: 0,
|
|
84
|
+
injectionPatterns: 0,
|
|
85
|
+
totalClasses: 0,
|
|
86
|
+
bloatedInterfaces: 0,
|
|
87
|
+
totalInterfaces: 0,
|
|
88
|
+
externalStateMutations: 0
|
|
89
|
+
};
|
|
90
|
+
let code;
|
|
91
|
+
try {
|
|
92
|
+
code = readFileSync(filePath, "utf-8");
|
|
93
|
+
} catch {
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
let ast;
|
|
97
|
+
try {
|
|
98
|
+
ast = parse(code, {
|
|
99
|
+
jsx: filePath.endsWith(".tsx") || filePath.endsWith(".jsx"),
|
|
100
|
+
range: false,
|
|
101
|
+
loc: false
|
|
102
|
+
});
|
|
103
|
+
} catch {
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
function visit(node) {
|
|
107
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
|
|
108
|
+
result.totalFunctions++;
|
|
109
|
+
if (isPureFunction(node)) result.pureFunctions++;
|
|
110
|
+
if (hasExternalStateMutation(node)) result.externalStateMutations++;
|
|
111
|
+
}
|
|
112
|
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
|
|
113
|
+
result.totalClasses++;
|
|
114
|
+
if (hasDependencyInjection(node)) result.injectionPatterns++;
|
|
115
|
+
}
|
|
116
|
+
if (node.type === "TSInterfaceDeclaration" || node.type === "TSTypeAliasDeclaration") {
|
|
117
|
+
result.totalInterfaces++;
|
|
118
|
+
const methodCount = countMethodsInInterface(node);
|
|
119
|
+
if (methodCount > 10) result.bloatedInterfaces++;
|
|
120
|
+
}
|
|
121
|
+
for (const key of Object.keys(node)) {
|
|
122
|
+
if (key === "parent") continue;
|
|
123
|
+
const child = node[key];
|
|
124
|
+
if (child && typeof child === "object") {
|
|
125
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && visit(c));
|
|
126
|
+
else if (child.type) visit(child);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
ast.body.forEach(visit);
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
function detectTestFramework(rootDir) {
|
|
134
|
+
const pkgPath = join(rootDir, "package.json");
|
|
135
|
+
if (!existsSync(pkgPath)) return false;
|
|
136
|
+
try {
|
|
137
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
138
|
+
const allDeps = {
|
|
139
|
+
...pkg.dependencies ?? {},
|
|
140
|
+
...pkg.devDependencies ?? {}
|
|
141
|
+
};
|
|
142
|
+
const testFrameworks = [
|
|
143
|
+
"jest",
|
|
144
|
+
"vitest",
|
|
145
|
+
"mocha",
|
|
146
|
+
"jasmine",
|
|
147
|
+
"ava",
|
|
148
|
+
"tap",
|
|
149
|
+
"pytest",
|
|
150
|
+
"unittest"
|
|
151
|
+
];
|
|
152
|
+
return testFrameworks.some((fw) => allDeps[fw]);
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
var TEST_PATTERNS = [
|
|
158
|
+
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
159
|
+
/__tests__\//,
|
|
160
|
+
/\/tests?\//,
|
|
161
|
+
/\/e2e\//,
|
|
162
|
+
/\/fixtures\//
|
|
163
|
+
];
|
|
164
|
+
function isTestFile(filePath, extra) {
|
|
165
|
+
if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
|
|
166
|
+
if (extra) return extra.some((p) => filePath.includes(p));
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
async function analyzeTestability(options) {
|
|
170
|
+
const allFiles = await scanFiles({
|
|
171
|
+
...options,
|
|
172
|
+
include: options.include || ["**/*.{ts,tsx,js,jsx}"],
|
|
173
|
+
includeTests: true
|
|
174
|
+
});
|
|
175
|
+
const sourceFiles = allFiles.filter(
|
|
176
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
177
|
+
);
|
|
178
|
+
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
179
|
+
const aggregated = {
|
|
180
|
+
pureFunctions: 0,
|
|
181
|
+
totalFunctions: 0,
|
|
182
|
+
injectionPatterns: 0,
|
|
183
|
+
totalClasses: 0,
|
|
184
|
+
bloatedInterfaces: 0,
|
|
185
|
+
totalInterfaces: 0,
|
|
186
|
+
externalStateMutations: 0
|
|
187
|
+
};
|
|
188
|
+
let processed = 0;
|
|
189
|
+
for (const f of sourceFiles) {
|
|
190
|
+
processed++;
|
|
191
|
+
options.onProgress?.(
|
|
192
|
+
processed,
|
|
193
|
+
sourceFiles.length,
|
|
194
|
+
`testability: analyzing files`
|
|
195
|
+
);
|
|
196
|
+
const a = analyzeFileTestability(f);
|
|
197
|
+
for (const key of Object.keys(aggregated)) {
|
|
198
|
+
aggregated[key] += a[key];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const hasTestFramework = detectTestFramework(options.rootDir);
|
|
202
|
+
const indexResult = calculateTestabilityIndex({
|
|
203
|
+
testFiles: testFiles.length,
|
|
204
|
+
sourceFiles: sourceFiles.length,
|
|
205
|
+
pureFunctions: aggregated.pureFunctions,
|
|
206
|
+
totalFunctions: Math.max(1, aggregated.totalFunctions),
|
|
207
|
+
injectionPatterns: aggregated.injectionPatterns,
|
|
208
|
+
totalClasses: Math.max(1, aggregated.totalClasses),
|
|
209
|
+
bloatedInterfaces: aggregated.bloatedInterfaces,
|
|
210
|
+
totalInterfaces: Math.max(1, aggregated.totalInterfaces),
|
|
211
|
+
externalStateMutations: aggregated.externalStateMutations,
|
|
212
|
+
hasTestFramework
|
|
213
|
+
});
|
|
214
|
+
const issues = [];
|
|
215
|
+
const minCoverage = options.minCoverageRatio ?? 0.3;
|
|
216
|
+
const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
217
|
+
if (!hasTestFramework) {
|
|
218
|
+
issues.push({
|
|
219
|
+
type: IssueType.LowTestability,
|
|
220
|
+
dimension: "framework",
|
|
221
|
+
severity: Severity.Critical,
|
|
222
|
+
message: "No testing framework detected in package.json \u2014 AI changes cannot be verified at all.",
|
|
223
|
+
location: { file: options.rootDir, line: 0 },
|
|
224
|
+
suggestion: "Add Jest, Vitest, or another testing framework as a devDependency."
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (actualRatio < minCoverage) {
|
|
228
|
+
const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
229
|
+
issues.push({
|
|
230
|
+
type: IssueType.LowTestability,
|
|
231
|
+
dimension: "test-coverage",
|
|
232
|
+
severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
|
|
233
|
+
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
234
|
+
location: { file: options.rootDir, line: 0 },
|
|
235
|
+
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (indexResult.dimensions.purityScore < 50) {
|
|
239
|
+
issues.push({
|
|
240
|
+
type: IssueType.LowTestability,
|
|
241
|
+
dimension: "purity",
|
|
242
|
+
severity: Severity.Major,
|
|
243
|
+
message: `Only ${indexResult.dimensions.purityScore}% of functions are pure \u2014 side-effectful functions require complex test setup.`,
|
|
244
|
+
location: { file: options.rootDir, line: 0 },
|
|
245
|
+
suggestion: "Extract pure transformation logic from I/O and mutation code."
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (indexResult.dimensions.observabilityScore < 50) {
|
|
249
|
+
issues.push({
|
|
250
|
+
type: IssueType.LowTestability,
|
|
251
|
+
dimension: "observability",
|
|
252
|
+
severity: Severity.Major,
|
|
253
|
+
message: `Many functions mutate external state directly \u2014 outputs are invisible to unit tests.`,
|
|
254
|
+
location: { file: options.rootDir, line: 0 },
|
|
255
|
+
suggestion: "Prefer returning values over mutating shared state."
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
summary: {
|
|
260
|
+
sourceFiles: sourceFiles.length,
|
|
261
|
+
testFiles: testFiles.length,
|
|
262
|
+
coverageRatio: Math.round(actualRatio * 100) / 100,
|
|
263
|
+
score: indexResult.score,
|
|
264
|
+
rating: indexResult.rating,
|
|
265
|
+
aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
|
|
266
|
+
dimensions: indexResult.dimensions
|
|
267
|
+
},
|
|
268
|
+
issues,
|
|
269
|
+
rawData: {
|
|
270
|
+
sourceFiles: sourceFiles.length,
|
|
271
|
+
testFiles: testFiles.length,
|
|
272
|
+
...aggregated,
|
|
273
|
+
hasTestFramework
|
|
274
|
+
},
|
|
275
|
+
recommendations: indexResult.recommendations
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/scoring.ts
|
|
280
|
+
function calculateTestabilityScore(report) {
|
|
281
|
+
const { summary, rawData, recommendations } = report;
|
|
282
|
+
const factors = [
|
|
283
|
+
{
|
|
284
|
+
name: "Test Coverage",
|
|
285
|
+
impact: Math.round(summary.dimensions.testCoverageRatio - 50),
|
|
286
|
+
description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "Function Purity",
|
|
290
|
+
impact: Math.round(summary.dimensions.purityScore - 50),
|
|
291
|
+
description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "Dependency Injection",
|
|
295
|
+
impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
|
|
296
|
+
description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "Interface Focus",
|
|
300
|
+
impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
|
|
301
|
+
description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "Observability",
|
|
305
|
+
impact: Math.round(summary.dimensions.observabilityScore - 50),
|
|
306
|
+
description: `${rawData.externalStateMutations} functions mutate external state`
|
|
307
|
+
}
|
|
308
|
+
];
|
|
309
|
+
const recs = recommendations.map(
|
|
310
|
+
(action) => ({
|
|
311
|
+
action,
|
|
312
|
+
estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
|
|
313
|
+
priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
return {
|
|
317
|
+
toolName: "testability",
|
|
318
|
+
score: summary.score,
|
|
319
|
+
rawMetrics: {
|
|
320
|
+
...rawData,
|
|
321
|
+
rating: summary.rating,
|
|
322
|
+
aiChangeSafetyRating: summary.aiChangeSafetyRating,
|
|
323
|
+
coverageRatio: summary.coverageRatio
|
|
324
|
+
},
|
|
325
|
+
factors,
|
|
326
|
+
recommendations: recs
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export {
|
|
331
|
+
analyzeTestability,
|
|
332
|
+
calculateTestabilityScore
|
|
333
|
+
};
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// src/analyzer.ts
|
|
2
|
+
import { scanFiles, calculateTestabilityIndex, Severity } from "@aiready/core";
|
|
3
|
+
import { readFileSync, existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
6
|
+
function countMethodsInInterface(node) {
|
|
7
|
+
if (node.type === "TSInterfaceDeclaration") {
|
|
8
|
+
return node.body.body.filter(
|
|
9
|
+
(m) => m.type === "TSMethodSignature" || m.type === "TSPropertySignature"
|
|
10
|
+
).length;
|
|
11
|
+
}
|
|
12
|
+
if (node.type === "TSTypeAliasDeclaration" && node.typeAnnotation.type === "TSTypeLiteral") {
|
|
13
|
+
return node.typeAnnotation.members.length;
|
|
14
|
+
}
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
function hasDependencyInjection(node) {
|
|
18
|
+
for (const member of node.body.body) {
|
|
19
|
+
if (member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "constructor") {
|
|
20
|
+
const fn = member.value;
|
|
21
|
+
if (fn.params && fn.params.length > 0) {
|
|
22
|
+
const typedParams = fn.params.filter((p) => {
|
|
23
|
+
const param = p;
|
|
24
|
+
return param.typeAnnotation != null || param.parameter?.typeAnnotation != null;
|
|
25
|
+
});
|
|
26
|
+
if (typedParams.length > 0) return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function isPureFunction(fn) {
|
|
33
|
+
let hasReturn = false;
|
|
34
|
+
let hasSideEffect = false;
|
|
35
|
+
function walk(node) {
|
|
36
|
+
if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
|
|
37
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
38
|
+
hasSideEffect = true;
|
|
39
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(
|
|
40
|
+
node.callee.object.name
|
|
41
|
+
))
|
|
42
|
+
hasSideEffect = true;
|
|
43
|
+
for (const key of Object.keys(node)) {
|
|
44
|
+
if (key === "parent") continue;
|
|
45
|
+
const child = node[key];
|
|
46
|
+
if (child && typeof child === "object") {
|
|
47
|
+
if (Array.isArray(child)) {
|
|
48
|
+
child.forEach((c) => c?.type && walk(c));
|
|
49
|
+
} else if (child.type) {
|
|
50
|
+
walk(child);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (fn.body?.type === "BlockStatement") {
|
|
56
|
+
fn.body.body.forEach((s) => walk(s));
|
|
57
|
+
} else if (fn.body) {
|
|
58
|
+
hasReturn = true;
|
|
59
|
+
}
|
|
60
|
+
return hasReturn && !hasSideEffect;
|
|
61
|
+
}
|
|
62
|
+
function hasExternalStateMutation(fn) {
|
|
63
|
+
let found = false;
|
|
64
|
+
function walk(node) {
|
|
65
|
+
if (found) return;
|
|
66
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
67
|
+
found = true;
|
|
68
|
+
for (const key of Object.keys(node)) {
|
|
69
|
+
if (key === "parent") continue;
|
|
70
|
+
const child = node[key];
|
|
71
|
+
if (child && typeof child === "object") {
|
|
72
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && walk(c));
|
|
73
|
+
else if (child.type) walk(child);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (fn.body?.type === "BlockStatement") fn.body.body.forEach((s) => walk(s));
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
function analyzeFileTestability(filePath) {
|
|
81
|
+
const result = {
|
|
82
|
+
pureFunctions: 0,
|
|
83
|
+
totalFunctions: 0,
|
|
84
|
+
injectionPatterns: 0,
|
|
85
|
+
totalClasses: 0,
|
|
86
|
+
bloatedInterfaces: 0,
|
|
87
|
+
totalInterfaces: 0,
|
|
88
|
+
externalStateMutations: 0
|
|
89
|
+
};
|
|
90
|
+
let code;
|
|
91
|
+
try {
|
|
92
|
+
code = readFileSync(filePath, "utf-8");
|
|
93
|
+
} catch {
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
let ast;
|
|
97
|
+
try {
|
|
98
|
+
ast = parse(code, {
|
|
99
|
+
jsx: filePath.endsWith(".tsx") || filePath.endsWith(".jsx"),
|
|
100
|
+
range: false,
|
|
101
|
+
loc: false
|
|
102
|
+
});
|
|
103
|
+
} catch {
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
function visit(node) {
|
|
107
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
|
|
108
|
+
result.totalFunctions++;
|
|
109
|
+
if (isPureFunction(node)) result.pureFunctions++;
|
|
110
|
+
if (hasExternalStateMutation(node)) result.externalStateMutations++;
|
|
111
|
+
}
|
|
112
|
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
|
|
113
|
+
result.totalClasses++;
|
|
114
|
+
if (hasDependencyInjection(node)) result.injectionPatterns++;
|
|
115
|
+
}
|
|
116
|
+
if (node.type === "TSInterfaceDeclaration" || node.type === "TSTypeAliasDeclaration") {
|
|
117
|
+
result.totalInterfaces++;
|
|
118
|
+
const methodCount = countMethodsInInterface(node);
|
|
119
|
+
if (methodCount > 10) result.bloatedInterfaces++;
|
|
120
|
+
}
|
|
121
|
+
for (const key of Object.keys(node)) {
|
|
122
|
+
if (key === "parent") continue;
|
|
123
|
+
const child = node[key];
|
|
124
|
+
if (child && typeof child === "object") {
|
|
125
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && visit(c));
|
|
126
|
+
else if (child.type) visit(child);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
ast.body.forEach(visit);
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
function detectTestFramework(rootDir) {
|
|
134
|
+
const pkgPath = join(rootDir, "package.json");
|
|
135
|
+
if (!existsSync(pkgPath)) return false;
|
|
136
|
+
try {
|
|
137
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
138
|
+
const allDeps = {
|
|
139
|
+
...pkg.dependencies ?? {},
|
|
140
|
+
...pkg.devDependencies ?? {}
|
|
141
|
+
};
|
|
142
|
+
const testFrameworks = [
|
|
143
|
+
"jest",
|
|
144
|
+
"vitest",
|
|
145
|
+
"mocha",
|
|
146
|
+
"jasmine",
|
|
147
|
+
"ava",
|
|
148
|
+
"tap",
|
|
149
|
+
"pytest",
|
|
150
|
+
"unittest"
|
|
151
|
+
];
|
|
152
|
+
return testFrameworks.some((fw) => allDeps[fw]);
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
var TEST_PATTERNS = [
|
|
158
|
+
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
159
|
+
/__tests__\//,
|
|
160
|
+
/\/tests?\//,
|
|
161
|
+
/\/e2e\//,
|
|
162
|
+
/\/fixtures\//
|
|
163
|
+
];
|
|
164
|
+
function isTestFile(filePath, extra) {
|
|
165
|
+
if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
|
|
166
|
+
if (extra) return extra.some((p) => filePath.includes(p));
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
async function analyzeTestability(options) {
|
|
170
|
+
const allFiles = await scanFiles({
|
|
171
|
+
...options,
|
|
172
|
+
include: options.include || ["**/*.{ts,tsx,js,jsx}"],
|
|
173
|
+
includeTests: true
|
|
174
|
+
});
|
|
175
|
+
const sourceFiles = allFiles.filter(
|
|
176
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
177
|
+
);
|
|
178
|
+
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
179
|
+
const aggregated = {
|
|
180
|
+
pureFunctions: 0,
|
|
181
|
+
totalFunctions: 0,
|
|
182
|
+
injectionPatterns: 0,
|
|
183
|
+
totalClasses: 0,
|
|
184
|
+
bloatedInterfaces: 0,
|
|
185
|
+
totalInterfaces: 0,
|
|
186
|
+
externalStateMutations: 0
|
|
187
|
+
};
|
|
188
|
+
let processed = 0;
|
|
189
|
+
for (const f of sourceFiles) {
|
|
190
|
+
processed++;
|
|
191
|
+
options.onProgress?.(
|
|
192
|
+
processed,
|
|
193
|
+
sourceFiles.length,
|
|
194
|
+
`testability: analyzing files`
|
|
195
|
+
);
|
|
196
|
+
const a = analyzeFileTestability(f);
|
|
197
|
+
for (const key of Object.keys(aggregated)) {
|
|
198
|
+
aggregated[key] += a[key];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const hasTestFramework = detectTestFramework(options.rootDir);
|
|
202
|
+
const indexResult = calculateTestabilityIndex({
|
|
203
|
+
testFiles: testFiles.length,
|
|
204
|
+
sourceFiles: sourceFiles.length,
|
|
205
|
+
pureFunctions: aggregated.pureFunctions,
|
|
206
|
+
totalFunctions: Math.max(1, aggregated.totalFunctions),
|
|
207
|
+
injectionPatterns: aggregated.injectionPatterns,
|
|
208
|
+
totalClasses: Math.max(1, aggregated.totalClasses),
|
|
209
|
+
bloatedInterfaces: aggregated.bloatedInterfaces,
|
|
210
|
+
totalInterfaces: Math.max(1, aggregated.totalInterfaces),
|
|
211
|
+
externalStateMutations: aggregated.externalStateMutations,
|
|
212
|
+
hasTestFramework
|
|
213
|
+
});
|
|
214
|
+
const issues = [];
|
|
215
|
+
const minCoverage = options.minCoverageRatio ?? 0.3;
|
|
216
|
+
const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
217
|
+
if (!hasTestFramework) {
|
|
218
|
+
issues.push({
|
|
219
|
+
type: "low-testability",
|
|
220
|
+
dimension: "framework",
|
|
221
|
+
severity: Severity.Critical,
|
|
222
|
+
message: "No testing framework detected in package.json \u2014 AI changes cannot be verified at all.",
|
|
223
|
+
location: { file: options.rootDir, line: 0 },
|
|
224
|
+
suggestion: "Add Jest, Vitest, or another testing framework as a devDependency."
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (actualRatio < minCoverage) {
|
|
228
|
+
const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
229
|
+
issues.push({
|
|
230
|
+
type: "low-testability",
|
|
231
|
+
dimension: "test-coverage",
|
|
232
|
+
severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
|
|
233
|
+
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
234
|
+
location: { file: options.rootDir, line: 0 },
|
|
235
|
+
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (indexResult.dimensions.purityScore < 50) {
|
|
239
|
+
issues.push({
|
|
240
|
+
type: "low-testability",
|
|
241
|
+
dimension: "purity",
|
|
242
|
+
severity: Severity.Major,
|
|
243
|
+
message: `Only ${indexResult.dimensions.purityScore}% of functions are pure \u2014 side-effectful functions require complex test setup.`,
|
|
244
|
+
location: { file: options.rootDir, line: 0 },
|
|
245
|
+
suggestion: "Extract pure transformation logic from I/O and mutation code."
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (indexResult.dimensions.observabilityScore < 50) {
|
|
249
|
+
issues.push({
|
|
250
|
+
type: "low-testability",
|
|
251
|
+
dimension: "observability",
|
|
252
|
+
severity: Severity.Major,
|
|
253
|
+
message: `Many functions mutate external state directly \u2014 outputs are invisible to unit tests.`,
|
|
254
|
+
location: { file: options.rootDir, line: 0 },
|
|
255
|
+
suggestion: "Prefer returning values over mutating shared state."
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
summary: {
|
|
260
|
+
sourceFiles: sourceFiles.length,
|
|
261
|
+
testFiles: testFiles.length,
|
|
262
|
+
coverageRatio: Math.round(actualRatio * 100) / 100,
|
|
263
|
+
score: indexResult.score,
|
|
264
|
+
rating: indexResult.rating,
|
|
265
|
+
aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
|
|
266
|
+
dimensions: indexResult.dimensions
|
|
267
|
+
},
|
|
268
|
+
issues,
|
|
269
|
+
rawData: {
|
|
270
|
+
sourceFiles: sourceFiles.length,
|
|
271
|
+
testFiles: testFiles.length,
|
|
272
|
+
...aggregated,
|
|
273
|
+
hasTestFramework
|
|
274
|
+
},
|
|
275
|
+
recommendations: indexResult.recommendations
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/scoring.ts
|
|
280
|
+
function calculateTestabilityScore(report) {
|
|
281
|
+
const { summary, rawData, recommendations } = report;
|
|
282
|
+
const factors = [
|
|
283
|
+
{
|
|
284
|
+
name: "Test Coverage",
|
|
285
|
+
impact: Math.round(summary.dimensions.testCoverageRatio - 50),
|
|
286
|
+
description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "Function Purity",
|
|
290
|
+
impact: Math.round(summary.dimensions.purityScore - 50),
|
|
291
|
+
description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "Dependency Injection",
|
|
295
|
+
impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
|
|
296
|
+
description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "Interface Focus",
|
|
300
|
+
impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
|
|
301
|
+
description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "Observability",
|
|
305
|
+
impact: Math.round(summary.dimensions.observabilityScore - 50),
|
|
306
|
+
description: `${rawData.externalStateMutations} functions mutate external state`
|
|
307
|
+
}
|
|
308
|
+
];
|
|
309
|
+
const recs = recommendations.map(
|
|
310
|
+
(action) => ({
|
|
311
|
+
action,
|
|
312
|
+
estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
|
|
313
|
+
priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
return {
|
|
317
|
+
toolName: "testability",
|
|
318
|
+
score: summary.score,
|
|
319
|
+
rawMetrics: {
|
|
320
|
+
...rawData,
|
|
321
|
+
rating: summary.rating,
|
|
322
|
+
aiChangeSafetyRating: summary.aiChangeSafetyRating,
|
|
323
|
+
coverageRatio: summary.coverageRatio
|
|
324
|
+
},
|
|
325
|
+
factors,
|
|
326
|
+
recommendations: recs
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export {
|
|
331
|
+
analyzeTestability,
|
|
332
|
+
calculateTestabilityScore
|
|
333
|
+
};
|
package/dist/cli.js
CHANGED
|
@@ -244,9 +244,9 @@ async function analyzeTestability(options) {
|
|
|
244
244
|
const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
245
245
|
if (!hasTestFramework) {
|
|
246
246
|
issues.push({
|
|
247
|
-
type:
|
|
247
|
+
type: import_core.IssueType.LowTestability,
|
|
248
248
|
dimension: "framework",
|
|
249
|
-
severity:
|
|
249
|
+
severity: import_core.Severity.Critical,
|
|
250
250
|
message: "No testing framework detected in package.json \u2014 AI changes cannot be verified at all.",
|
|
251
251
|
location: { file: options.rootDir, line: 0 },
|
|
252
252
|
suggestion: "Add Jest, Vitest, or another testing framework as a devDependency."
|
|
@@ -255,9 +255,9 @@ async function analyzeTestability(options) {
|
|
|
255
255
|
if (actualRatio < minCoverage) {
|
|
256
256
|
const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
257
257
|
issues.push({
|
|
258
|
-
type:
|
|
258
|
+
type: import_core.IssueType.LowTestability,
|
|
259
259
|
dimension: "test-coverage",
|
|
260
|
-
severity: actualRatio === 0 ?
|
|
260
|
+
severity: actualRatio === 0 ? import_core.Severity.Critical : import_core.Severity.Major,
|
|
261
261
|
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
262
262
|
location: { file: options.rootDir, line: 0 },
|
|
263
263
|
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
|
|
@@ -265,9 +265,9 @@ async function analyzeTestability(options) {
|
|
|
265
265
|
}
|
|
266
266
|
if (indexResult.dimensions.purityScore < 50) {
|
|
267
267
|
issues.push({
|
|
268
|
-
type:
|
|
268
|
+
type: import_core.IssueType.LowTestability,
|
|
269
269
|
dimension: "purity",
|
|
270
|
-
severity:
|
|
270
|
+
severity: import_core.Severity.Major,
|
|
271
271
|
message: `Only ${indexResult.dimensions.purityScore}% of functions are pure \u2014 side-effectful functions require complex test setup.`,
|
|
272
272
|
location: { file: options.rootDir, line: 0 },
|
|
273
273
|
suggestion: "Extract pure transformation logic from I/O and mutation code."
|
|
@@ -275,9 +275,9 @@ async function analyzeTestability(options) {
|
|
|
275
275
|
}
|
|
276
276
|
if (indexResult.dimensions.observabilityScore < 50) {
|
|
277
277
|
issues.push({
|
|
278
|
-
type:
|
|
278
|
+
type: import_core.IssueType.LowTestability,
|
|
279
279
|
dimension: "observability",
|
|
280
|
-
severity:
|
|
280
|
+
severity: import_core.Severity.Major,
|
|
281
281
|
message: `Many functions mutate external state directly \u2014 outputs are invisible to unit tests.`,
|
|
282
282
|
location: { file: options.rootDir, line: 0 },
|
|
283
283
|
suggestion: "Prefer returning values over mutating shared state."
|
package/dist/cli.mjs
CHANGED
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Issue, ToolScoringOutput } from '@aiready/core';
|
|
1
|
+
import { Issue, IssueType, ToolScoringOutput } from '@aiready/core';
|
|
2
2
|
|
|
3
3
|
interface TestabilityOptions {
|
|
4
4
|
/** Root directory to scan */
|
|
@@ -17,7 +17,7 @@ interface TestabilityOptions {
|
|
|
17
17
|
onProgress?: (processed: number, total: number, message: string) => void;
|
|
18
18
|
}
|
|
19
19
|
interface TestabilityIssue extends Issue {
|
|
20
|
-
type:
|
|
20
|
+
type: IssueType.LowTestability;
|
|
21
21
|
/** Category of testability barrier */
|
|
22
22
|
dimension: 'test-coverage' | 'purity' | 'dependency-injection' | 'interface-focus' | 'observability' | 'framework';
|
|
23
23
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Issue, ToolScoringOutput } from '@aiready/core';
|
|
1
|
+
import { Issue, IssueType, ToolScoringOutput } from '@aiready/core';
|
|
2
2
|
|
|
3
3
|
interface TestabilityOptions {
|
|
4
4
|
/** Root directory to scan */
|
|
@@ -17,7 +17,7 @@ interface TestabilityOptions {
|
|
|
17
17
|
onProgress?: (processed: number, total: number, message: string) => void;
|
|
18
18
|
}
|
|
19
19
|
interface TestabilityIssue extends Issue {
|
|
20
|
-
type:
|
|
20
|
+
type: IssueType.LowTestability;
|
|
21
21
|
/** Category of testability barrier */
|
|
22
22
|
dimension: 'test-coverage' | 'purity' | 'dependency-injection' | 'interface-focus' | 'observability' | 'framework';
|
|
23
23
|
}
|
package/dist/index.js
CHANGED
|
@@ -243,9 +243,9 @@ async function analyzeTestability(options) {
|
|
|
243
243
|
const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
244
244
|
if (!hasTestFramework) {
|
|
245
245
|
issues.push({
|
|
246
|
-
type:
|
|
246
|
+
type: import_core.IssueType.LowTestability,
|
|
247
247
|
dimension: "framework",
|
|
248
|
-
severity:
|
|
248
|
+
severity: import_core.Severity.Critical,
|
|
249
249
|
message: "No testing framework detected in package.json \u2014 AI changes cannot be verified at all.",
|
|
250
250
|
location: { file: options.rootDir, line: 0 },
|
|
251
251
|
suggestion: "Add Jest, Vitest, or another testing framework as a devDependency."
|
|
@@ -254,9 +254,9 @@ async function analyzeTestability(options) {
|
|
|
254
254
|
if (actualRatio < minCoverage) {
|
|
255
255
|
const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
256
256
|
issues.push({
|
|
257
|
-
type:
|
|
257
|
+
type: import_core.IssueType.LowTestability,
|
|
258
258
|
dimension: "test-coverage",
|
|
259
|
-
severity: actualRatio === 0 ?
|
|
259
|
+
severity: actualRatio === 0 ? import_core.Severity.Critical : import_core.Severity.Major,
|
|
260
260
|
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
261
261
|
location: { file: options.rootDir, line: 0 },
|
|
262
262
|
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
|
|
@@ -264,9 +264,9 @@ async function analyzeTestability(options) {
|
|
|
264
264
|
}
|
|
265
265
|
if (indexResult.dimensions.purityScore < 50) {
|
|
266
266
|
issues.push({
|
|
267
|
-
type:
|
|
267
|
+
type: import_core.IssueType.LowTestability,
|
|
268
268
|
dimension: "purity",
|
|
269
|
-
severity:
|
|
269
|
+
severity: import_core.Severity.Major,
|
|
270
270
|
message: `Only ${indexResult.dimensions.purityScore}% of functions are pure \u2014 side-effectful functions require complex test setup.`,
|
|
271
271
|
location: { file: options.rootDir, line: 0 },
|
|
272
272
|
suggestion: "Extract pure transformation logic from I/O and mutation code."
|
|
@@ -274,9 +274,9 @@ async function analyzeTestability(options) {
|
|
|
274
274
|
}
|
|
275
275
|
if (indexResult.dimensions.observabilityScore < 50) {
|
|
276
276
|
issues.push({
|
|
277
|
-
type:
|
|
277
|
+
type: import_core.IssueType.LowTestability,
|
|
278
278
|
dimension: "observability",
|
|
279
|
-
severity:
|
|
279
|
+
severity: import_core.Severity.Major,
|
|
280
280
|
message: `Many functions mutate external state directly \u2014 outputs are invisible to unit tests.`,
|
|
281
281
|
location: { file: options.rootDir, line: 0 },
|
|
282
282
|
suggestion: "Prefer returning values over mutating shared state."
|
package/dist/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiready/testability",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"chalk": "^5.3.0",
|
|
41
41
|
"commander": "^14.0.0",
|
|
42
42
|
"glob": "^13.0.0",
|
|
43
|
-
"@aiready/core": "0.19.
|
|
43
|
+
"@aiready/core": "0.19.3"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^24.0.0",
|
package/src/analyzer.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 5. Observability (return values vs. external state mutations)
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { scanFiles, calculateTestabilityIndex } from '@aiready/core';
|
|
13
|
+
import { scanFiles, calculateTestabilityIndex, Severity, IssueType } from '@aiready/core';
|
|
14
14
|
import { readFileSync, existsSync } from 'fs';
|
|
15
15
|
import { join } from 'path';
|
|
16
16
|
import { parse } from '@typescript-eslint/typescript-estree';
|
|
@@ -336,9 +336,9 @@ export async function analyzeTestability(
|
|
|
336
336
|
|
|
337
337
|
if (!hasTestFramework) {
|
|
338
338
|
issues.push({
|
|
339
|
-
type:
|
|
339
|
+
type: IssueType.LowTestability,
|
|
340
340
|
dimension: 'framework',
|
|
341
|
-
severity:
|
|
341
|
+
severity: Severity.Critical,
|
|
342
342
|
message:
|
|
343
343
|
'No testing framework detected in package.json — AI changes cannot be verified at all.',
|
|
344
344
|
location: { file: options.rootDir, line: 0 },
|
|
@@ -351,9 +351,9 @@ export async function analyzeTestability(
|
|
|
351
351
|
const needed =
|
|
352
352
|
Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
353
353
|
issues.push({
|
|
354
|
-
type:
|
|
354
|
+
type: IssueType.LowTestability,
|
|
355
355
|
dimension: 'test-coverage',
|
|
356
|
-
severity: actualRatio === 0 ?
|
|
356
|
+
severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
|
|
357
357
|
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
358
358
|
location: { file: options.rootDir, line: 0 },
|
|
359
359
|
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`,
|
|
@@ -362,9 +362,9 @@ export async function analyzeTestability(
|
|
|
362
362
|
|
|
363
363
|
if (indexResult.dimensions.purityScore < 50) {
|
|
364
364
|
issues.push({
|
|
365
|
-
type:
|
|
365
|
+
type: IssueType.LowTestability,
|
|
366
366
|
dimension: 'purity',
|
|
367
|
-
severity:
|
|
367
|
+
severity: Severity.Major,
|
|
368
368
|
message: `Only ${indexResult.dimensions.purityScore}% of functions are pure — side-effectful functions require complex test setup.`,
|
|
369
369
|
location: { file: options.rootDir, line: 0 },
|
|
370
370
|
suggestion:
|
|
@@ -374,9 +374,9 @@ export async function analyzeTestability(
|
|
|
374
374
|
|
|
375
375
|
if (indexResult.dimensions.observabilityScore < 50) {
|
|
376
376
|
issues.push({
|
|
377
|
-
type:
|
|
377
|
+
type: IssueType.LowTestability,
|
|
378
378
|
dimension: 'observability',
|
|
379
|
-
severity:
|
|
379
|
+
severity: Severity.Major,
|
|
380
380
|
message: `Many functions mutate external state directly — outputs are invisible to unit tests.`,
|
|
381
381
|
location: { file: options.rootDir, line: 0 },
|
|
382
382
|
suggestion: 'Prefer returning values over mutating shared state.',
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Issue } from '@aiready/core';
|
|
1
|
+
import type { Issue, IssueType } from '@aiready/core';
|
|
2
2
|
|
|
3
3
|
export interface TestabilityOptions {
|
|
4
4
|
/** Root directory to scan */
|
|
@@ -18,7 +18,7 @@ export interface TestabilityOptions {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface TestabilityIssue extends Issue {
|
|
21
|
-
type:
|
|
21
|
+
type: IssueType.LowTestability;
|
|
22
22
|
/** Category of testability barrier */
|
|
23
23
|
dimension:
|
|
24
24
|
| 'test-coverage'
|