@aiready/testability 0.6.21 → 0.6.23

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