@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/.turbo/turbo-build.log
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/testability@0.1.
|
|
3
|
+
> @aiready/testability@0.1.6 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,15 +9,15 @@
|
|
|
9
9
|
[34mCLI[39m Target: es2020
|
|
10
10
|
[34mCJS[39m Build start
|
|
11
11
|
[34mESM[39m Build start
|
|
12
|
-
[32mCJS[39m [1mdist/cli.js [22m[32m19.
|
|
13
|
-
[32mCJS[39m [1mdist/index.js [22m[32m12.
|
|
14
|
-
[32mCJS[39m ⚡️ Build success in
|
|
12
|
+
[32mCJS[39m [1mdist/cli.js [22m[32m19.75 KB[39m
|
|
13
|
+
[32mCJS[39m [1mdist/index.js [22m[32m12.97 KB[39m
|
|
14
|
+
[32mCJS[39m ⚡️ Build success in 138ms
|
|
15
15
|
[32mESM[39m [1mdist/index.mjs [22m[32m152.00 B[39m
|
|
16
|
-
[32mESM[39m [1mdist/chunk-
|
|
17
|
-
[32mESM[39m [1mdist/cli.mjs [22m[32m6.
|
|
18
|
-
[32mESM[39m ⚡️ Build success in
|
|
16
|
+
[32mESM[39m [1mdist/chunk-YLYLRZRS.mjs [22m[32m11.76 KB[39m
|
|
17
|
+
[32mESM[39m [1mdist/cli.mjs [22m[32m6.37 KB[39m
|
|
18
|
+
[32mESM[39m ⚡️ Build success in 138ms
|
|
19
19
|
DTS Build start
|
|
20
|
-
DTS ⚡️ Build success in
|
|
20
|
+
DTS ⚡️ Build success in 4492ms
|
|
21
21
|
DTS dist/cli.d.ts 20.00 B
|
|
22
22
|
DTS dist/index.d.ts 2.54 KB
|
|
23
23
|
DTS dist/cli.d.mts 20.00 B
|
package/.turbo/turbo-test.log
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/testability@0.1.
|
|
3
|
+
> @aiready/testability@0.1.6 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[
|
|
10
|
-
[33m[2m✓[22m[39m should calculate ratio of test files to source files [33m 732[2mms[22m[39m
|
|
11
|
-
[33m[2m✓[22m[39m should detect interfaces with too many methods [33m 473[2mms[22m[39m
|
|
9
|
+
[32m✓[39m src/__tests__/analyzer.test.ts [2m([22m[2m3 tests[22m[2m)[22m[32m 88[2mms[22m[39m
|
|
12
10
|
|
|
13
11
|
[2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
14
12
|
[2m Tests [22m [1m[32m3 passed[39m[22m[90m (3)[39m
|
|
15
|
-
[2m Start at [22m
|
|
16
|
-
[2m Duration [22m
|
|
13
|
+
[2m Start at [22m 22:19:15
|
|
14
|
+
[2m Duration [22m 1.19s[2m (transform 173ms, setup 0ms, import 878ms, tests 88ms, environment 0ms)[22m
|
|
17
15
|
|
|
18
16
|
[?25h
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// src/analyzer.ts
|
|
2
|
+
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
|
|
3
|
+
import { join, extname } from "path";
|
|
4
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
5
|
+
import { calculateTestabilityIndex } from "@aiready/core";
|
|
6
|
+
var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
7
|
+
var DEFAULT_EXCLUDES = [
|
|
8
|
+
"node_modules",
|
|
9
|
+
"dist",
|
|
10
|
+
".git",
|
|
11
|
+
"coverage",
|
|
12
|
+
".turbo",
|
|
13
|
+
"build"
|
|
14
|
+
];
|
|
15
|
+
var TEST_PATTERNS = [
|
|
16
|
+
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
17
|
+
/__tests__\//,
|
|
18
|
+
/\/tests?\//,
|
|
19
|
+
/\/e2e\//,
|
|
20
|
+
/\/fixtures\//
|
|
21
|
+
];
|
|
22
|
+
function isTestFile(filePath, extra) {
|
|
23
|
+
if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
|
|
24
|
+
if (extra) return extra.some((p) => filePath.includes(p));
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
function isSourceFile(filePath) {
|
|
28
|
+
return SRC_EXTENSIONS.has(extname(filePath));
|
|
29
|
+
}
|
|
30
|
+
function collectFiles(dir, options, depth = 0) {
|
|
31
|
+
if (depth > (options.maxDepth ?? 20)) return [];
|
|
32
|
+
const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
|
|
33
|
+
const files = [];
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = readdirSync(dir);
|
|
37
|
+
} catch {
|
|
38
|
+
return files;
|
|
39
|
+
}
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
|
|
42
|
+
const full = join(dir, entry);
|
|
43
|
+
let stat;
|
|
44
|
+
try {
|
|
45
|
+
stat = statSync(full);
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (stat.isDirectory()) {
|
|
50
|
+
files.push(...collectFiles(full, options, depth + 1));
|
|
51
|
+
} else if (stat.isFile() && isSourceFile(full)) {
|
|
52
|
+
if (!options.include || options.include.some((p) => full.includes(p))) {
|
|
53
|
+
files.push(full);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
59
|
+
function countMethodsInInterface(node) {
|
|
60
|
+
if (node.type === "TSInterfaceDeclaration") {
|
|
61
|
+
return node.body.body.filter(
|
|
62
|
+
(m) => m.type === "TSMethodSignature" || m.type === "TSPropertySignature"
|
|
63
|
+
).length;
|
|
64
|
+
}
|
|
65
|
+
if (node.type === "TSTypeAliasDeclaration" && node.typeAnnotation.type === "TSTypeLiteral") {
|
|
66
|
+
return node.typeAnnotation.members.length;
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
function hasDependencyInjection(node) {
|
|
71
|
+
for (const member of node.body.body) {
|
|
72
|
+
if (member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "constructor") {
|
|
73
|
+
const fn = member.value;
|
|
74
|
+
if (fn.params && fn.params.length > 0) {
|
|
75
|
+
const typedParams = fn.params.filter((p) => {
|
|
76
|
+
const param = p;
|
|
77
|
+
return param.typeAnnotation != null || param.parameter?.typeAnnotation != null;
|
|
78
|
+
});
|
|
79
|
+
if (typedParams.length > 0) return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
function isPureFunction(fn) {
|
|
86
|
+
let hasReturn = false;
|
|
87
|
+
let hasSideEffect = false;
|
|
88
|
+
function walk(node) {
|
|
89
|
+
if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
|
|
90
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
91
|
+
hasSideEffect = true;
|
|
92
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(
|
|
93
|
+
node.callee.object.name
|
|
94
|
+
))
|
|
95
|
+
hasSideEffect = true;
|
|
96
|
+
for (const key of Object.keys(node)) {
|
|
97
|
+
if (key === "parent") continue;
|
|
98
|
+
const child = node[key];
|
|
99
|
+
if (child && typeof child === "object") {
|
|
100
|
+
if (Array.isArray(child)) {
|
|
101
|
+
child.forEach((c) => c?.type && walk(c));
|
|
102
|
+
} else if (child.type) {
|
|
103
|
+
walk(child);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (fn.body?.type === "BlockStatement") {
|
|
109
|
+
fn.body.body.forEach((s) => walk(s));
|
|
110
|
+
} else if (fn.body) {
|
|
111
|
+
hasReturn = true;
|
|
112
|
+
}
|
|
113
|
+
return hasReturn && !hasSideEffect;
|
|
114
|
+
}
|
|
115
|
+
function hasExternalStateMutation(fn) {
|
|
116
|
+
let found = false;
|
|
117
|
+
function walk(node) {
|
|
118
|
+
if (found) return;
|
|
119
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
120
|
+
found = true;
|
|
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 && walk(c));
|
|
126
|
+
else if (child.type) walk(child);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (fn.body?.type === "BlockStatement") fn.body.body.forEach((s) => walk(s));
|
|
131
|
+
return found;
|
|
132
|
+
}
|
|
133
|
+
function analyzeFileTestability(filePath) {
|
|
134
|
+
const result = {
|
|
135
|
+
pureFunctions: 0,
|
|
136
|
+
totalFunctions: 0,
|
|
137
|
+
injectionPatterns: 0,
|
|
138
|
+
totalClasses: 0,
|
|
139
|
+
bloatedInterfaces: 0,
|
|
140
|
+
totalInterfaces: 0,
|
|
141
|
+
externalStateMutations: 0
|
|
142
|
+
};
|
|
143
|
+
let code;
|
|
144
|
+
try {
|
|
145
|
+
code = readFileSync(filePath, "utf-8");
|
|
146
|
+
} catch {
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
let ast;
|
|
150
|
+
try {
|
|
151
|
+
ast = parse(code, {
|
|
152
|
+
jsx: filePath.endsWith(".tsx") || filePath.endsWith(".jsx"),
|
|
153
|
+
range: false,
|
|
154
|
+
loc: false
|
|
155
|
+
});
|
|
156
|
+
} catch {
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
function visit(node) {
|
|
160
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
|
|
161
|
+
result.totalFunctions++;
|
|
162
|
+
if (isPureFunction(node)) result.pureFunctions++;
|
|
163
|
+
if (hasExternalStateMutation(node)) result.externalStateMutations++;
|
|
164
|
+
}
|
|
165
|
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
|
|
166
|
+
result.totalClasses++;
|
|
167
|
+
if (hasDependencyInjection(node)) result.injectionPatterns++;
|
|
168
|
+
}
|
|
169
|
+
if (node.type === "TSInterfaceDeclaration" || node.type === "TSTypeAliasDeclaration") {
|
|
170
|
+
result.totalInterfaces++;
|
|
171
|
+
const methodCount = countMethodsInInterface(node);
|
|
172
|
+
if (methodCount > 10) result.bloatedInterfaces++;
|
|
173
|
+
}
|
|
174
|
+
for (const key of Object.keys(node)) {
|
|
175
|
+
if (key === "parent") continue;
|
|
176
|
+
const child = node[key];
|
|
177
|
+
if (child && typeof child === "object") {
|
|
178
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && visit(c));
|
|
179
|
+
else if (child.type) visit(child);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
ast.body.forEach(visit);
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
function detectTestFramework(rootDir) {
|
|
187
|
+
const pkgPath = join(rootDir, "package.json");
|
|
188
|
+
if (!existsSync(pkgPath)) return false;
|
|
189
|
+
try {
|
|
190
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
191
|
+
const allDeps = {
|
|
192
|
+
...pkg.dependencies ?? {},
|
|
193
|
+
...pkg.devDependencies ?? {}
|
|
194
|
+
};
|
|
195
|
+
const testFrameworks = [
|
|
196
|
+
"jest",
|
|
197
|
+
"vitest",
|
|
198
|
+
"mocha",
|
|
199
|
+
"jasmine",
|
|
200
|
+
"ava",
|
|
201
|
+
"tap",
|
|
202
|
+
"pytest",
|
|
203
|
+
"unittest"
|
|
204
|
+
];
|
|
205
|
+
return testFrameworks.some((fw) => allDeps[fw]);
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function analyzeTestability(options) {
|
|
211
|
+
const allFiles = collectFiles(options.rootDir, options);
|
|
212
|
+
const sourceFiles = allFiles.filter(
|
|
213
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
214
|
+
);
|
|
215
|
+
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
216
|
+
const aggregated = {
|
|
217
|
+
pureFunctions: 0,
|
|
218
|
+
totalFunctions: 0,
|
|
219
|
+
injectionPatterns: 0,
|
|
220
|
+
totalClasses: 0,
|
|
221
|
+
bloatedInterfaces: 0,
|
|
222
|
+
totalInterfaces: 0,
|
|
223
|
+
externalStateMutations: 0
|
|
224
|
+
};
|
|
225
|
+
for (const f of sourceFiles) {
|
|
226
|
+
const a = analyzeFileTestability(f);
|
|
227
|
+
for (const key of Object.keys(aggregated)) {
|
|
228
|
+
aggregated[key] += a[key];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const hasTestFramework = detectTestFramework(options.rootDir);
|
|
232
|
+
const indexResult = calculateTestabilityIndex({
|
|
233
|
+
testFiles: testFiles.length,
|
|
234
|
+
sourceFiles: sourceFiles.length,
|
|
235
|
+
pureFunctions: aggregated.pureFunctions,
|
|
236
|
+
totalFunctions: Math.max(1, aggregated.totalFunctions),
|
|
237
|
+
injectionPatterns: aggregated.injectionPatterns,
|
|
238
|
+
totalClasses: Math.max(1, aggregated.totalClasses),
|
|
239
|
+
bloatedInterfaces: aggregated.bloatedInterfaces,
|
|
240
|
+
totalInterfaces: Math.max(1, aggregated.totalInterfaces),
|
|
241
|
+
externalStateMutations: aggregated.externalStateMutations,
|
|
242
|
+
hasTestFramework
|
|
243
|
+
});
|
|
244
|
+
const issues = [];
|
|
245
|
+
const minCoverage = options.minCoverageRatio ?? 0.3;
|
|
246
|
+
const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
247
|
+
if (!hasTestFramework) {
|
|
248
|
+
issues.push({
|
|
249
|
+
type: "low-testability",
|
|
250
|
+
dimension: "framework",
|
|
251
|
+
severity: "critical",
|
|
252
|
+
message: "No testing framework detected in package.json \u2014 AI changes cannot be verified at all.",
|
|
253
|
+
location: { file: options.rootDir, line: 0 },
|
|
254
|
+
suggestion: "Add Jest, Vitest, or another testing framework as a devDependency."
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (actualRatio < minCoverage) {
|
|
258
|
+
const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
259
|
+
issues.push({
|
|
260
|
+
type: "low-testability",
|
|
261
|
+
dimension: "test-coverage",
|
|
262
|
+
severity: actualRatio === 0 ? "critical" : "major",
|
|
263
|
+
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
264
|
+
location: { file: options.rootDir, line: 0 },
|
|
265
|
+
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (indexResult.dimensions.purityScore < 50) {
|
|
269
|
+
issues.push({
|
|
270
|
+
type: "low-testability",
|
|
271
|
+
dimension: "purity",
|
|
272
|
+
severity: "major",
|
|
273
|
+
message: `Only ${indexResult.dimensions.purityScore}% of functions are pure \u2014 side-effectful functions require complex test setup.`,
|
|
274
|
+
location: { file: options.rootDir, line: 0 },
|
|
275
|
+
suggestion: "Extract pure transformation logic from I/O and mutation code."
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (indexResult.dimensions.observabilityScore < 50) {
|
|
279
|
+
issues.push({
|
|
280
|
+
type: "low-testability",
|
|
281
|
+
dimension: "observability",
|
|
282
|
+
severity: "major",
|
|
283
|
+
message: `Many functions mutate external state directly \u2014 outputs are invisible to unit tests.`,
|
|
284
|
+
location: { file: options.rootDir, line: 0 },
|
|
285
|
+
suggestion: "Prefer returning values over mutating shared state."
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
summary: {
|
|
290
|
+
sourceFiles: sourceFiles.length,
|
|
291
|
+
testFiles: testFiles.length,
|
|
292
|
+
coverageRatio: Math.round(actualRatio * 100) / 100,
|
|
293
|
+
score: indexResult.score,
|
|
294
|
+
rating: indexResult.rating,
|
|
295
|
+
aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
|
|
296
|
+
dimensions: indexResult.dimensions
|
|
297
|
+
},
|
|
298
|
+
issues,
|
|
299
|
+
rawData: {
|
|
300
|
+
sourceFiles: sourceFiles.length,
|
|
301
|
+
testFiles: testFiles.length,
|
|
302
|
+
...aggregated,
|
|
303
|
+
hasTestFramework
|
|
304
|
+
},
|
|
305
|
+
recommendations: indexResult.recommendations
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/scoring.ts
|
|
310
|
+
function calculateTestabilityScore(report) {
|
|
311
|
+
const { summary, rawData, recommendations } = report;
|
|
312
|
+
const factors = [
|
|
313
|
+
{
|
|
314
|
+
name: "Test Coverage",
|
|
315
|
+
impact: Math.round(summary.dimensions.testCoverageRatio - 50),
|
|
316
|
+
description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: "Function Purity",
|
|
320
|
+
impact: Math.round(summary.dimensions.purityScore - 50),
|
|
321
|
+
description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: "Dependency Injection",
|
|
325
|
+
impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
|
|
326
|
+
description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: "Interface Focus",
|
|
330
|
+
impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
|
|
331
|
+
description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: "Observability",
|
|
335
|
+
impact: Math.round(summary.dimensions.observabilityScore - 50),
|
|
336
|
+
description: `${rawData.externalStateMutations} functions mutate external state`
|
|
337
|
+
}
|
|
338
|
+
];
|
|
339
|
+
const recs = recommendations.map(
|
|
340
|
+
(action) => ({
|
|
341
|
+
action,
|
|
342
|
+
estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
|
|
343
|
+
priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
return {
|
|
347
|
+
toolName: "testability",
|
|
348
|
+
score: summary.score,
|
|
349
|
+
rawMetrics: {
|
|
350
|
+
...rawData,
|
|
351
|
+
rating: summary.rating,
|
|
352
|
+
aiChangeSafetyRating: summary.aiChangeSafetyRating,
|
|
353
|
+
coverageRatio: summary.coverageRatio
|
|
354
|
+
},
|
|
355
|
+
factors,
|
|
356
|
+
recommendations: recs
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export {
|
|
361
|
+
analyzeTestability,
|
|
362
|
+
calculateTestabilityScore
|
|
363
|
+
};
|
package/dist/cli.js
CHANGED
|
@@ -32,7 +32,14 @@ var import_path = require("path");
|
|
|
32
32
|
var import_typescript_estree = require("@typescript-eslint/typescript-estree");
|
|
33
33
|
var import_core = require("@aiready/core");
|
|
34
34
|
var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
35
|
-
var DEFAULT_EXCLUDES = [
|
|
35
|
+
var DEFAULT_EXCLUDES = [
|
|
36
|
+
"node_modules",
|
|
37
|
+
"dist",
|
|
38
|
+
".git",
|
|
39
|
+
"coverage",
|
|
40
|
+
".turbo",
|
|
41
|
+
"build"
|
|
42
|
+
];
|
|
36
43
|
var TEST_PATTERNS = [
|
|
37
44
|
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
38
45
|
/__tests__\//,
|
|
@@ -108,8 +115,12 @@ function isPureFunction(fn) {
|
|
|
108
115
|
let hasSideEffect = false;
|
|
109
116
|
function walk(node) {
|
|
110
117
|
if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
|
|
111
|
-
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
112
|
-
|
|
118
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
119
|
+
hasSideEffect = true;
|
|
120
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(
|
|
121
|
+
node.callee.object.name
|
|
122
|
+
))
|
|
123
|
+
hasSideEffect = true;
|
|
113
124
|
for (const key of Object.keys(node)) {
|
|
114
125
|
if (key === "parent") continue;
|
|
115
126
|
const child = node[key];
|
|
@@ -133,7 +144,8 @@ function hasExternalStateMutation(fn) {
|
|
|
133
144
|
let found = false;
|
|
134
145
|
function walk(node) {
|
|
135
146
|
if (found) return;
|
|
136
|
-
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
147
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
148
|
+
found = true;
|
|
137
149
|
for (const key of Object.keys(node)) {
|
|
138
150
|
if (key === "parent") continue;
|
|
139
151
|
const child = node[key];
|
|
@@ -208,7 +220,16 @@ function detectTestFramework(rootDir) {
|
|
|
208
220
|
...pkg.dependencies ?? {},
|
|
209
221
|
...pkg.devDependencies ?? {}
|
|
210
222
|
};
|
|
211
|
-
const testFrameworks = [
|
|
223
|
+
const testFrameworks = [
|
|
224
|
+
"jest",
|
|
225
|
+
"vitest",
|
|
226
|
+
"mocha",
|
|
227
|
+
"jasmine",
|
|
228
|
+
"ava",
|
|
229
|
+
"tap",
|
|
230
|
+
"pytest",
|
|
231
|
+
"unittest"
|
|
232
|
+
];
|
|
212
233
|
return testFrameworks.some((fw) => allDeps[fw]);
|
|
213
234
|
} catch {
|
|
214
235
|
return false;
|
|
@@ -216,7 +237,9 @@ function detectTestFramework(rootDir) {
|
|
|
216
237
|
}
|
|
217
238
|
async function analyzeTestability(options) {
|
|
218
239
|
const allFiles = collectFiles(options.rootDir, options);
|
|
219
|
-
const sourceFiles = allFiles.filter(
|
|
240
|
+
const sourceFiles = allFiles.filter(
|
|
241
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
242
|
+
);
|
|
220
243
|
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
221
244
|
const aggregated = {
|
|
222
245
|
pureFunctions: 0,
|
|
@@ -341,11 +364,13 @@ function calculateTestabilityScore(report) {
|
|
|
341
364
|
description: `${rawData.externalStateMutations} functions mutate external state`
|
|
342
365
|
}
|
|
343
366
|
];
|
|
344
|
-
const recs = recommendations.map(
|
|
345
|
-
action
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
367
|
+
const recs = recommendations.map(
|
|
368
|
+
(action) => ({
|
|
369
|
+
action,
|
|
370
|
+
estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
|
|
371
|
+
priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
|
|
372
|
+
})
|
|
373
|
+
);
|
|
349
374
|
return {
|
|
350
375
|
toolName: "testability",
|
|
351
376
|
score: summary.score,
|
|
@@ -366,7 +391,11 @@ var import_fs2 = require("fs");
|
|
|
366
391
|
var import_path2 = require("path");
|
|
367
392
|
var import_core2 = require("@aiready/core");
|
|
368
393
|
var program = new import_commander.Command();
|
|
369
|
-
program.name("aiready-testability").description(
|
|
394
|
+
program.name("aiready-testability").description(
|
|
395
|
+
"Measure how safely AI-generated changes can be verified in your codebase"
|
|
396
|
+
).version("0.1.0").addHelpText(
|
|
397
|
+
"after",
|
|
398
|
+
`
|
|
370
399
|
DIMENSIONS MEASURED:
|
|
371
400
|
Test Coverage Ratio of test files to source files
|
|
372
401
|
Function Purity Pure functions are trivially AI-testable
|
|
@@ -384,7 +413,15 @@ EXAMPLES:
|
|
|
384
413
|
aiready-testability . # Full analysis
|
|
385
414
|
aiready-testability src/ --output json # JSON report
|
|
386
415
|
aiready-testability . --min-coverage 0.5 # Stricter 50% threshold
|
|
387
|
-
`
|
|
416
|
+
`
|
|
417
|
+
).argument("<directory>", "Directory to analyze").option(
|
|
418
|
+
"--min-coverage <ratio>",
|
|
419
|
+
"Minimum acceptable test/source ratio (default: 0.3)",
|
|
420
|
+
"0.3"
|
|
421
|
+
).option(
|
|
422
|
+
"--test-patterns <patterns>",
|
|
423
|
+
"Additional test file patterns (comma-separated)"
|
|
424
|
+
).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) => {
|
|
388
425
|
console.log(import_chalk.default.blue("\u{1F9EA} Analyzing testability...\n"));
|
|
389
426
|
const startTime = Date.now();
|
|
390
427
|
const config = await (0, import_core2.loadConfig)(directory);
|
|
@@ -453,18 +490,32 @@ function displayConsoleReport(report, scoring, elapsed) {
|
|
|
453
490
|
const safetyRating = summary.aiChangeSafetyRating;
|
|
454
491
|
console.log(import_chalk.default.bold("\n\u{1F9EA} Testability Analysis\n"));
|
|
455
492
|
if (safetyRating === "blind-risk") {
|
|
456
|
-
console.log(
|
|
457
|
-
|
|
458
|
-
|
|
493
|
+
console.log(
|
|
494
|
+
import_chalk.default.bgRed.white.bold(
|
|
495
|
+
" \u{1F480} BLIND RISK \u2014 NO TESTS DETECTED. AI-GENERATED CHANGES CANNOT BE VERIFIED. "
|
|
496
|
+
)
|
|
497
|
+
);
|
|
459
498
|
console.log();
|
|
460
499
|
} else if (safetyRating === "high-risk") {
|
|
461
|
-
console.log(
|
|
500
|
+
console.log(
|
|
501
|
+
import_chalk.default.red.bold(
|
|
502
|
+
` \u{1F534} HIGH RISK \u2014 Insufficient test coverage. AI changes may introduce silent bugs.`
|
|
503
|
+
)
|
|
504
|
+
);
|
|
462
505
|
console.log();
|
|
463
506
|
}
|
|
464
|
-
console.log(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
console.log(
|
|
507
|
+
console.log(
|
|
508
|
+
`AI Change Safety: ${safetyColor(safetyRating)(`${safetyIcon(safetyRating)} ${safetyRating.toUpperCase()}`)}`
|
|
509
|
+
);
|
|
510
|
+
console.log(
|
|
511
|
+
`Score: ${import_chalk.default.bold(summary.score + "/100")} (${summary.rating})`
|
|
512
|
+
);
|
|
513
|
+
console.log(
|
|
514
|
+
`Source Files: ${import_chalk.default.cyan(rawData.sourceFiles)} Test Files: ${import_chalk.default.cyan(rawData.testFiles)}`
|
|
515
|
+
);
|
|
516
|
+
console.log(
|
|
517
|
+
`Coverage Ratio: ${import_chalk.default.bold(Math.round(summary.coverageRatio * 100) + "%")}`
|
|
518
|
+
);
|
|
468
519
|
console.log(`Analysis Time: ${import_chalk.default.gray(elapsed + "s")}
|
|
469
520
|
`);
|
|
470
521
|
console.log(import_chalk.default.bold("\u{1F4D0} Dimension Scores\n"));
|
|
@@ -484,7 +535,10 @@ function displayConsoleReport(report, scoring, elapsed) {
|
|
|
484
535
|
for (const issue of issues) {
|
|
485
536
|
const sev = issue.severity === "critical" ? import_chalk.default.red : issue.severity === "major" ? import_chalk.default.yellow : import_chalk.default.blue;
|
|
486
537
|
console.log(`${sev(issue.severity.toUpperCase())} ${issue.message}`);
|
|
487
|
-
if (issue.suggestion)
|
|
538
|
+
if (issue.suggestion)
|
|
539
|
+
console.log(
|
|
540
|
+
` ${import_chalk.default.dim("\u2192")} ${import_chalk.default.italic(issue.suggestion)}`
|
|
541
|
+
);
|
|
488
542
|
console.log();
|
|
489
543
|
}
|
|
490
544
|
}
|