@aiready/testability 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/.turbo/turbo-lint.log +1 -1
- package/.turbo/turbo-test.log +4 -4
- package/dist/chunk-2QLLQPTL.mjs +339 -0
- package/dist/chunk-ULOUVO3Q.mjs +338 -0
- package/dist/cli.js +10 -9
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +7 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.js +46 -1
- package/dist/index.mjs +47 -1
- package/package.json +2 -2
- package/src/analyzer.ts +6 -1
- package/src/index.ts +7 -0
- package/src/provider.ts +55 -0
- package/src/scoring.ts +2 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/testability@0.
|
|
3
|
+
> @aiready/testability@0.3.0 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
|
|
15
|
-
[32mESM[39m [1mdist/chunk-7MM2QISW.mjs [22m[32m11.02 KB[39m
|
|
16
|
-
[32mESM[39m [1mdist/index.mjs [22m[32m152.00 B[39m
|
|
12
|
+
[32mCJS[39m [1mdist/cli.js [22m[32m18.44 KB[39m
|
|
13
|
+
[32mCJS[39m [1mdist/index.js [22m[32m13.56 KB[39m
|
|
14
|
+
[32mCJS[39m ⚡️ Build success in 51ms
|
|
17
15
|
[32mESM[39m [1mdist/cli.mjs [22m[32m5.75 KB[39m
|
|
18
|
-
[32mESM[39m
|
|
16
|
+
[32mESM[39m [1mdist/chunk-2QLLQPTL.mjs [22m[32m11.08 KB[39m
|
|
17
|
+
[32mESM[39m [1mdist/index.mjs [22m[32m1.28 KB[39m
|
|
18
|
+
[32mESM[39m ⚡️ Build success in 53ms
|
|
19
19
|
DTS Build start
|
|
20
|
-
DTS ⚡️ Build success in
|
|
20
|
+
DTS ⚡️ Build success in 2884ms
|
|
21
21
|
DTS dist/cli.d.ts 20.00 B
|
|
22
|
-
DTS dist/index.d.ts 2.
|
|
22
|
+
DTS dist/index.d.ts 2.78 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.78 KB
|
package/.turbo/turbo-lint.log
CHANGED
package/.turbo/turbo-test.log
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @aiready/testability@0.
|
|
3
|
+
> @aiready/testability@0.3.0 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 29[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
|
|
13
|
+
[2m Start at [22m 09:33:04
|
|
14
|
+
[2m Duration [22m 463ms[2m (transform 130ms, setup 0ms, import 311ms, tests 29ms, environment 0ms)[22m
|
|
15
15
|
|
|
16
16
|
[?25h
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// src/analyzer.ts
|
|
2
|
+
import {
|
|
3
|
+
scanFiles,
|
|
4
|
+
calculateTestabilityIndex,
|
|
5
|
+
Severity,
|
|
6
|
+
IssueType
|
|
7
|
+
} from "@aiready/core";
|
|
8
|
+
import { readFileSync, existsSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
11
|
+
function countMethodsInInterface(node) {
|
|
12
|
+
if (node.type === "TSInterfaceDeclaration") {
|
|
13
|
+
return node.body.body.filter(
|
|
14
|
+
(m) => m.type === "TSMethodSignature" || m.type === "TSPropertySignature"
|
|
15
|
+
).length;
|
|
16
|
+
}
|
|
17
|
+
if (node.type === "TSTypeAliasDeclaration" && node.typeAnnotation.type === "TSTypeLiteral") {
|
|
18
|
+
return node.typeAnnotation.members.length;
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
function hasDependencyInjection(node) {
|
|
23
|
+
for (const member of node.body.body) {
|
|
24
|
+
if (member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "constructor") {
|
|
25
|
+
const fn = member.value;
|
|
26
|
+
if (fn.params && fn.params.length > 0) {
|
|
27
|
+
const typedParams = fn.params.filter((p) => {
|
|
28
|
+
const param = p;
|
|
29
|
+
return param.typeAnnotation != null || param.parameter?.typeAnnotation != null;
|
|
30
|
+
});
|
|
31
|
+
if (typedParams.length > 0) return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
function isPureFunction(fn) {
|
|
38
|
+
let hasReturn = false;
|
|
39
|
+
let hasSideEffect = false;
|
|
40
|
+
function walk(node) {
|
|
41
|
+
if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
|
|
42
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
43
|
+
hasSideEffect = true;
|
|
44
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(
|
|
45
|
+
node.callee.object.name
|
|
46
|
+
))
|
|
47
|
+
hasSideEffect = true;
|
|
48
|
+
for (const key of Object.keys(node)) {
|
|
49
|
+
if (key === "parent") continue;
|
|
50
|
+
const child = node[key];
|
|
51
|
+
if (child && typeof child === "object") {
|
|
52
|
+
if (Array.isArray(child)) {
|
|
53
|
+
child.forEach((c) => c?.type && walk(c));
|
|
54
|
+
} else if (child.type) {
|
|
55
|
+
walk(child);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (fn.body?.type === "BlockStatement") {
|
|
61
|
+
fn.body.body.forEach((s) => walk(s));
|
|
62
|
+
} else if (fn.body) {
|
|
63
|
+
hasReturn = true;
|
|
64
|
+
}
|
|
65
|
+
return hasReturn && !hasSideEffect;
|
|
66
|
+
}
|
|
67
|
+
function hasExternalStateMutation(fn) {
|
|
68
|
+
let found = false;
|
|
69
|
+
function walk(node) {
|
|
70
|
+
if (found) return;
|
|
71
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
72
|
+
found = true;
|
|
73
|
+
for (const key of Object.keys(node)) {
|
|
74
|
+
if (key === "parent") continue;
|
|
75
|
+
const child = node[key];
|
|
76
|
+
if (child && typeof child === "object") {
|
|
77
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && walk(c));
|
|
78
|
+
else if (child.type) walk(child);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (fn.body?.type === "BlockStatement") fn.body.body.forEach((s) => walk(s));
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
function analyzeFileTestability(filePath) {
|
|
86
|
+
const result = {
|
|
87
|
+
pureFunctions: 0,
|
|
88
|
+
totalFunctions: 0,
|
|
89
|
+
injectionPatterns: 0,
|
|
90
|
+
totalClasses: 0,
|
|
91
|
+
bloatedInterfaces: 0,
|
|
92
|
+
totalInterfaces: 0,
|
|
93
|
+
externalStateMutations: 0
|
|
94
|
+
};
|
|
95
|
+
let code;
|
|
96
|
+
try {
|
|
97
|
+
code = readFileSync(filePath, "utf-8");
|
|
98
|
+
} catch {
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
let ast;
|
|
102
|
+
try {
|
|
103
|
+
ast = parse(code, {
|
|
104
|
+
jsx: filePath.endsWith(".tsx") || filePath.endsWith(".jsx"),
|
|
105
|
+
range: false,
|
|
106
|
+
loc: false
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
function visit(node) {
|
|
112
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
|
|
113
|
+
result.totalFunctions++;
|
|
114
|
+
if (isPureFunction(node)) result.pureFunctions++;
|
|
115
|
+
if (hasExternalStateMutation(node)) result.externalStateMutations++;
|
|
116
|
+
}
|
|
117
|
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
|
|
118
|
+
result.totalClasses++;
|
|
119
|
+
if (hasDependencyInjection(node)) result.injectionPatterns++;
|
|
120
|
+
}
|
|
121
|
+
if (node.type === "TSInterfaceDeclaration" || node.type === "TSTypeAliasDeclaration") {
|
|
122
|
+
result.totalInterfaces++;
|
|
123
|
+
const methodCount = countMethodsInInterface(node);
|
|
124
|
+
if (methodCount > 10) result.bloatedInterfaces++;
|
|
125
|
+
}
|
|
126
|
+
for (const key of Object.keys(node)) {
|
|
127
|
+
if (key === "parent") continue;
|
|
128
|
+
const child = node[key];
|
|
129
|
+
if (child && typeof child === "object") {
|
|
130
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && visit(c));
|
|
131
|
+
else if (child.type) visit(child);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
ast.body.forEach(visit);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
function detectTestFramework(rootDir) {
|
|
139
|
+
const pkgPath = join(rootDir, "package.json");
|
|
140
|
+
if (!existsSync(pkgPath)) return false;
|
|
141
|
+
try {
|
|
142
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
143
|
+
const allDeps = {
|
|
144
|
+
...pkg.dependencies ?? {},
|
|
145
|
+
...pkg.devDependencies ?? {}
|
|
146
|
+
};
|
|
147
|
+
const testFrameworks = [
|
|
148
|
+
"jest",
|
|
149
|
+
"vitest",
|
|
150
|
+
"mocha",
|
|
151
|
+
"jasmine",
|
|
152
|
+
"ava",
|
|
153
|
+
"tap",
|
|
154
|
+
"pytest",
|
|
155
|
+
"unittest"
|
|
156
|
+
];
|
|
157
|
+
return testFrameworks.some((fw) => allDeps[fw]);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
var TEST_PATTERNS = [
|
|
163
|
+
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
164
|
+
/__tests__\//,
|
|
165
|
+
/\/tests?\//,
|
|
166
|
+
/\/e2e\//,
|
|
167
|
+
/\/fixtures\//
|
|
168
|
+
];
|
|
169
|
+
function isTestFile(filePath, extra) {
|
|
170
|
+
if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
|
|
171
|
+
if (extra) return extra.some((p) => filePath.includes(p));
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
async function analyzeTestability(options) {
|
|
175
|
+
const allFiles = await scanFiles({
|
|
176
|
+
...options,
|
|
177
|
+
include: options.include || ["**/*.{ts,tsx,js,jsx}"],
|
|
178
|
+
includeTests: true
|
|
179
|
+
});
|
|
180
|
+
const sourceFiles = allFiles.filter(
|
|
181
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
182
|
+
);
|
|
183
|
+
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
184
|
+
const aggregated = {
|
|
185
|
+
pureFunctions: 0,
|
|
186
|
+
totalFunctions: 0,
|
|
187
|
+
injectionPatterns: 0,
|
|
188
|
+
totalClasses: 0,
|
|
189
|
+
bloatedInterfaces: 0,
|
|
190
|
+
totalInterfaces: 0,
|
|
191
|
+
externalStateMutations: 0
|
|
192
|
+
};
|
|
193
|
+
let processed = 0;
|
|
194
|
+
for (const f of sourceFiles) {
|
|
195
|
+
processed++;
|
|
196
|
+
options.onProgress?.(
|
|
197
|
+
processed,
|
|
198
|
+
sourceFiles.length,
|
|
199
|
+
`testability: analyzing files`
|
|
200
|
+
);
|
|
201
|
+
const a = analyzeFileTestability(f);
|
|
202
|
+
for (const key of Object.keys(aggregated)) {
|
|
203
|
+
aggregated[key] += a[key];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const hasTestFramework = detectTestFramework(options.rootDir);
|
|
207
|
+
const indexResult = calculateTestabilityIndex({
|
|
208
|
+
testFiles: testFiles.length,
|
|
209
|
+
sourceFiles: sourceFiles.length,
|
|
210
|
+
pureFunctions: aggregated.pureFunctions,
|
|
211
|
+
totalFunctions: Math.max(1, aggregated.totalFunctions),
|
|
212
|
+
injectionPatterns: aggregated.injectionPatterns,
|
|
213
|
+
totalClasses: Math.max(1, aggregated.totalClasses),
|
|
214
|
+
bloatedInterfaces: aggregated.bloatedInterfaces,
|
|
215
|
+
totalInterfaces: Math.max(1, aggregated.totalInterfaces),
|
|
216
|
+
externalStateMutations: aggregated.externalStateMutations,
|
|
217
|
+
hasTestFramework
|
|
218
|
+
});
|
|
219
|
+
const issues = [];
|
|
220
|
+
const minCoverage = options.minCoverageRatio ?? 0.3;
|
|
221
|
+
const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
222
|
+
if (!hasTestFramework) {
|
|
223
|
+
issues.push({
|
|
224
|
+
type: IssueType.LowTestability,
|
|
225
|
+
dimension: "framework",
|
|
226
|
+
severity: Severity.Critical,
|
|
227
|
+
message: "No testing framework detected in package.json \u2014 AI changes cannot be verified at all.",
|
|
228
|
+
location: { file: options.rootDir, line: 0 },
|
|
229
|
+
suggestion: "Add Jest, Vitest, or another testing framework as a devDependency."
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (actualRatio < minCoverage) {
|
|
233
|
+
const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
234
|
+
issues.push({
|
|
235
|
+
type: IssueType.LowTestability,
|
|
236
|
+
dimension: "test-coverage",
|
|
237
|
+
severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
|
|
238
|
+
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
239
|
+
location: { file: options.rootDir, line: 0 },
|
|
240
|
+
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (indexResult.dimensions.purityScore < 50) {
|
|
244
|
+
issues.push({
|
|
245
|
+
type: IssueType.LowTestability,
|
|
246
|
+
dimension: "purity",
|
|
247
|
+
severity: Severity.Major,
|
|
248
|
+
message: `Only ${indexResult.dimensions.purityScore}% of functions are pure \u2014 side-effectful functions require complex test setup.`,
|
|
249
|
+
location: { file: options.rootDir, line: 0 },
|
|
250
|
+
suggestion: "Extract pure transformation logic from I/O and mutation code."
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (indexResult.dimensions.observabilityScore < 50) {
|
|
254
|
+
issues.push({
|
|
255
|
+
type: IssueType.LowTestability,
|
|
256
|
+
dimension: "observability",
|
|
257
|
+
severity: Severity.Major,
|
|
258
|
+
message: `Many functions mutate external state directly \u2014 outputs are invisible to unit tests.`,
|
|
259
|
+
location: { file: options.rootDir, line: 0 },
|
|
260
|
+
suggestion: "Prefer returning values over mutating shared state."
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
summary: {
|
|
265
|
+
sourceFiles: sourceFiles.length,
|
|
266
|
+
testFiles: testFiles.length,
|
|
267
|
+
coverageRatio: Math.round(actualRatio * 100) / 100,
|
|
268
|
+
score: indexResult.score,
|
|
269
|
+
rating: indexResult.rating,
|
|
270
|
+
aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
|
|
271
|
+
dimensions: indexResult.dimensions
|
|
272
|
+
},
|
|
273
|
+
issues,
|
|
274
|
+
rawData: {
|
|
275
|
+
sourceFiles: sourceFiles.length,
|
|
276
|
+
testFiles: testFiles.length,
|
|
277
|
+
...aggregated,
|
|
278
|
+
hasTestFramework
|
|
279
|
+
},
|
|
280
|
+
recommendations: indexResult.recommendations
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/scoring.ts
|
|
285
|
+
import { ToolName } from "@aiready/core";
|
|
286
|
+
function calculateTestabilityScore(report) {
|
|
287
|
+
const { summary, rawData, recommendations } = report;
|
|
288
|
+
const factors = [
|
|
289
|
+
{
|
|
290
|
+
name: "Test Coverage",
|
|
291
|
+
impact: Math.round(summary.dimensions.testCoverageRatio - 50),
|
|
292
|
+
description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
name: "Function Purity",
|
|
296
|
+
impact: Math.round(summary.dimensions.purityScore - 50),
|
|
297
|
+
description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: "Dependency Injection",
|
|
301
|
+
impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
|
|
302
|
+
description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: "Interface Focus",
|
|
306
|
+
impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
|
|
307
|
+
description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: "Observability",
|
|
311
|
+
impact: Math.round(summary.dimensions.observabilityScore - 50),
|
|
312
|
+
description: `${rawData.externalStateMutations} functions mutate external state`
|
|
313
|
+
}
|
|
314
|
+
];
|
|
315
|
+
const recs = recommendations.map(
|
|
316
|
+
(action) => ({
|
|
317
|
+
action,
|
|
318
|
+
estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
|
|
319
|
+
priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
|
|
320
|
+
})
|
|
321
|
+
);
|
|
322
|
+
return {
|
|
323
|
+
toolName: ToolName.TestabilityIndex,
|
|
324
|
+
score: summary.score,
|
|
325
|
+
rawMetrics: {
|
|
326
|
+
...rawData,
|
|
327
|
+
rating: summary.rating,
|
|
328
|
+
aiChangeSafetyRating: summary.aiChangeSafetyRating,
|
|
329
|
+
coverageRatio: summary.coverageRatio
|
|
330
|
+
},
|
|
331
|
+
factors,
|
|
332
|
+
recommendations: recs
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export {
|
|
337
|
+
analyzeTestability,
|
|
338
|
+
calculateTestabilityScore
|
|
339
|
+
};
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// src/analyzer.ts
|
|
2
|
+
import {
|
|
3
|
+
scanFiles,
|
|
4
|
+
calculateTestabilityIndex,
|
|
5
|
+
Severity,
|
|
6
|
+
IssueType
|
|
7
|
+
} from "@aiready/core";
|
|
8
|
+
import { readFileSync, existsSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
11
|
+
function countMethodsInInterface(node) {
|
|
12
|
+
if (node.type === "TSInterfaceDeclaration") {
|
|
13
|
+
return node.body.body.filter(
|
|
14
|
+
(m) => m.type === "TSMethodSignature" || m.type === "TSPropertySignature"
|
|
15
|
+
).length;
|
|
16
|
+
}
|
|
17
|
+
if (node.type === "TSTypeAliasDeclaration" && node.typeAnnotation.type === "TSTypeLiteral") {
|
|
18
|
+
return node.typeAnnotation.members.length;
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
function hasDependencyInjection(node) {
|
|
23
|
+
for (const member of node.body.body) {
|
|
24
|
+
if (member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "constructor") {
|
|
25
|
+
const fn = member.value;
|
|
26
|
+
if (fn.params && fn.params.length > 0) {
|
|
27
|
+
const typedParams = fn.params.filter((p) => {
|
|
28
|
+
const param = p;
|
|
29
|
+
return param.typeAnnotation != null || param.parameter?.typeAnnotation != null;
|
|
30
|
+
});
|
|
31
|
+
if (typedParams.length > 0) return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
function isPureFunction(fn) {
|
|
38
|
+
let hasReturn = false;
|
|
39
|
+
let hasSideEffect = false;
|
|
40
|
+
function walk(node) {
|
|
41
|
+
if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
|
|
42
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
43
|
+
hasSideEffect = true;
|
|
44
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(
|
|
45
|
+
node.callee.object.name
|
|
46
|
+
))
|
|
47
|
+
hasSideEffect = true;
|
|
48
|
+
for (const key of Object.keys(node)) {
|
|
49
|
+
if (key === "parent") continue;
|
|
50
|
+
const child = node[key];
|
|
51
|
+
if (child && typeof child === "object") {
|
|
52
|
+
if (Array.isArray(child)) {
|
|
53
|
+
child.forEach((c) => c?.type && walk(c));
|
|
54
|
+
} else if (child.type) {
|
|
55
|
+
walk(child);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (fn.body?.type === "BlockStatement") {
|
|
61
|
+
fn.body.body.forEach((s) => walk(s));
|
|
62
|
+
} else if (fn.body) {
|
|
63
|
+
hasReturn = true;
|
|
64
|
+
}
|
|
65
|
+
return hasReturn && !hasSideEffect;
|
|
66
|
+
}
|
|
67
|
+
function hasExternalStateMutation(fn) {
|
|
68
|
+
let found = false;
|
|
69
|
+
function walk(node) {
|
|
70
|
+
if (found) return;
|
|
71
|
+
if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
|
|
72
|
+
found = true;
|
|
73
|
+
for (const key of Object.keys(node)) {
|
|
74
|
+
if (key === "parent") continue;
|
|
75
|
+
const child = node[key];
|
|
76
|
+
if (child && typeof child === "object") {
|
|
77
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && walk(c));
|
|
78
|
+
else if (child.type) walk(child);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (fn.body?.type === "BlockStatement") fn.body.body.forEach((s) => walk(s));
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
function analyzeFileTestability(filePath) {
|
|
86
|
+
const result = {
|
|
87
|
+
pureFunctions: 0,
|
|
88
|
+
totalFunctions: 0,
|
|
89
|
+
injectionPatterns: 0,
|
|
90
|
+
totalClasses: 0,
|
|
91
|
+
bloatedInterfaces: 0,
|
|
92
|
+
totalInterfaces: 0,
|
|
93
|
+
externalStateMutations: 0
|
|
94
|
+
};
|
|
95
|
+
let code;
|
|
96
|
+
try {
|
|
97
|
+
code = readFileSync(filePath, "utf-8");
|
|
98
|
+
} catch {
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
let ast;
|
|
102
|
+
try {
|
|
103
|
+
ast = parse(code, {
|
|
104
|
+
jsx: filePath.endsWith(".tsx") || filePath.endsWith(".jsx"),
|
|
105
|
+
range: false,
|
|
106
|
+
loc: false
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
function visit(node) {
|
|
112
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
|
|
113
|
+
result.totalFunctions++;
|
|
114
|
+
if (isPureFunction(node)) result.pureFunctions++;
|
|
115
|
+
if (hasExternalStateMutation(node)) result.externalStateMutations++;
|
|
116
|
+
}
|
|
117
|
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
|
|
118
|
+
result.totalClasses++;
|
|
119
|
+
if (hasDependencyInjection(node)) result.injectionPatterns++;
|
|
120
|
+
}
|
|
121
|
+
if (node.type === "TSInterfaceDeclaration" || node.type === "TSTypeAliasDeclaration") {
|
|
122
|
+
result.totalInterfaces++;
|
|
123
|
+
const methodCount = countMethodsInInterface(node);
|
|
124
|
+
if (methodCount > 10) result.bloatedInterfaces++;
|
|
125
|
+
}
|
|
126
|
+
for (const key of Object.keys(node)) {
|
|
127
|
+
if (key === "parent") continue;
|
|
128
|
+
const child = node[key];
|
|
129
|
+
if (child && typeof child === "object") {
|
|
130
|
+
if (Array.isArray(child)) child.forEach((c) => c?.type && visit(c));
|
|
131
|
+
else if (child.type) visit(child);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
ast.body.forEach(visit);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
function detectTestFramework(rootDir) {
|
|
139
|
+
const pkgPath = join(rootDir, "package.json");
|
|
140
|
+
if (!existsSync(pkgPath)) return false;
|
|
141
|
+
try {
|
|
142
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
143
|
+
const allDeps = {
|
|
144
|
+
...pkg.dependencies ?? {},
|
|
145
|
+
...pkg.devDependencies ?? {}
|
|
146
|
+
};
|
|
147
|
+
const testFrameworks = [
|
|
148
|
+
"jest",
|
|
149
|
+
"vitest",
|
|
150
|
+
"mocha",
|
|
151
|
+
"jasmine",
|
|
152
|
+
"ava",
|
|
153
|
+
"tap",
|
|
154
|
+
"pytest",
|
|
155
|
+
"unittest"
|
|
156
|
+
];
|
|
157
|
+
return testFrameworks.some((fw) => allDeps[fw]);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
var TEST_PATTERNS = [
|
|
163
|
+
/\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
164
|
+
/__tests__\//,
|
|
165
|
+
/\/tests?\//,
|
|
166
|
+
/\/e2e\//,
|
|
167
|
+
/\/fixtures\//
|
|
168
|
+
];
|
|
169
|
+
function isTestFile(filePath, extra) {
|
|
170
|
+
if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
|
|
171
|
+
if (extra) return extra.some((p) => filePath.includes(p));
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
async function analyzeTestability(options) {
|
|
175
|
+
const allFiles = await scanFiles({
|
|
176
|
+
...options,
|
|
177
|
+
include: options.include || ["**/*.{ts,tsx,js,jsx}"],
|
|
178
|
+
includeTests: true
|
|
179
|
+
});
|
|
180
|
+
const sourceFiles = allFiles.filter(
|
|
181
|
+
(f) => !isTestFile(f, options.testPatterns)
|
|
182
|
+
);
|
|
183
|
+
const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
|
|
184
|
+
const aggregated = {
|
|
185
|
+
pureFunctions: 0,
|
|
186
|
+
totalFunctions: 0,
|
|
187
|
+
injectionPatterns: 0,
|
|
188
|
+
totalClasses: 0,
|
|
189
|
+
bloatedInterfaces: 0,
|
|
190
|
+
totalInterfaces: 0,
|
|
191
|
+
externalStateMutations: 0
|
|
192
|
+
};
|
|
193
|
+
let processed = 0;
|
|
194
|
+
for (const f of sourceFiles) {
|
|
195
|
+
processed++;
|
|
196
|
+
options.onProgress?.(
|
|
197
|
+
processed,
|
|
198
|
+
sourceFiles.length,
|
|
199
|
+
`testability: analyzing files`
|
|
200
|
+
);
|
|
201
|
+
const a = analyzeFileTestability(f);
|
|
202
|
+
for (const key of Object.keys(aggregated)) {
|
|
203
|
+
aggregated[key] += a[key];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const hasTestFramework = detectTestFramework(options.rootDir);
|
|
207
|
+
const indexResult = calculateTestabilityIndex({
|
|
208
|
+
testFiles: testFiles.length,
|
|
209
|
+
sourceFiles: sourceFiles.length,
|
|
210
|
+
pureFunctions: aggregated.pureFunctions,
|
|
211
|
+
totalFunctions: Math.max(1, aggregated.totalFunctions),
|
|
212
|
+
injectionPatterns: aggregated.injectionPatterns,
|
|
213
|
+
totalClasses: Math.max(1, aggregated.totalClasses),
|
|
214
|
+
bloatedInterfaces: aggregated.bloatedInterfaces,
|
|
215
|
+
totalInterfaces: Math.max(1, aggregated.totalInterfaces),
|
|
216
|
+
externalStateMutations: aggregated.externalStateMutations,
|
|
217
|
+
hasTestFramework
|
|
218
|
+
});
|
|
219
|
+
const issues = [];
|
|
220
|
+
const minCoverage = options.minCoverageRatio ?? 0.3;
|
|
221
|
+
const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
|
|
222
|
+
if (!hasTestFramework) {
|
|
223
|
+
issues.push({
|
|
224
|
+
type: IssueType.LowTestability,
|
|
225
|
+
dimension: "framework",
|
|
226
|
+
severity: Severity.Critical,
|
|
227
|
+
message: "No testing framework detected in package.json \u2014 AI changes cannot be verified at all.",
|
|
228
|
+
location: { file: options.rootDir, line: 0 },
|
|
229
|
+
suggestion: "Add Jest, Vitest, or another testing framework as a devDependency."
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (actualRatio < minCoverage) {
|
|
233
|
+
const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
|
|
234
|
+
issues.push({
|
|
235
|
+
type: IssueType.LowTestability,
|
|
236
|
+
dimension: "test-coverage",
|
|
237
|
+
severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
|
|
238
|
+
message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
|
|
239
|
+
location: { file: options.rootDir, line: 0 },
|
|
240
|
+
suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (indexResult.dimensions.purityScore < 50) {
|
|
244
|
+
issues.push({
|
|
245
|
+
type: IssueType.LowTestability,
|
|
246
|
+
dimension: "purity",
|
|
247
|
+
severity: Severity.Major,
|
|
248
|
+
message: `Only ${indexResult.dimensions.purityScore}% of functions are pure \u2014 side-effectful functions require complex test setup.`,
|
|
249
|
+
location: { file: options.rootDir, line: 0 },
|
|
250
|
+
suggestion: "Extract pure transformation logic from I/O and mutation code."
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (indexResult.dimensions.observabilityScore < 50) {
|
|
254
|
+
issues.push({
|
|
255
|
+
type: IssueType.LowTestability,
|
|
256
|
+
dimension: "observability",
|
|
257
|
+
severity: Severity.Major,
|
|
258
|
+
message: `Many functions mutate external state directly \u2014 outputs are invisible to unit tests.`,
|
|
259
|
+
location: { file: options.rootDir, line: 0 },
|
|
260
|
+
suggestion: "Prefer returning values over mutating shared state."
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
summary: {
|
|
265
|
+
sourceFiles: sourceFiles.length,
|
|
266
|
+
testFiles: testFiles.length,
|
|
267
|
+
coverageRatio: Math.round(actualRatio * 100) / 100,
|
|
268
|
+
score: indexResult.score,
|
|
269
|
+
rating: indexResult.rating,
|
|
270
|
+
aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
|
|
271
|
+
dimensions: indexResult.dimensions
|
|
272
|
+
},
|
|
273
|
+
issues,
|
|
274
|
+
rawData: {
|
|
275
|
+
sourceFiles: sourceFiles.length,
|
|
276
|
+
testFiles: testFiles.length,
|
|
277
|
+
...aggregated,
|
|
278
|
+
hasTestFramework
|
|
279
|
+
},
|
|
280
|
+
recommendations: indexResult.recommendations
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/scoring.ts
|
|
285
|
+
function calculateTestabilityScore(report) {
|
|
286
|
+
const { summary, rawData, recommendations } = report;
|
|
287
|
+
const factors = [
|
|
288
|
+
{
|
|
289
|
+
name: "Test Coverage",
|
|
290
|
+
impact: Math.round(summary.dimensions.testCoverageRatio - 50),
|
|
291
|
+
description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "Function Purity",
|
|
295
|
+
impact: Math.round(summary.dimensions.purityScore - 50),
|
|
296
|
+
description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "Dependency Injection",
|
|
300
|
+
impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
|
|
301
|
+
description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "Interface Focus",
|
|
305
|
+
impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
|
|
306
|
+
description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "Observability",
|
|
310
|
+
impact: Math.round(summary.dimensions.observabilityScore - 50),
|
|
311
|
+
description: `${rawData.externalStateMutations} functions mutate external state`
|
|
312
|
+
}
|
|
313
|
+
];
|
|
314
|
+
const recs = recommendations.map(
|
|
315
|
+
(action) => ({
|
|
316
|
+
action,
|
|
317
|
+
estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
|
|
318
|
+
priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
return {
|
|
322
|
+
toolName: "testability",
|
|
323
|
+
score: summary.score,
|
|
324
|
+
rawMetrics: {
|
|
325
|
+
...rawData,
|
|
326
|
+
rating: summary.rating,
|
|
327
|
+
aiChangeSafetyRating: summary.aiChangeSafetyRating,
|
|
328
|
+
coverageRatio: summary.coverageRatio
|
|
329
|
+
},
|
|
330
|
+
factors,
|
|
331
|
+
recommendations: recs
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export {
|
|
336
|
+
analyzeTestability,
|
|
337
|
+
calculateTestabilityScore
|
|
338
|
+
};
|
package/dist/cli.js
CHANGED
|
@@ -305,6 +305,7 @@ async function analyzeTestability(options) {
|
|
|
305
305
|
}
|
|
306
306
|
|
|
307
307
|
// src/scoring.ts
|
|
308
|
+
var import_core2 = require("@aiready/core");
|
|
308
309
|
function calculateTestabilityScore(report) {
|
|
309
310
|
const { summary, rawData, recommendations } = report;
|
|
310
311
|
const factors = [
|
|
@@ -342,7 +343,7 @@ function calculateTestabilityScore(report) {
|
|
|
342
343
|
})
|
|
343
344
|
);
|
|
344
345
|
return {
|
|
345
|
-
toolName:
|
|
346
|
+
toolName: import_core2.ToolName.TestabilityIndex,
|
|
346
347
|
score: summary.score,
|
|
347
348
|
rawMetrics: {
|
|
348
349
|
...rawData,
|
|
@@ -359,7 +360,7 @@ function calculateTestabilityScore(report) {
|
|
|
359
360
|
var import_chalk = __toESM(require("chalk"));
|
|
360
361
|
var import_fs2 = require("fs");
|
|
361
362
|
var import_path2 = require("path");
|
|
362
|
-
var
|
|
363
|
+
var import_core3 = require("@aiready/core");
|
|
363
364
|
var program = new import_commander.Command();
|
|
364
365
|
program.name("aiready-testability").description(
|
|
365
366
|
"Measure how safely AI-generated changes can be verified in your codebase"
|
|
@@ -394,8 +395,8 @@ EXAMPLES:
|
|
|
394
395
|
).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) => {
|
|
395
396
|
console.log(import_chalk.default.blue("\u{1F9EA} Analyzing testability...\n"));
|
|
396
397
|
const startTime = Date.now();
|
|
397
|
-
const config = await (0,
|
|
398
|
-
const mergedConfig = (0,
|
|
398
|
+
const config = await (0, import_core3.loadConfig)(directory);
|
|
399
|
+
const mergedConfig = (0, import_core3.mergeConfigWithDefaults)(config, {
|
|
399
400
|
minCoverageRatio: 0.3
|
|
400
401
|
});
|
|
401
402
|
const finalOptions = {
|
|
@@ -410,7 +411,7 @@ EXAMPLES:
|
|
|
410
411
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(2);
|
|
411
412
|
if (options.output === "json") {
|
|
412
413
|
const payload = { report, score: scoring };
|
|
413
|
-
const outputPath = (0,
|
|
414
|
+
const outputPath = (0, import_core3.resolveOutputPath)(
|
|
414
415
|
options.outputFile,
|
|
415
416
|
`testability-report-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.json`,
|
|
416
417
|
directory
|
|
@@ -443,9 +444,9 @@ function displayConsoleReport(report, scoring, elapsed) {
|
|
|
443
444
|
);
|
|
444
445
|
console.log();
|
|
445
446
|
}
|
|
446
|
-
const safetyColor = (0,
|
|
447
|
+
const safetyColor = (0, import_core3.getSeverityColor)(safetyRating, import_chalk.default);
|
|
447
448
|
console.log(
|
|
448
|
-
`AI Change Safety: ${safetyColor(`${(0,
|
|
449
|
+
`AI Change Safety: ${safetyColor(`${(0, import_core3.getSafetyIcon)(safetyRating)} ${safetyRating.toUpperCase()}`)}`
|
|
449
450
|
);
|
|
450
451
|
console.log(
|
|
451
452
|
`Score: ${import_chalk.default.bold(summary.score + "/100")} (${summary.rating})`
|
|
@@ -468,12 +469,12 @@ function displayConsoleReport(report, scoring, elapsed) {
|
|
|
468
469
|
];
|
|
469
470
|
for (const [name, val] of dims) {
|
|
470
471
|
const color = val >= 70 ? import_chalk.default.green : val >= 50 ? import_chalk.default.yellow : import_chalk.default.red;
|
|
471
|
-
console.log(` ${name.padEnd(22)} ${color((0,
|
|
472
|
+
console.log(` ${name.padEnd(22)} ${color((0, import_core3.getScoreBar)(val))} ${val}/100`);
|
|
472
473
|
}
|
|
473
474
|
if (issues.length > 0) {
|
|
474
475
|
console.log(import_chalk.default.bold("\n\u26A0\uFE0F Issues\n"));
|
|
475
476
|
for (const issue of issues) {
|
|
476
|
-
const sev = (0,
|
|
477
|
+
const sev = (0, import_core3.getSeverityColor)(issue.severity, import_chalk.default);
|
|
477
478
|
console.log(`${sev(issue.severity.toUpperCase())} ${issue.message}`);
|
|
478
479
|
if (issue.suggestion)
|
|
479
480
|
console.log(
|
package/dist/cli.mjs
CHANGED
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { Issue, IssueType, ToolScoringOutput } from '@aiready/core';
|
|
1
|
+
import { ToolProvider, Issue, IssueType, ToolScoringOutput } from '@aiready/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Testability Tool Provider
|
|
5
|
+
*/
|
|
6
|
+
declare const TestabilityProvider: ToolProvider;
|
|
2
7
|
|
|
3
8
|
interface TestabilityOptions {
|
|
4
9
|
/** Root directory to scan */
|
|
@@ -73,4 +78,4 @@ declare function analyzeTestability(options: TestabilityOptions): Promise<Testab
|
|
|
73
78
|
*/
|
|
74
79
|
declare function calculateTestabilityScore(report: TestabilityReport): ToolScoringOutput;
|
|
75
80
|
|
|
76
|
-
export { type TestabilityIssue, type TestabilityOptions, type TestabilityReport, analyzeTestability, calculateTestabilityScore };
|
|
81
|
+
export { type TestabilityIssue, type TestabilityOptions, TestabilityProvider, type TestabilityReport, analyzeTestability, calculateTestabilityScore };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { Issue, IssueType, ToolScoringOutput } from '@aiready/core';
|
|
1
|
+
import { ToolProvider, Issue, IssueType, ToolScoringOutput } from '@aiready/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Testability Tool Provider
|
|
5
|
+
*/
|
|
6
|
+
declare const TestabilityProvider: ToolProvider;
|
|
2
7
|
|
|
3
8
|
interface TestabilityOptions {
|
|
4
9
|
/** Root directory to scan */
|
|
@@ -73,4 +78,4 @@ declare function analyzeTestability(options: TestabilityOptions): Promise<Testab
|
|
|
73
78
|
*/
|
|
74
79
|
declare function calculateTestabilityScore(report: TestabilityReport): ToolScoringOutput;
|
|
75
80
|
|
|
76
|
-
export { type TestabilityIssue, type TestabilityOptions, type TestabilityReport, analyzeTestability, calculateTestabilityScore };
|
|
81
|
+
export { type TestabilityIssue, type TestabilityOptions, TestabilityProvider, type TestabilityReport, analyzeTestability, calculateTestabilityScore };
|
package/dist/index.js
CHANGED
|
@@ -20,10 +20,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
TestabilityProvider: () => TestabilityProvider,
|
|
23
24
|
analyzeTestability: () => analyzeTestability,
|
|
24
25
|
calculateTestabilityScore: () => calculateTestabilityScore
|
|
25
26
|
});
|
|
26
27
|
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
var import_core4 = require("@aiready/core");
|
|
29
|
+
|
|
30
|
+
// src/provider.ts
|
|
31
|
+
var import_core3 = require("@aiready/core");
|
|
27
32
|
|
|
28
33
|
// src/analyzer.ts
|
|
29
34
|
var import_core = require("@aiready/core");
|
|
@@ -304,6 +309,7 @@ async function analyzeTestability(options) {
|
|
|
304
309
|
}
|
|
305
310
|
|
|
306
311
|
// src/scoring.ts
|
|
312
|
+
var import_core2 = require("@aiready/core");
|
|
307
313
|
function calculateTestabilityScore(report) {
|
|
308
314
|
const { summary, rawData, recommendations } = report;
|
|
309
315
|
const factors = [
|
|
@@ -341,7 +347,7 @@ function calculateTestabilityScore(report) {
|
|
|
341
347
|
})
|
|
342
348
|
);
|
|
343
349
|
return {
|
|
344
|
-
toolName:
|
|
350
|
+
toolName: import_core2.ToolName.TestabilityIndex,
|
|
345
351
|
score: summary.score,
|
|
346
352
|
rawMetrics: {
|
|
347
353
|
...rawData,
|
|
@@ -353,8 +359,47 @@ function calculateTestabilityScore(report) {
|
|
|
353
359
|
recommendations: recs
|
|
354
360
|
};
|
|
355
361
|
}
|
|
362
|
+
|
|
363
|
+
// src/provider.ts
|
|
364
|
+
var TestabilityProvider = {
|
|
365
|
+
id: import_core3.ToolName.TestabilityIndex,
|
|
366
|
+
alias: ["testability", "tests", "verification"],
|
|
367
|
+
async analyze(options) {
|
|
368
|
+
const report = await analyzeTestability(options);
|
|
369
|
+
const results = report.issues.map((i) => ({
|
|
370
|
+
fileName: i.location.file,
|
|
371
|
+
issues: [i],
|
|
372
|
+
metrics: {
|
|
373
|
+
testabilityScore: report.summary.score
|
|
374
|
+
}
|
|
375
|
+
}));
|
|
376
|
+
return import_core3.SpokeOutputSchema.parse({
|
|
377
|
+
results,
|
|
378
|
+
summary: report.summary,
|
|
379
|
+
metadata: {
|
|
380
|
+
toolName: import_core3.ToolName.TestabilityIndex,
|
|
381
|
+
version: "0.2.5",
|
|
382
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
383
|
+
rawData: report.rawData
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
score(output, options) {
|
|
388
|
+
const report = {
|
|
389
|
+
summary: output.summary,
|
|
390
|
+
rawData: output.metadata.rawData,
|
|
391
|
+
recommendations: output.summary.recommendations || []
|
|
392
|
+
};
|
|
393
|
+
return calculateTestabilityScore(report);
|
|
394
|
+
},
|
|
395
|
+
defaultWeight: 10
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// src/index.ts
|
|
399
|
+
import_core4.ToolRegistry.register(TestabilityProvider);
|
|
356
400
|
// Annotate the CommonJS export names for ESM import in node:
|
|
357
401
|
0 && (module.exports = {
|
|
402
|
+
TestabilityProvider,
|
|
358
403
|
analyzeTestability,
|
|
359
404
|
calculateTestabilityScore
|
|
360
405
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,54 @@
|
|
|
1
1
|
import {
|
|
2
2
|
analyzeTestability,
|
|
3
3
|
calculateTestabilityScore
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-2QLLQPTL.mjs";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
import { ToolRegistry } from "@aiready/core";
|
|
8
|
+
|
|
9
|
+
// src/provider.ts
|
|
10
|
+
import {
|
|
11
|
+
ToolName,
|
|
12
|
+
SpokeOutputSchema
|
|
13
|
+
} from "@aiready/core";
|
|
14
|
+
var TestabilityProvider = {
|
|
15
|
+
id: ToolName.TestabilityIndex,
|
|
16
|
+
alias: ["testability", "tests", "verification"],
|
|
17
|
+
async analyze(options) {
|
|
18
|
+
const report = await analyzeTestability(options);
|
|
19
|
+
const results = report.issues.map((i) => ({
|
|
20
|
+
fileName: i.location.file,
|
|
21
|
+
issues: [i],
|
|
22
|
+
metrics: {
|
|
23
|
+
testabilityScore: report.summary.score
|
|
24
|
+
}
|
|
25
|
+
}));
|
|
26
|
+
return SpokeOutputSchema.parse({
|
|
27
|
+
results,
|
|
28
|
+
summary: report.summary,
|
|
29
|
+
metadata: {
|
|
30
|
+
toolName: ToolName.TestabilityIndex,
|
|
31
|
+
version: "0.2.5",
|
|
32
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
33
|
+
rawData: report.rawData
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
score(output, options) {
|
|
38
|
+
const report = {
|
|
39
|
+
summary: output.summary,
|
|
40
|
+
rawData: output.metadata.rawData,
|
|
41
|
+
recommendations: output.summary.recommendations || []
|
|
42
|
+
};
|
|
43
|
+
return calculateTestabilityScore(report);
|
|
44
|
+
},
|
|
45
|
+
defaultWeight: 10
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/index.ts
|
|
49
|
+
ToolRegistry.register(TestabilityProvider);
|
|
5
50
|
export {
|
|
51
|
+
TestabilityProvider,
|
|
6
52
|
analyzeTestability,
|
|
7
53
|
calculateTestabilityScore
|
|
8
54
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiready/testability",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
43
|
+
"@aiready/core": "0.21.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^24.0.0",
|
package/src/analyzer.ts
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
* 5. Observability (return values vs. external state mutations)
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
scanFiles,
|
|
15
|
+
calculateTestabilityIndex,
|
|
16
|
+
Severity,
|
|
17
|
+
IssueType,
|
|
18
|
+
} from '@aiready/core';
|
|
14
19
|
import { readFileSync, existsSync } from 'fs';
|
|
15
20
|
import { join } from 'path';
|
|
16
21
|
import { parse } from '@typescript-eslint/typescript-estree';
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import { ToolRegistry } from '@aiready/core';
|
|
2
|
+
import { TestabilityProvider } from './provider';
|
|
3
|
+
|
|
4
|
+
// Register with global registry
|
|
5
|
+
ToolRegistry.register(TestabilityProvider);
|
|
6
|
+
|
|
1
7
|
export { analyzeTestability } from './analyzer';
|
|
2
8
|
export { calculateTestabilityScore } from './scoring';
|
|
9
|
+
export { TestabilityProvider };
|
|
3
10
|
export type {
|
|
4
11
|
TestabilityOptions,
|
|
5
12
|
TestabilityReport,
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ToolProvider,
|
|
3
|
+
ToolName,
|
|
4
|
+
SpokeOutput,
|
|
5
|
+
ScanOptions,
|
|
6
|
+
ToolScoringOutput,
|
|
7
|
+
AnalysisResult,
|
|
8
|
+
SpokeOutputSchema,
|
|
9
|
+
} from '@aiready/core';
|
|
10
|
+
import { analyzeTestability } from './analyzer';
|
|
11
|
+
import { calculateTestabilityScore } from './scoring';
|
|
12
|
+
import { TestabilityOptions, TestabilityReport } from './types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Testability Tool Provider
|
|
16
|
+
*/
|
|
17
|
+
export const TestabilityProvider: ToolProvider = {
|
|
18
|
+
id: ToolName.TestabilityIndex,
|
|
19
|
+
alias: ['testability', 'tests', 'verification'],
|
|
20
|
+
|
|
21
|
+
async analyze(options: ScanOptions): Promise<SpokeOutput> {
|
|
22
|
+
const report = await analyzeTestability(options as TestabilityOptions);
|
|
23
|
+
|
|
24
|
+
const results: AnalysisResult[] = report.issues.map((i) => ({
|
|
25
|
+
fileName: i.location.file,
|
|
26
|
+
issues: [i] as any[],
|
|
27
|
+
metrics: {
|
|
28
|
+
testabilityScore: report.summary.score,
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
return SpokeOutputSchema.parse({
|
|
33
|
+
results,
|
|
34
|
+
summary: report.summary,
|
|
35
|
+
metadata: {
|
|
36
|
+
toolName: ToolName.TestabilityIndex,
|
|
37
|
+
version: '0.2.5',
|
|
38
|
+
timestamp: new Date().toISOString(),
|
|
39
|
+
rawData: report.rawData,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
score(output: SpokeOutput, options: ScanOptions): ToolScoringOutput {
|
|
45
|
+
const report = {
|
|
46
|
+
summary: output.summary,
|
|
47
|
+
rawData: (output.metadata as any).rawData,
|
|
48
|
+
recommendations: (output.summary as any).recommendations || [],
|
|
49
|
+
} as unknown as TestabilityReport;
|
|
50
|
+
|
|
51
|
+
return calculateTestabilityScore(report);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
defaultWeight: 10,
|
|
55
|
+
};
|
package/src/scoring.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ToolScoringOutput, ToolName } from '@aiready/core';
|
|
2
2
|
import type { TestabilityReport } from './types';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -50,7 +50,7 @@ export function calculateTestabilityScore(
|
|
|
50
50
|
);
|
|
51
51
|
|
|
52
52
|
return {
|
|
53
|
-
toolName:
|
|
53
|
+
toolName: ToolName.TestabilityIndex,
|
|
54
54
|
score: summary.score,
|
|
55
55
|
rawMetrics: {
|
|
56
56
|
...rawData,
|