@aiready/testability 0.4.15 → 0.4.17

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,251 @@
1
+ // src/analyzer.ts
2
+ import {
3
+ scanFiles,
4
+ calculateTestabilityIndex,
5
+ Severity,
6
+ IssueType,
7
+ emitProgress,
8
+ getParser
9
+ } from "@aiready/core";
10
+ import { readFileSync, existsSync } from "fs";
11
+ import { join } from "path";
12
+ async function analyzeFileTestability(filePath) {
13
+ const result = {
14
+ pureFunctions: 0,
15
+ totalFunctions: 0,
16
+ injectionPatterns: 0,
17
+ totalClasses: 0,
18
+ bloatedInterfaces: 0,
19
+ totalInterfaces: 0,
20
+ externalStateMutations: 0
21
+ };
22
+ const parser = getParser(filePath);
23
+ if (!parser) return result;
24
+ let code;
25
+ try {
26
+ code = readFileSync(filePath, "utf-8");
27
+ } catch {
28
+ return result;
29
+ }
30
+ try {
31
+ await parser.initialize();
32
+ const parseResult = parser.parse(code, filePath);
33
+ for (const exp of parseResult.exports) {
34
+ if (exp.type === "function") {
35
+ result.totalFunctions++;
36
+ if (exp.isPure) result.pureFunctions++;
37
+ if (exp.hasSideEffects) result.externalStateMutations++;
38
+ }
39
+ if (exp.type === "class") {
40
+ result.totalClasses++;
41
+ if (exp.parameters && exp.parameters.length > 0) {
42
+ result.injectionPatterns++;
43
+ }
44
+ }
45
+ if (exp.type === "interface") {
46
+ result.totalInterfaces++;
47
+ }
48
+ }
49
+ } catch (error) {
50
+ console.warn(`Testability: Failed to parse ${filePath}: ${error}`);
51
+ }
52
+ return result;
53
+ }
54
+ function detectTestFramework(rootDir) {
55
+ const manifests = [
56
+ { file: "package.json", deps: ["jest", "vitest", "mocha", "mocha", "jasmine", "ava", "tap"] },
57
+ { file: "requirements.txt", deps: ["pytest", "unittest", "nose"] },
58
+ { file: "pyproject.toml", deps: ["pytest"] },
59
+ { file: "pom.xml", deps: ["junit", "testng"] },
60
+ { file: "build.gradle", deps: ["junit", "testng"] },
61
+ { file: "go.mod", deps: ["testing"] }
62
+ // go testing is built-in
63
+ ];
64
+ for (const m of manifests) {
65
+ const p = join(rootDir, m.file);
66
+ if (existsSync(p)) {
67
+ if (m.file === "go.mod") return true;
68
+ try {
69
+ const content = readFileSync(p, "utf-8");
70
+ if (m.deps.some((d) => content.includes(d))) return true;
71
+ } catch {
72
+ }
73
+ }
74
+ }
75
+ return false;
76
+ }
77
+ var TEST_PATTERNS = [
78
+ /\.(test|spec)\.(ts|tsx|js|jsx)$/,
79
+ /_test\.go$/,
80
+ /test_.*\.py$/,
81
+ /.*_test\.py$/,
82
+ /.*Test\.java$/,
83
+ /.*Tests\.cs$/,
84
+ /__tests__\//,
85
+ /\/tests?\//,
86
+ /\/e2e\//,
87
+ /\/fixtures\//
88
+ ];
89
+ function isTestFile(filePath, extra) {
90
+ if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
91
+ if (extra) return extra.some((p) => filePath.includes(p));
92
+ return false;
93
+ }
94
+ async function analyzeTestability(options) {
95
+ const allFiles = await scanFiles({
96
+ ...options,
97
+ include: options.include || ["**/*.{ts,tsx,js,jsx,py,java,cs,go}"],
98
+ includeTests: true
99
+ });
100
+ const sourceFiles = allFiles.filter(
101
+ (f) => !isTestFile(f, options.testPatterns)
102
+ );
103
+ const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
104
+ const aggregated = {
105
+ pureFunctions: 0,
106
+ totalFunctions: 0,
107
+ injectionPatterns: 0,
108
+ totalClasses: 0,
109
+ bloatedInterfaces: 0,
110
+ totalInterfaces: 0,
111
+ externalStateMutations: 0
112
+ };
113
+ let processed = 0;
114
+ for (const f of sourceFiles) {
115
+ processed++;
116
+ emitProgress(
117
+ processed,
118
+ sourceFiles.length,
119
+ "testability",
120
+ "analyzing files",
121
+ options.onProgress
122
+ );
123
+ const a = await analyzeFileTestability(f);
124
+ for (const key of Object.keys(aggregated)) {
125
+ aggregated[key] += a[key];
126
+ }
127
+ }
128
+ const hasTestFramework = detectTestFramework(options.rootDir);
129
+ const indexResult = calculateTestabilityIndex({
130
+ testFiles: testFiles.length,
131
+ sourceFiles: sourceFiles.length,
132
+ pureFunctions: aggregated.pureFunctions,
133
+ totalFunctions: Math.max(1, aggregated.totalFunctions),
134
+ injectionPatterns: aggregated.injectionPatterns,
135
+ totalClasses: Math.max(1, aggregated.totalClasses),
136
+ bloatedInterfaces: aggregated.bloatedInterfaces,
137
+ totalInterfaces: Math.max(1, aggregated.totalInterfaces),
138
+ externalStateMutations: aggregated.externalStateMutations,
139
+ hasTestFramework
140
+ });
141
+ const issues = [];
142
+ const minCoverage = options.minCoverageRatio ?? 0.3;
143
+ const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
144
+ if (!hasTestFramework) {
145
+ issues.push({
146
+ type: IssueType.LowTestability,
147
+ dimension: "framework",
148
+ severity: Severity.Critical,
149
+ message: "No major testing framework detected \u2014 AI changes cannot be safely verified.",
150
+ location: { file: options.rootDir, line: 0 },
151
+ suggestion: "Add a testing framework (e.g., Jest, Pytest, JUnit) to enable automated verification."
152
+ });
153
+ }
154
+ if (actualRatio < minCoverage) {
155
+ const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
156
+ issues.push({
157
+ type: IssueType.LowTestability,
158
+ dimension: "test-coverage",
159
+ severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
160
+ message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
161
+ location: { file: options.rootDir, line: 0 },
162
+ suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
163
+ });
164
+ }
165
+ if (indexResult.dimensions.purityScore < 50) {
166
+ issues.push({
167
+ type: IssueType.LowTestability,
168
+ dimension: "purity",
169
+ severity: Severity.Major,
170
+ message: `Only ${indexResult.dimensions.purityScore}% of functions appear pure \u2014 side-effectful code is harder for AI to verify safely.`,
171
+ location: { file: options.rootDir, line: 0 },
172
+ suggestion: "Refactor complex side-effectful logic into pure functions where possible."
173
+ });
174
+ }
175
+ return {
176
+ summary: {
177
+ sourceFiles: sourceFiles.length,
178
+ testFiles: testFiles.length,
179
+ coverageRatio: Math.round(actualRatio * 100) / 100,
180
+ score: indexResult.score,
181
+ rating: indexResult.rating,
182
+ aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
183
+ dimensions: indexResult.dimensions
184
+ },
185
+ issues,
186
+ rawData: {
187
+ sourceFiles: sourceFiles.length,
188
+ testFiles: testFiles.length,
189
+ ...aggregated,
190
+ hasTestFramework
191
+ },
192
+ recommendations: indexResult.recommendations
193
+ };
194
+ }
195
+
196
+ // src/scoring.ts
197
+ import { ToolName } from "@aiready/core";
198
+ function calculateTestabilityScore(report) {
199
+ const { summary, rawData, recommendations } = report;
200
+ const factors = [
201
+ {
202
+ name: "Test Coverage",
203
+ impact: Math.round(summary.dimensions.testCoverageRatio - 50),
204
+ description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
205
+ },
206
+ {
207
+ name: "Function Purity",
208
+ impact: Math.round(summary.dimensions.purityScore - 50),
209
+ description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
210
+ },
211
+ {
212
+ name: "Dependency Injection",
213
+ impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
214
+ description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
215
+ },
216
+ {
217
+ name: "Interface Focus",
218
+ impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
219
+ description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
220
+ },
221
+ {
222
+ name: "Observability",
223
+ impact: Math.round(summary.dimensions.observabilityScore - 50),
224
+ description: `${rawData.externalStateMutations} functions mutate external state`
225
+ }
226
+ ];
227
+ const recs = recommendations.map(
228
+ (action) => ({
229
+ action,
230
+ estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
231
+ priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
232
+ })
233
+ );
234
+ return {
235
+ toolName: ToolName.TestabilityIndex,
236
+ score: summary.score,
237
+ rawMetrics: {
238
+ ...rawData,
239
+ rating: summary.rating,
240
+ aiChangeSafetyRating: summary.aiChangeSafetyRating,
241
+ coverageRatio: summary.coverageRatio
242
+ },
243
+ factors,
244
+ recommendations: recs
245
+ };
246
+ }
247
+
248
+ export {
249
+ analyzeTestability,
250
+ calculateTestabilityScore
251
+ };
@@ -0,0 +1,259 @@
1
+ // src/analyzer.ts
2
+ import {
3
+ scanFiles,
4
+ calculateTestabilityIndex,
5
+ Severity,
6
+ IssueType,
7
+ emitProgress,
8
+ getParser
9
+ } from "@aiready/core";
10
+ import { readFileSync, existsSync } from "fs";
11
+ import { join } from "path";
12
+ async function analyzeFileTestability(filePath) {
13
+ const result = {
14
+ pureFunctions: 0,
15
+ totalFunctions: 0,
16
+ injectionPatterns: 0,
17
+ totalClasses: 0,
18
+ bloatedInterfaces: 0,
19
+ totalInterfaces: 0,
20
+ externalStateMutations: 0
21
+ };
22
+ const parser = getParser(filePath);
23
+ if (!parser) return result;
24
+ let code;
25
+ try {
26
+ code = readFileSync(filePath, "utf-8");
27
+ } catch {
28
+ return result;
29
+ }
30
+ try {
31
+ await parser.initialize();
32
+ const parseResult = parser.parse(code, filePath);
33
+ for (const exp of parseResult.exports) {
34
+ console.log(`[DEBUG] export: ${exp.name}, type: ${exp.type}, methods: ${exp.methodCount}, props: ${exp.propertyCount}`);
35
+ if (exp.type === "function") {
36
+ result.totalFunctions++;
37
+ if (exp.isPure) result.pureFunctions++;
38
+ if (exp.hasSideEffects) result.externalStateMutations++;
39
+ }
40
+ if (exp.type === "class") {
41
+ result.totalClasses++;
42
+ if (exp.parameters && exp.parameters.length > 0) {
43
+ result.injectionPatterns++;
44
+ }
45
+ }
46
+ if (exp.type === "interface" || exp.type === "class") {
47
+ if (exp.type === "interface") result.totalInterfaces++;
48
+ const total = (exp.methodCount || 0) + (exp.propertyCount || 0);
49
+ if (total > 5) {
50
+ result.bloatedInterfaces++;
51
+ }
52
+ }
53
+ }
54
+ } catch (error) {
55
+ console.warn(`Testability: Failed to parse ${filePath}: ${error}`);
56
+ }
57
+ return result;
58
+ }
59
+ function detectTestFramework(rootDir) {
60
+ const manifests = [
61
+ {
62
+ file: "package.json",
63
+ deps: ["jest", "vitest", "mocha", "mocha", "jasmine", "ava", "tap"]
64
+ },
65
+ { file: "requirements.txt", deps: ["pytest", "unittest", "nose"] },
66
+ { file: "pyproject.toml", deps: ["pytest"] },
67
+ { file: "pom.xml", deps: ["junit", "testng"] },
68
+ { file: "build.gradle", deps: ["junit", "testng"] },
69
+ { file: "go.mod", deps: ["testing"] }
70
+ // go testing is built-in
71
+ ];
72
+ for (const m of manifests) {
73
+ const p = join(rootDir, m.file);
74
+ if (existsSync(p)) {
75
+ if (m.file === "go.mod") return true;
76
+ try {
77
+ const content = readFileSync(p, "utf-8");
78
+ if (m.deps.some((d) => content.includes(d))) return true;
79
+ } catch {
80
+ }
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+ var TEST_PATTERNS = [
86
+ /\.(test|spec)\.(ts|tsx|js|jsx)$/,
87
+ /_test\.go$/,
88
+ /test_.*\.py$/,
89
+ /.*_test\.py$/,
90
+ /.*Test\.java$/,
91
+ /.*Tests\.cs$/,
92
+ /__tests__\//,
93
+ /\/tests?\//,
94
+ /\/e2e\//,
95
+ /\/fixtures\//
96
+ ];
97
+ function isTestFile(filePath, extra) {
98
+ if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
99
+ if (extra) return extra.some((p) => filePath.includes(p));
100
+ return false;
101
+ }
102
+ async function analyzeTestability(options) {
103
+ const allFiles = await scanFiles({
104
+ ...options,
105
+ include: options.include || ["**/*.{ts,tsx,js,jsx,py,java,cs,go}"],
106
+ includeTests: true
107
+ });
108
+ const sourceFiles = allFiles.filter(
109
+ (f) => !isTestFile(f, options.testPatterns)
110
+ );
111
+ const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
112
+ const aggregated = {
113
+ pureFunctions: 0,
114
+ totalFunctions: 0,
115
+ injectionPatterns: 0,
116
+ totalClasses: 0,
117
+ bloatedInterfaces: 0,
118
+ totalInterfaces: 0,
119
+ externalStateMutations: 0
120
+ };
121
+ let processed = 0;
122
+ for (const f of sourceFiles) {
123
+ processed++;
124
+ emitProgress(
125
+ processed,
126
+ sourceFiles.length,
127
+ "testability",
128
+ "analyzing files",
129
+ options.onProgress
130
+ );
131
+ const a = await analyzeFileTestability(f);
132
+ for (const key of Object.keys(aggregated)) {
133
+ aggregated[key] += a[key];
134
+ }
135
+ }
136
+ const hasTestFramework = detectTestFramework(options.rootDir);
137
+ const indexResult = calculateTestabilityIndex({
138
+ testFiles: testFiles.length,
139
+ sourceFiles: sourceFiles.length,
140
+ pureFunctions: aggregated.pureFunctions,
141
+ totalFunctions: Math.max(1, aggregated.totalFunctions),
142
+ injectionPatterns: aggregated.injectionPatterns,
143
+ totalClasses: Math.max(1, aggregated.totalClasses),
144
+ bloatedInterfaces: aggregated.bloatedInterfaces,
145
+ totalInterfaces: Math.max(1, aggregated.totalInterfaces),
146
+ externalStateMutations: aggregated.externalStateMutations,
147
+ hasTestFramework
148
+ });
149
+ const issues = [];
150
+ const minCoverage = options.minCoverageRatio ?? 0.3;
151
+ const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
152
+ if (!hasTestFramework) {
153
+ issues.push({
154
+ type: IssueType.LowTestability,
155
+ dimension: "framework",
156
+ severity: Severity.Critical,
157
+ message: "No major testing framework detected \u2014 AI changes cannot be safely verified.",
158
+ location: { file: options.rootDir, line: 0 },
159
+ suggestion: "Add a testing framework (e.g., Jest, Pytest, JUnit) to enable automated verification."
160
+ });
161
+ }
162
+ if (actualRatio < minCoverage) {
163
+ const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
164
+ issues.push({
165
+ type: IssueType.LowTestability,
166
+ dimension: "test-coverage",
167
+ severity: actualRatio === 0 ? Severity.Critical : Severity.Major,
168
+ message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
169
+ location: { file: options.rootDir, line: 0 },
170
+ suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
171
+ });
172
+ }
173
+ if (indexResult.dimensions.purityScore < 50) {
174
+ issues.push({
175
+ type: IssueType.LowTestability,
176
+ dimension: "purity",
177
+ severity: Severity.Major,
178
+ message: `Only ${indexResult.dimensions.purityScore}% of functions appear pure \u2014 side-effectful code is harder for AI to verify safely.`,
179
+ location: { file: options.rootDir, line: 0 },
180
+ suggestion: "Refactor complex side-effectful logic into pure functions where possible."
181
+ });
182
+ }
183
+ return {
184
+ summary: {
185
+ sourceFiles: sourceFiles.length,
186
+ testFiles: testFiles.length,
187
+ coverageRatio: Math.round(actualRatio * 100) / 100,
188
+ score: indexResult.score,
189
+ rating: indexResult.rating,
190
+ aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
191
+ dimensions: indexResult.dimensions
192
+ },
193
+ issues,
194
+ rawData: {
195
+ sourceFiles: sourceFiles.length,
196
+ testFiles: testFiles.length,
197
+ ...aggregated,
198
+ hasTestFramework
199
+ },
200
+ recommendations: indexResult.recommendations
201
+ };
202
+ }
203
+
204
+ // src/scoring.ts
205
+ import { ToolName } from "@aiready/core";
206
+ function calculateTestabilityScore(report) {
207
+ const { summary, rawData, recommendations } = report;
208
+ const factors = [
209
+ {
210
+ name: "Test Coverage",
211
+ impact: Math.round(summary.dimensions.testCoverageRatio - 50),
212
+ description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
213
+ },
214
+ {
215
+ name: "Function Purity",
216
+ impact: Math.round(summary.dimensions.purityScore - 50),
217
+ description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
218
+ },
219
+ {
220
+ name: "Dependency Injection",
221
+ impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
222
+ description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
223
+ },
224
+ {
225
+ name: "Interface Focus",
226
+ impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
227
+ description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
228
+ },
229
+ {
230
+ name: "Observability",
231
+ impact: Math.round(summary.dimensions.observabilityScore - 50),
232
+ description: `${rawData.externalStateMutations} functions mutate external state`
233
+ }
234
+ ];
235
+ const recs = recommendations.map(
236
+ (action) => ({
237
+ action,
238
+ estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
239
+ priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
240
+ })
241
+ );
242
+ return {
243
+ toolName: ToolName.TestabilityIndex,
244
+ score: summary.score,
245
+ rawMetrics: {
246
+ ...rawData,
247
+ rating: summary.rating,
248
+ aiChangeSafetyRating: summary.aiChangeSafetyRating,
249
+ coverageRatio: summary.coverageRatio
250
+ },
251
+ factors,
252
+ recommendations: recs
253
+ };
254
+ }
255
+
256
+ export {
257
+ analyzeTestability,
258
+ calculateTestabilityScore
259
+ };