@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/.turbo/turbo-build.log +10 -10
- package/.turbo/turbo-lint.log +5 -0
- package/.turbo/turbo-test.log +5 -6
- package/dist/chunk-D7AV63F3.mjs +332 -0
- package/dist/chunk-DDNB7FI4.mjs +333 -0
- package/dist/chunk-PAP7ZRNB.mjs +366 -0
- package/dist/chunk-YLYLRZRS.mjs +363 -0
- package/dist/cli.js +93 -69
- package/dist/cli.mjs +46 -13
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +53 -58
- package/dist/index.mjs +1 -1
- package/package.json +3 -3
- package/src/__tests__/analyzer.test.ts +18 -6
- package/src/analyzer.ts +116 -92
- package/src/cli.ts +80 -29
- package/src/scoring.ts +14 -9
- package/src/types.ts +10 -2
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 {
|
|
14
|
-
import {
|
|
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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
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(
|
|
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
|
|
115
|
-
param.
|
|
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:
|
|
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
|
-
)
|
|
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(
|
|
142
|
-
|
|
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:
|
|
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
|
-
)
|
|
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 {
|
|
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 {
|
|
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 (
|
|
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 = [
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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(
|
|
278
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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 {
|
|
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(
|
|
20
|
+
.description(
|
|
21
|
+
'Measure how safely AI-generated changes can be verified in your codebase'
|
|
22
|
+
)
|
|
17
23
|
.version('0.1.0')
|
|
18
|
-
.addHelpText(
|
|
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(
|
|
39
|
-
|
|
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:
|
|
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':
|
|
86
|
-
|
|
87
|
-
case '
|
|
88
|
-
|
|
89
|
-
|
|
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':
|
|
96
|
-
|
|
97
|
-
case '
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
116
|
-
|
|
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(
|
|
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(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
console.log(
|
|
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 =
|
|
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 =
|
|
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)
|
|
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(
|
|
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(
|
|
40
|
-
action
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 {
|
|
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:
|
|
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 {
|