@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.
package/src/analyzer.ts CHANGED
@@ -10,66 +10,16 @@
10
10
  * 5. Observability (return values vs. external state mutations)
11
11
  */
12
12
 
13
- import { readdirSync, statSync, readFileSync, existsSync } from 'fs';
14
- import { join, extname, basename } from 'path';
13
+ import { scanFiles, calculateTestabilityIndex } from '@aiready/core';
14
+ import { readFileSync, existsSync } from 'fs';
15
+ import { join } from 'path';
15
16
  import { parse } from '@typescript-eslint/typescript-estree';
16
17
  import type { TSESTree } from '@typescript-eslint/types';
17
- import { calculateTestabilityIndex } from '@aiready/core';
18
- import type { TestabilityOptions, TestabilityIssue, TestabilityReport } from './types';
19
-
20
- // ---------------------------------------------------------------------------
21
- // File classification
22
- // ---------------------------------------------------------------------------
23
-
24
- const SRC_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
25
- const DEFAULT_EXCLUDES = ['node_modules', 'dist', '.git', 'coverage', '.turbo', 'build'];
26
- const TEST_PATTERNS = [
27
- /\.(test|spec)\.(ts|tsx|js|jsx)$/,
28
- /__tests__\//,
29
- /\/tests?\//,
30
- /\/e2e\//,
31
- /\/fixtures\//,
32
- ];
33
-
34
- function isTestFile(filePath: string, extra?: string[]): boolean {
35
- if (TEST_PATTERNS.some(p => p.test(filePath))) return true;
36
- if (extra) return extra.some(p => filePath.includes(p));
37
- return false;
38
- }
39
-
40
- function isSourceFile(filePath: string): boolean {
41
- return SRC_EXTENSIONS.has(extname(filePath));
42
- }
43
-
44
- function collectFiles(
45
- dir: string,
46
- options: TestabilityOptions,
47
- depth = 0,
48
- ): string[] {
49
- if (depth > (options.maxDepth ?? 20)) return [];
50
- const excludes = [...DEFAULT_EXCLUDES, ...(options.exclude ?? [])];
51
- const files: string[] = [];
52
- let entries: string[];
53
- try {
54
- entries = readdirSync(dir);
55
- } catch {
56
- return files;
57
- }
58
- for (const entry of entries) {
59
- if (excludes.some(ex => entry === ex || entry.includes(ex))) continue;
60
- const full = join(dir, entry);
61
- let stat;
62
- try { stat = statSync(full); } catch { continue; }
63
- if (stat.isDirectory()) {
64
- files.push(...collectFiles(full, options, depth + 1));
65
- } else if (stat.isFile() && isSourceFile(full)) {
66
- if (!options.include || options.include.some(p => full.includes(p))) {
67
- files.push(full);
68
- }
69
- }
70
- }
71
- return files;
72
- }
18
+ import type {
19
+ TestabilityOptions,
20
+ TestabilityIssue,
21
+ TestabilityReport,
22
+ } from './types';
73
23
 
74
24
  // ---------------------------------------------------------------------------
75
25
  // Per-file analysis
@@ -85,20 +35,27 @@ interface FileAnalysis {
85
35
  externalStateMutations: number;
86
36
  }
87
37
 
88
- function countMethodsInInterface(node: TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeAliasDeclaration): number {
38
+ function countMethodsInInterface(
39
+ node: TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeAliasDeclaration
40
+ ): number {
89
41
  // Count method signatures
90
42
  if (node.type === 'TSInterfaceDeclaration') {
91
- return node.body.body.filter(m =>
92
- m.type === 'TSMethodSignature' || m.type === 'TSPropertySignature'
43
+ return node.body.body.filter(
44
+ (m) => m.type === 'TSMethodSignature' || m.type === 'TSPropertySignature'
93
45
  ).length;
94
46
  }
95
- if (node.type === 'TSTypeAliasDeclaration' && node.typeAnnotation.type === 'TSTypeLiteral') {
47
+ if (
48
+ node.type === 'TSTypeAliasDeclaration' &&
49
+ node.typeAnnotation.type === 'TSTypeLiteral'
50
+ ) {
96
51
  return node.typeAnnotation.members.length;
97
52
  }
98
53
  return 0;
99
54
  }
100
55
 
101
- function hasDependencyInjection(node: TSESTree.ClassDeclaration | TSESTree.ClassExpression): boolean {
56
+ function hasDependencyInjection(
57
+ node: TSESTree.ClassDeclaration | TSESTree.ClassExpression
58
+ ): boolean {
102
59
  // Look for a constructor with typed parameters (the most common DI pattern)
103
60
  for (const member of node.body.body) {
104
61
  if (
@@ -109,10 +66,12 @@ function hasDependencyInjection(node: TSESTree.ClassDeclaration | TSESTree.Class
109
66
  const fn = member.value;
110
67
  if (fn.params && fn.params.length > 0) {
111
68
  // If constructor takes parameters that are typed class/interface references, that's DI
112
- const typedParams = fn.params.filter(p => {
69
+ const typedParams = fn.params.filter((p) => {
113
70
  const param = p as any;
114
- return param.typeAnnotation != null ||
115
- param.parameter?.typeAnnotation != null;
71
+ return (
72
+ param.typeAnnotation != null ||
73
+ param.parameter?.typeAnnotation != null
74
+ );
116
75
  });
117
76
  if (typedParams.length > 0) return true;
118
77
  }
@@ -122,7 +81,10 @@ function hasDependencyInjection(node: TSESTree.ClassDeclaration | TSESTree.Class
122
81
  }
123
82
 
124
83
  function isPureFunction(
125
- fn: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression,
84
+ fn:
85
+ | TSESTree.FunctionDeclaration
86
+ | TSESTree.FunctionExpression
87
+ | TSESTree.ArrowFunctionExpression
126
88
  ): boolean {
127
89
  let hasReturn = false;
128
90
  let hasSideEffect = false;
@@ -132,14 +94,18 @@ function isPureFunction(
132
94
  if (
133
95
  node.type === 'AssignmentExpression' &&
134
96
  node.left.type === 'MemberExpression'
135
- ) hasSideEffect = true;
97
+ )
98
+ hasSideEffect = true;
136
99
  // Calls to console, process, global objects
137
100
  if (
138
101
  node.type === 'CallExpression' &&
139
102
  node.callee.type === 'MemberExpression' &&
140
103
  node.callee.object.type === 'Identifier' &&
141
- ['console', 'process', 'window', 'document', 'fs'].includes(node.callee.object.name)
142
- ) hasSideEffect = true;
104
+ ['console', 'process', 'window', 'document', 'fs'].includes(
105
+ node.callee.object.name
106
+ )
107
+ )
108
+ hasSideEffect = true;
143
109
 
144
110
  // Recurse
145
111
  for (const key of Object.keys(node)) {
@@ -147,7 +113,7 @@ function isPureFunction(
147
113
  const child = (node as any)[key];
148
114
  if (child && typeof child === 'object') {
149
115
  if (Array.isArray(child)) {
150
- child.forEach(c => c?.type && walk(c));
116
+ child.forEach((c) => c?.type && walk(c));
151
117
  } else if (child.type) {
152
118
  walk(child);
153
119
  }
@@ -156,7 +122,7 @@ function isPureFunction(
156
122
  }
157
123
 
158
124
  if (fn.body?.type === 'BlockStatement') {
159
- fn.body.body.forEach(s => walk(s));
125
+ fn.body.body.forEach((s) => walk(s));
160
126
  } else if (fn.body) {
161
127
  hasReturn = true; // arrow expression body
162
128
  }
@@ -165,7 +131,10 @@ function isPureFunction(
165
131
  }
166
132
 
167
133
  function hasExternalStateMutation(
168
- fn: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression,
134
+ fn:
135
+ | TSESTree.FunctionDeclaration
136
+ | TSESTree.FunctionExpression
137
+ | TSESTree.ArrowFunctionExpression
169
138
  ): boolean {
170
139
  let found = false;
171
140
  function walk(node: TSESTree.Node) {
@@ -173,17 +142,18 @@ function hasExternalStateMutation(
173
142
  if (
174
143
  node.type === 'AssignmentExpression' &&
175
144
  node.left.type === 'MemberExpression'
176
- ) found = true;
145
+ )
146
+ found = true;
177
147
  for (const key of Object.keys(node)) {
178
148
  if (key === 'parent') continue;
179
149
  const child = (node as any)[key];
180
150
  if (child && typeof child === 'object') {
181
- if (Array.isArray(child)) child.forEach(c => c?.type && walk(c));
151
+ if (Array.isArray(child)) child.forEach((c) => c?.type && walk(c));
182
152
  else if (child.type) walk(child);
183
153
  }
184
154
  }
185
155
  }
186
- if (fn.body?.type === 'BlockStatement') fn.body.body.forEach(s => walk(s));
156
+ if (fn.body?.type === 'BlockStatement') fn.body.body.forEach((s) => walk(s));
187
157
  return found;
188
158
  }
189
159
 
@@ -199,7 +169,11 @@ function analyzeFileTestability(filePath: string): FileAnalysis {
199
169
  };
200
170
 
201
171
  let code: string;
202
- try { code = readFileSync(filePath, 'utf-8'); } catch { return result; }
172
+ try {
173
+ code = readFileSync(filePath, 'utf-8');
174
+ } catch {
175
+ return result;
176
+ }
203
177
 
204
178
  let ast: TSESTree.Program;
205
179
  try {
@@ -208,7 +182,9 @@ function analyzeFileTestability(filePath: string): FileAnalysis {
208
182
  range: false,
209
183
  loc: false,
210
184
  });
211
- } catch { return result; }
185
+ } catch {
186
+ return result;
187
+ }
212
188
 
213
189
  function visit(node: TSESTree.Node) {
214
190
  if (
@@ -226,7 +202,10 @@ function analyzeFileTestability(filePath: string): FileAnalysis {
226
202
  if (hasDependencyInjection(node)) result.injectionPatterns++;
227
203
  }
228
204
 
229
- if (node.type === 'TSInterfaceDeclaration' || node.type === 'TSTypeAliasDeclaration') {
205
+ if (
206
+ node.type === 'TSInterfaceDeclaration' ||
207
+ node.type === 'TSTypeAliasDeclaration'
208
+ ) {
230
209
  result.totalInterfaces++;
231
210
  const methodCount = countMethodsInInterface(node as any);
232
211
  if (methodCount > 10) result.bloatedInterfaces++;
@@ -237,7 +216,7 @@ function analyzeFileTestability(filePath: string): FileAnalysis {
237
216
  if (key === 'parent') continue;
238
217
  const child = (node as any)[key];
239
218
  if (child && typeof child === 'object') {
240
- if (Array.isArray(child)) child.forEach(c => c?.type && visit(c));
219
+ if (Array.isArray(child)) child.forEach((c) => c?.type && visit(c));
241
220
  else if (child.type) visit(child);
242
221
  }
243
222
  }
@@ -260,22 +239,54 @@ function detectTestFramework(rootDir: string): boolean {
260
239
  ...(pkg.dependencies ?? {}),
261
240
  ...(pkg.devDependencies ?? {}),
262
241
  };
263
- const testFrameworks = ['jest', 'vitest', 'mocha', 'jasmine', 'ava', 'tap', 'pytest', 'unittest'];
264
- return testFrameworks.some(fw => allDeps[fw]);
265
- } catch { return false; }
242
+ const testFrameworks = [
243
+ 'jest',
244
+ 'vitest',
245
+ 'mocha',
246
+ 'jasmine',
247
+ 'ava',
248
+ 'tap',
249
+ 'pytest',
250
+ 'unittest',
251
+ ];
252
+ return testFrameworks.some((fw) => allDeps[fw]);
253
+ } catch {
254
+ return false;
255
+ }
266
256
  }
267
257
 
268
258
  // ---------------------------------------------------------------------------
269
259
  // Main analyzer
270
260
  // ---------------------------------------------------------------------------
271
261
 
262
+ const TEST_PATTERNS = [
263
+ /\.(test|spec)\.(ts|tsx|js|jsx)$/,
264
+ /__tests__\//,
265
+ /\/tests?\//,
266
+ /\/e2e\//,
267
+ /\/fixtures\//,
268
+ ];
269
+
270
+ function isTestFile(filePath: string, extra?: string[]): boolean {
271
+ if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
272
+ if (extra) return extra.some((p) => filePath.includes(p));
273
+ return false;
274
+ }
275
+
272
276
  export async function analyzeTestability(
273
- options: TestabilityOptions,
277
+ options: TestabilityOptions
274
278
  ): Promise<TestabilityReport> {
275
- const allFiles = collectFiles(options.rootDir, options);
279
+ // Use core scanFiles which respects .gitignore recursively
280
+ const allFiles = await scanFiles({
281
+ ...options,
282
+ include: options.include || ['**/*.{ts,tsx,js,jsx}'],
283
+ includeTests: true,
284
+ });
276
285
 
277
- const sourceFiles = allFiles.filter(f => !isTestFile(f, options.testPatterns));
278
- const testFiles = allFiles.filter(f => isTestFile(f, options.testPatterns));
286
+ const sourceFiles = allFiles.filter(
287
+ (f) => !isTestFile(f, options.testPatterns)
288
+ );
289
+ const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
279
290
 
280
291
  const aggregated: FileAnalysis = {
281
292
  pureFunctions: 0,
@@ -287,7 +298,15 @@ export async function analyzeTestability(
287
298
  externalStateMutations: 0,
288
299
  };
289
300
 
301
+ let processed = 0;
290
302
  for (const f of sourceFiles) {
303
+ processed++;
304
+ options.onProgress?.(
305
+ processed,
306
+ sourceFiles.length,
307
+ `testability: analyzing files`
308
+ );
309
+
291
310
  const a = analyzeFileTestability(f);
292
311
  for (const key of Object.keys(aggregated) as Array<keyof FileAnalysis>) {
293
312
  aggregated[key] += a[key];
@@ -312,21 +331,25 @@ export async function analyzeTestability(
312
331
  // Build issues
313
332
  const issues: TestabilityIssue[] = [];
314
333
  const minCoverage = options.minCoverageRatio ?? 0.3;
315
- const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
334
+ const actualRatio =
335
+ sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
316
336
 
317
337
  if (!hasTestFramework) {
318
338
  issues.push({
319
339
  type: 'low-testability',
320
340
  dimension: 'framework',
321
341
  severity: 'critical',
322
- message: 'No testing framework detected in package.json — AI changes cannot be verified at all.',
342
+ message:
343
+ 'No testing framework detected in package.json — AI changes cannot be verified at all.',
323
344
  location: { file: options.rootDir, line: 0 },
324
- suggestion: 'Add Jest, Vitest, or another testing framework as a devDependency.',
345
+ suggestion:
346
+ 'Add Jest, Vitest, or another testing framework as a devDependency.',
325
347
  });
326
348
  }
327
349
 
328
350
  if (actualRatio < minCoverage) {
329
- const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
351
+ const needed =
352
+ Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
330
353
  issues.push({
331
354
  type: 'low-testability',
332
355
  dimension: 'test-coverage',
@@ -344,7 +367,8 @@ export async function analyzeTestability(
344
367
  severity: 'major',
345
368
  message: `Only ${indexResult.dimensions.purityScore}% of functions are pure — side-effectful functions require complex test setup.`,
346
369
  location: { file: options.rootDir, line: 0 },
347
- suggestion: 'Extract pure transformation logic from I/O and mutation code.',
370
+ suggestion:
371
+ 'Extract pure transformation logic from I/O and mutation code.',
348
372
  });
349
373
  }
350
374
 
package/src/cli.ts CHANGED
@@ -7,15 +7,23 @@ import type { TestabilityOptions } from './types';
7
7
  import chalk from 'chalk';
8
8
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
9
9
  import { dirname } from 'path';
10
- import { loadConfig, mergeConfigWithDefaults, resolveOutputPath } from '@aiready/core';
10
+ import {
11
+ loadConfig,
12
+ mergeConfigWithDefaults,
13
+ resolveOutputPath,
14
+ } from '@aiready/core';
11
15
 
12
16
  const program = new Command();
13
17
 
14
18
  program
15
19
  .name('aiready-testability')
16
- .description('Measure how safely AI-generated changes can be verified in your codebase')
20
+ .description(
21
+ 'Measure how safely AI-generated changes can be verified in your codebase'
22
+ )
17
23
  .version('0.1.0')
18
- .addHelpText('after', `
24
+ .addHelpText(
25
+ 'after',
26
+ `
19
27
  DIMENSIONS MEASURED:
20
28
  Test Coverage Ratio of test files to source files
21
29
  Function Purity Pure functions are trivially AI-testable
@@ -33,10 +41,18 @@ EXAMPLES:
33
41
  aiready-testability . # Full analysis
34
42
  aiready-testability src/ --output json # JSON report
35
43
  aiready-testability . --min-coverage 0.5 # Stricter 50% threshold
36
- `)
44
+ `
45
+ )
37
46
  .argument('<directory>', 'Directory to analyze')
38
- .option('--min-coverage <ratio>', 'Minimum acceptable test/source ratio (default: 0.3)', '0.3')
39
- .option('--test-patterns <patterns>', 'Additional test file patterns (comma-separated)')
47
+ .option(
48
+ '--min-coverage <ratio>',
49
+ 'Minimum acceptable test/source ratio (default: 0.3)',
50
+ '0.3'
51
+ )
52
+ .option(
53
+ '--test-patterns <patterns>',
54
+ 'Additional test file patterns (comma-separated)'
55
+ )
40
56
  .option('--include <patterns>', 'File patterns to include (comma-separated)')
41
57
  .option('--exclude <patterns>', 'File patterns to exclude (comma-separated)')
42
58
  .option('-o, --output <format>', 'Output format: console|json', 'console')
@@ -52,7 +68,9 @@ EXAMPLES:
52
68
 
53
69
  const finalOptions: TestabilityOptions = {
54
70
  rootDir: directory,
55
- minCoverageRatio: parseFloat(options.minCoverage ?? '0.3') || mergedConfig.minCoverageRatio,
71
+ minCoverageRatio:
72
+ parseFloat(options.minCoverage ?? '0.3') ||
73
+ mergedConfig.minCoverageRatio,
56
74
  testPatterns: options.testPatterns?.split(','),
57
75
  include: options.include?.split(','),
58
76
  exclude: options.exclude?.split(','),
@@ -67,7 +85,7 @@ EXAMPLES:
67
85
  const outputPath = resolveOutputPath(
68
86
  options.outputFile,
69
87
  `testability-report-${new Date().toISOString().split('T')[0]}.json`,
70
- directory,
88
+ directory
71
89
  );
72
90
  const dir = dirname(outputPath);
73
91
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
@@ -82,21 +100,31 @@ program.parse();
82
100
 
83
101
  function safetyColor(rating: string) {
84
102
  switch (rating) {
85
- case 'safe': return chalk.green;
86
- case 'moderate-risk': return chalk.yellow;
87
- case 'high-risk': return chalk.red;
88
- case 'blind-risk': return chalk.bgRed.white;
89
- default: return chalk.white;
103
+ case 'safe':
104
+ return chalk.green;
105
+ case 'moderate-risk':
106
+ return chalk.yellow;
107
+ case 'high-risk':
108
+ return chalk.red;
109
+ case 'blind-risk':
110
+ return chalk.bgRed.white;
111
+ default:
112
+ return chalk.white;
90
113
  }
91
114
  }
92
115
 
93
116
  function safetyIcon(rating: string) {
94
117
  switch (rating) {
95
- case 'safe': return '✅';
96
- case 'moderate-risk': return '⚠️ ';
97
- case 'high-risk': return '🔴';
98
- case 'blind-risk': return '💀';
99
- default: return '';
118
+ case 'safe':
119
+ return '';
120
+ case 'moderate-risk':
121
+ return '⚠️ ';
122
+ case 'high-risk':
123
+ return '🔴';
124
+ case 'blind-risk':
125
+ return '💀';
126
+ default:
127
+ return '❓';
100
128
  }
101
129
  }
102
130
 
@@ -112,19 +140,33 @@ function displayConsoleReport(report: any, scoring: any, elapsed: string) {
112
140
  console.log(chalk.bold('\n🧪 Testability Analysis\n'));
113
141
 
114
142
  if (safetyRating === 'blind-risk') {
115
- console.log(chalk.bgRed.white.bold(
116
- ' 💀 BLIND RISK — NO TESTS DETECTED. AI-GENERATED CHANGES CANNOT BE VERIFIED. '
117
- ));
143
+ console.log(
144
+ chalk.bgRed.white.bold(
145
+ ' 💀 BLIND RISK — NO TESTS DETECTED. AI-GENERATED CHANGES CANNOT BE VERIFIED. '
146
+ )
147
+ );
118
148
  console.log();
119
149
  } else if (safetyRating === 'high-risk') {
120
- console.log(chalk.red.bold(` 🔴 HIGH RISK — Insufficient test coverage. AI changes may introduce silent bugs.`));
150
+ console.log(
151
+ chalk.red.bold(
152
+ ` 🔴 HIGH RISK — Insufficient test coverage. AI changes may introduce silent bugs.`
153
+ )
154
+ );
121
155
  console.log();
122
156
  }
123
157
 
124
- console.log(`AI Change Safety: ${safetyColor(safetyRating)(`${safetyIcon(safetyRating)} ${safetyRating.toUpperCase()}`)}`);
125
- console.log(`Score: ${chalk.bold(summary.score + '/100')} (${summary.rating})`);
126
- console.log(`Source Files: ${chalk.cyan(rawData.sourceFiles)} Test Files: ${chalk.cyan(rawData.testFiles)}`);
127
- console.log(`Coverage Ratio: ${chalk.bold(Math.round(summary.coverageRatio * 100) + '%')}`);
158
+ console.log(
159
+ `AI Change Safety: ${safetyColor(safetyRating)(`${safetyIcon(safetyRating)} ${safetyRating.toUpperCase()}`)}`
160
+ );
161
+ console.log(
162
+ `Score: ${chalk.bold(summary.score + '/100')} (${summary.rating})`
163
+ );
164
+ console.log(
165
+ `Source Files: ${chalk.cyan(rawData.sourceFiles)} Test Files: ${chalk.cyan(rawData.testFiles)}`
166
+ );
167
+ console.log(
168
+ `Coverage Ratio: ${chalk.bold(Math.round(summary.coverageRatio * 100) + '%')}`
169
+ );
128
170
  console.log(`Analysis Time: ${chalk.gray(elapsed + 's')}\n`);
129
171
 
130
172
  console.log(chalk.bold('📐 Dimension Scores\n'));
@@ -136,16 +178,25 @@ function displayConsoleReport(report: any, scoring: any, elapsed: string) {
136
178
  ['Observability', summary.dimensions.observabilityScore],
137
179
  ];
138
180
  for (const [name, val] of dims) {
139
- const color = val >= 70 ? chalk.green : val >= 50 ? chalk.yellow : chalk.red;
181
+ const color =
182
+ val >= 70 ? chalk.green : val >= 50 ? chalk.yellow : chalk.red;
140
183
  console.log(` ${name.padEnd(22)} ${color(scoreBar(val))} ${val}/100`);
141
184
  }
142
185
 
143
186
  if (issues.length > 0) {
144
187
  console.log(chalk.bold('\n⚠️ Issues\n'));
145
188
  for (const issue of issues) {
146
- const sev = issue.severity === 'critical' ? chalk.red : issue.severity === 'major' ? chalk.yellow : chalk.blue;
189
+ const sev =
190
+ issue.severity === 'critical'
191
+ ? chalk.red
192
+ : issue.severity === 'major'
193
+ ? chalk.yellow
194
+ : chalk.blue;
147
195
  console.log(`${sev(issue.severity.toUpperCase())} ${issue.message}`);
148
- if (issue.suggestion) console.log(` ${chalk.dim('→')} ${chalk.italic(issue.suggestion)}`);
196
+ if (issue.suggestion)
197
+ console.log(
198
+ ` ${chalk.dim('→')} ${chalk.italic(issue.suggestion)}`
199
+ );
149
200
  console.log();
150
201
  }
151
202
  }
package/src/scoring.ts CHANGED
@@ -1,11 +1,12 @@
1
- import { calculateTestabilityIndex } from '@aiready/core';
2
1
  import type { ToolScoringOutput } from '@aiready/core';
3
2
  import type { TestabilityReport } from './types';
4
3
 
5
4
  /**
6
5
  * Convert testability report into a ToolScoringOutput for the unified score.
7
6
  */
8
- export function calculateTestabilityScore(report: TestabilityReport): ToolScoringOutput {
7
+ export function calculateTestabilityScore(
8
+ report: TestabilityReport
9
+ ): ToolScoringOutput {
9
10
  const { summary, rawData, recommendations } = report;
10
11
 
11
12
  const factors: ToolScoringOutput['factors'] = [
@@ -36,13 +37,17 @@ export function calculateTestabilityScore(report: TestabilityReport): ToolScorin
36
37
  },
37
38
  ];
38
39
 
39
- const recs: ToolScoringOutput['recommendations'] = recommendations.map(action => ({
40
- action,
41
- estimatedImpact: summary.aiChangeSafetyRating === 'blind-risk' ? 15 : 8,
42
- priority: summary.aiChangeSafetyRating === 'blind-risk' || summary.aiChangeSafetyRating === 'high-risk'
43
- ? 'high'
44
- : 'medium',
45
- }));
40
+ const recs: ToolScoringOutput['recommendations'] = recommendations.map(
41
+ (action) => ({
42
+ action,
43
+ estimatedImpact: summary.aiChangeSafetyRating === 'blind-risk' ? 15 : 8,
44
+ priority:
45
+ summary.aiChangeSafetyRating === 'blind-risk' ||
46
+ summary.aiChangeSafetyRating === 'high-risk'
47
+ ? 'high'
48
+ : 'medium',
49
+ })
50
+ );
46
51
 
47
52
  return {
48
53
  toolName: 'testability',
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ScanOptions, Issue } from '@aiready/core';
1
+ import type { Issue } from '@aiready/core';
2
2
 
3
3
  export interface TestabilityOptions {
4
4
  /** Root directory to scan */
@@ -13,12 +13,20 @@ export interface TestabilityOptions {
13
13
  include?: string[];
14
14
  /** File glob patterns to exclude */
15
15
  exclude?: string[];
16
+ /** Progress callback */
17
+ onProgress?: (processed: number, total: number, message: string) => void;
16
18
  }
17
19
 
18
20
  export interface TestabilityIssue extends Issue {
19
21
  type: 'low-testability';
20
22
  /** Category of testability barrier */
21
- dimension: 'test-coverage' | 'purity' | 'dependency-injection' | 'interface-focus' | 'observability' | 'framework';
23
+ dimension:
24
+ | 'test-coverage'
25
+ | 'purity'
26
+ | 'dependency-injection'
27
+ | 'interface-focus'
28
+ | 'observability'
29
+ | 'framework';
22
30
  }
23
31
 
24
32
  export interface TestabilityReport {