@aiready/testability 0.4.4 → 0.4.9

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