@aiready/testability 0.2.0 → 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.
@@ -1,6 +1,6 @@
1
1
 
2
2
  
3
- > @aiready/testability@0.1.12 build /Users/pengcao/projects/aiready/packages/testability
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
  CLI Building entry: src/cli.ts, src/index.ts
@@ -9,16 +9,16 @@
9
9
  CLI Target: es2020
10
10
  CJS Build start
11
11
  ESM Build start
12
+ CJS dist/index.js 12.28 KB
13
+ CJS dist/cli.js 18.38 KB
14
+ CJS ⚡️ Build success in 38ms
15
+ ESM dist/chunk-7MM2QISW.mjs 11.02 KB
12
16
  ESM dist/index.mjs 152.00 B
13
- ESM dist/chunk-DDNB7FI4.mjs 10.93 KB
14
17
  ESM dist/cli.mjs 5.75 KB
15
- ESM ⚡️ Build success in 117ms
16
- CJS dist/index.js 12.12 KB
17
- CJS dist/cli.js 18.21 KB
18
- CJS ⚡️ Build success in 117ms
18
+ ESM ⚡️ Build success in 38ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 3196ms
20
+ DTS ⚡️ Build success in 2233ms
21
21
  DTS dist/cli.d.ts 20.00 B
22
- DTS dist/index.d.ts 2.64 KB
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.64 KB
24
+ DTS dist/index.d.mts 2.66 KB
@@ -1,16 +1,16 @@
1
1
 
2
2
  
3
- > @aiready/testability@0.1.12 test /Users/pengcao/projects/aiready/packages/testability
3
+ > @aiready/testability@0.2.2 test /Users/pengcao/projects/aiready/packages/testability
4
4
  > vitest run
5
5
 
6
6
  [?25l
7
7
   RUN  v4.0.18 /Users/pengcao/projects/aiready/packages/testability
8
8
 
9
- ✓ src/__tests__/analyzer.test.ts (3 tests) 344ms
9
+ ✓ src/__tests__/analyzer.test.ts (3 tests) 217ms
10
10
 
11
11
   Test Files  1 passed (1)
12
12
   Tests  3 passed (3)
13
-  Start at  16:12:21
14
-  Duration  2.16s (transform 365ms, setup 0ms, import 1.64s, tests 344ms, environment 0ms)
13
+  Start at  20:01:55
14
+  Duration  2.14s (transform 162ms, setup 0ms, import 1.21s, tests 217ms, environment 0ms)
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: "low-testability",
247
+ type: import_core.IssueType.LowTestability,
248
248
  dimension: "framework",
249
- severity: "critical",
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: "low-testability",
258
+ type: import_core.IssueType.LowTestability,
259
259
  dimension: "test-coverage",
260
- severity: actualRatio === 0 ? "critical" : "major",
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: "low-testability",
268
+ type: import_core.IssueType.LowTestability,
269
269
  dimension: "purity",
270
- severity: "major",
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: "low-testability",
278
+ type: import_core.IssueType.LowTestability,
279
279
  dimension: "observability",
280
- severity: "major",
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
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  analyzeTestability,
4
4
  calculateTestabilityScore
5
- } from "./chunk-DDNB7FI4.mjs";
5
+ } from "./chunk-7MM2QISW.mjs";
6
6
 
7
7
  // src/cli.ts
8
8
  import { Command } from "commander";
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: 'low-testability';
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: 'low-testability';
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: "low-testability",
246
+ type: import_core.IssueType.LowTestability,
247
247
  dimension: "framework",
248
- severity: "critical",
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: "low-testability",
257
+ type: import_core.IssueType.LowTestability,
258
258
  dimension: "test-coverage",
259
- severity: actualRatio === 0 ? "critical" : "major",
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: "low-testability",
267
+ type: import_core.IssueType.LowTestability,
268
268
  dimension: "purity",
269
- severity: "major",
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: "low-testability",
277
+ type: import_core.IssueType.LowTestability,
278
278
  dimension: "observability",
279
- severity: "major",
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
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  analyzeTestability,
3
3
  calculateTestabilityScore
4
- } from "./chunk-DDNB7FI4.mjs";
4
+ } from "./chunk-7MM2QISW.mjs";
5
5
  export {
6
6
  analyzeTestability,
7
7
  calculateTestabilityScore
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiready/testability",
3
- "version": "0.2.0",
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.0"
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: 'low-testability',
339
+ type: IssueType.LowTestability,
340
340
  dimension: 'framework',
341
- severity: 'critical',
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: 'low-testability',
354
+ type: IssueType.LowTestability,
355
355
  dimension: 'test-coverage',
356
- severity: actualRatio === 0 ? 'critical' : 'major',
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: 'low-testability',
365
+ type: IssueType.LowTestability,
366
366
  dimension: 'purity',
367
- severity: 'major',
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: 'low-testability',
377
+ type: IssueType.LowTestability,
378
378
  dimension: 'observability',
379
- severity: 'major',
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: 'low-testability';
21
+ type: IssueType.LowTestability;
22
22
  /** Category of testability barrier */
23
23
  dimension:
24
24
  | 'test-coverage'