@aiready/testability 0.2.2 → 0.2.5

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.2.1 build /Users/pengcao/projects/aiready/packages/testability
3
+ > @aiready/testability@0.2.4 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/cli.js 18.21 KB
13
- CJS dist/index.js 12.12 KB
14
- CJS ⚡️ Build success in 47ms
15
- ESM dist/index.mjs 152.00 B
16
12
  ESM dist/cli.mjs 5.75 KB
17
- ESM dist/chunk-DDNB7FI4.mjs 10.93 KB
18
- ESM ⚡️ Build success in 47ms
13
+ ESM dist/chunk-ULOUVO3Q.mjs 11.02 KB
14
+ ESM dist/index.mjs 152.00 B
15
+ ESM ⚡️ Build success in 133ms
16
+ CJS dist/cli.js 18.38 KB
17
+ CJS dist/index.js 12.28 KB
18
+ CJS ⚡️ Build success in 136ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 1066ms
20
+ DTS ⚡️ Build success in 4086ms
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.2.1 test /Users/pengcao/projects/aiready/packages/testability
3
+ > @aiready/testability@0.2.4 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) 46ms
9
+ ✓ src/__tests__/analyzer.test.ts (3 tests) 200ms
10
10
 
11
11
   Test Files  1 passed (1)
12
12
   Tests  3 passed (3)
13
-  Start at  20:13:24
14
-  Duration  1.83s (transform 330ms, setup 0ms, import 1.19s, tests 46ms, environment 0ms)
13
+  Start at  21:39:02
14
+  Duration  1.24s (transform 226ms, setup 0ms, import 767ms, tests 200ms, 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
+ };
@@ -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
@@ -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-ULOUVO3Q.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-ULOUVO3Q.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.2",
3
+ "version": "0.2.5",
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.2"
43
+ "@aiready/core": "0.19.5"
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 { scanFiles, calculateTestabilityIndex } from '@aiready/core';
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';
@@ -336,9 +341,9 @@ export async function analyzeTestability(
336
341
 
337
342
  if (!hasTestFramework) {
338
343
  issues.push({
339
- type: 'low-testability',
344
+ type: IssueType.LowTestability,
340
345
  dimension: 'framework',
341
- severity: 'critical',
346
+ severity: Severity.Critical,
342
347
  message:
343
348
  'No testing framework detected in package.json — AI changes cannot be verified at all.',
344
349
  location: { file: options.rootDir, line: 0 },
@@ -351,9 +356,9 @@ export async function analyzeTestability(
351
356
  const needed =
352
357
  Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
353
358
  issues.push({
354
- type: 'low-testability',
359
+ type: IssueType.LowTestability,
355
360
  dimension: 'test-coverage',
356
- severity: actualRatio === 0 ? 'critical' : 'major',
361
+ severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
357
362
  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
363
  location: { file: options.rootDir, line: 0 },
359
364
  suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`,
@@ -362,9 +367,9 @@ export async function analyzeTestability(
362
367
 
363
368
  if (indexResult.dimensions.purityScore < 50) {
364
369
  issues.push({
365
- type: 'low-testability',
370
+ type: IssueType.LowTestability,
366
371
  dimension: 'purity',
367
- severity: 'major',
372
+ severity: Severity.Major,
368
373
  message: `Only ${indexResult.dimensions.purityScore}% of functions are pure — side-effectful functions require complex test setup.`,
369
374
  location: { file: options.rootDir, line: 0 },
370
375
  suggestion:
@@ -374,9 +379,9 @@ export async function analyzeTestability(
374
379
 
375
380
  if (indexResult.dimensions.observabilityScore < 50) {
376
381
  issues.push({
377
- type: 'low-testability',
382
+ type: IssueType.LowTestability,
378
383
  dimension: 'observability',
379
- severity: 'major',
384
+ severity: Severity.Major,
380
385
  message: `Many functions mutate external state directly — outputs are invisible to unit tests.`,
381
386
  location: { file: options.rootDir, line: 0 },
382
387
  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'