@aiready/testability 0.1.5 → 0.1.8

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