@aiready/testability 0.1.1

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,74 @@
1
+ import { Issue, ToolScoringOutput } from '@aiready/core';
2
+
3
+ interface TestabilityOptions {
4
+ /** Root directory to scan */
5
+ rootDir: string;
6
+ /** Minimum test-to-source ratio to consider acceptable (default: 0.3) */
7
+ minCoverageRatio?: number;
8
+ /** Custom test file patterns in addition to built-ins */
9
+ testPatterns?: string[];
10
+ /** Maximum scan depth */
11
+ maxDepth?: number;
12
+ /** File glob patterns to include */
13
+ include?: string[];
14
+ /** File glob patterns to exclude */
15
+ exclude?: string[];
16
+ }
17
+ interface TestabilityIssue extends Issue {
18
+ type: 'low-testability';
19
+ /** Category of testability barrier */
20
+ dimension: 'test-coverage' | 'purity' | 'dependency-injection' | 'interface-focus' | 'observability' | 'framework';
21
+ }
22
+ interface TestabilityReport {
23
+ summary: {
24
+ sourceFiles: number;
25
+ testFiles: number;
26
+ coverageRatio: number;
27
+ score: number;
28
+ rating: 'excellent' | 'good' | 'moderate' | 'poor' | 'unverifiable';
29
+ /** THE MOST IMPORTANT FIELD: is AI-generated code safe to ship? */
30
+ aiChangeSafetyRating: 'safe' | 'moderate-risk' | 'high-risk' | 'blind-risk';
31
+ dimensions: {
32
+ testCoverageRatio: number;
33
+ purityScore: number;
34
+ dependencyInjectionScore: number;
35
+ interfaceFocusScore: number;
36
+ observabilityScore: number;
37
+ };
38
+ };
39
+ issues: TestabilityIssue[];
40
+ rawData: {
41
+ sourceFiles: number;
42
+ testFiles: number;
43
+ pureFunctions: number;
44
+ totalFunctions: number;
45
+ injectionPatterns: number;
46
+ totalClasses: number;
47
+ bloatedInterfaces: number;
48
+ totalInterfaces: number;
49
+ externalStateMutations: number;
50
+ hasTestFramework: boolean;
51
+ };
52
+ recommendations: string[];
53
+ }
54
+
55
+ /**
56
+ * Testability analyzer.
57
+ *
58
+ * Walks the codebase and measures 5 structural dimensions that determine
59
+ * whether AI-generated changes can be safely verified:
60
+ * 1. Test file coverage ratio
61
+ * 2. Pure function prevalence
62
+ * 3. Dependency injection patterns
63
+ * 4. Interface focus (bloated interface detection)
64
+ * 5. Observability (return values vs. external state mutations)
65
+ */
66
+
67
+ declare function analyzeTestability(options: TestabilityOptions): Promise<TestabilityReport>;
68
+
69
+ /**
70
+ * Convert testability report into a ToolScoringOutput for the unified score.
71
+ */
72
+ declare function calculateTestabilityScore(report: TestabilityReport): ToolScoringOutput;
73
+
74
+ export { type TestabilityIssue, type TestabilityOptions, type TestabilityReport, analyzeTestability, calculateTestabilityScore };
@@ -0,0 +1,74 @@
1
+ import { Issue, ToolScoringOutput } from '@aiready/core';
2
+
3
+ interface TestabilityOptions {
4
+ /** Root directory to scan */
5
+ rootDir: string;
6
+ /** Minimum test-to-source ratio to consider acceptable (default: 0.3) */
7
+ minCoverageRatio?: number;
8
+ /** Custom test file patterns in addition to built-ins */
9
+ testPatterns?: string[];
10
+ /** Maximum scan depth */
11
+ maxDepth?: number;
12
+ /** File glob patterns to include */
13
+ include?: string[];
14
+ /** File glob patterns to exclude */
15
+ exclude?: string[];
16
+ }
17
+ interface TestabilityIssue extends Issue {
18
+ type: 'low-testability';
19
+ /** Category of testability barrier */
20
+ dimension: 'test-coverage' | 'purity' | 'dependency-injection' | 'interface-focus' | 'observability' | 'framework';
21
+ }
22
+ interface TestabilityReport {
23
+ summary: {
24
+ sourceFiles: number;
25
+ testFiles: number;
26
+ coverageRatio: number;
27
+ score: number;
28
+ rating: 'excellent' | 'good' | 'moderate' | 'poor' | 'unverifiable';
29
+ /** THE MOST IMPORTANT FIELD: is AI-generated code safe to ship? */
30
+ aiChangeSafetyRating: 'safe' | 'moderate-risk' | 'high-risk' | 'blind-risk';
31
+ dimensions: {
32
+ testCoverageRatio: number;
33
+ purityScore: number;
34
+ dependencyInjectionScore: number;
35
+ interfaceFocusScore: number;
36
+ observabilityScore: number;
37
+ };
38
+ };
39
+ issues: TestabilityIssue[];
40
+ rawData: {
41
+ sourceFiles: number;
42
+ testFiles: number;
43
+ pureFunctions: number;
44
+ totalFunctions: number;
45
+ injectionPatterns: number;
46
+ totalClasses: number;
47
+ bloatedInterfaces: number;
48
+ totalInterfaces: number;
49
+ externalStateMutations: number;
50
+ hasTestFramework: boolean;
51
+ };
52
+ recommendations: string[];
53
+ }
54
+
55
+ /**
56
+ * Testability analyzer.
57
+ *
58
+ * Walks the codebase and measures 5 structural dimensions that determine
59
+ * whether AI-generated changes can be safely verified:
60
+ * 1. Test file coverage ratio
61
+ * 2. Pure function prevalence
62
+ * 3. Dependency injection patterns
63
+ * 4. Interface focus (bloated interface detection)
64
+ * 5. Observability (return values vs. external state mutations)
65
+ */
66
+
67
+ declare function analyzeTestability(options: TestabilityOptions): Promise<TestabilityReport>;
68
+
69
+ /**
70
+ * Convert testability report into a ToolScoringOutput for the unified score.
71
+ */
72
+ declare function calculateTestabilityScore(report: TestabilityReport): ToolScoringOutput;
73
+
74
+ export { type TestabilityIssue, type TestabilityOptions, type TestabilityReport, analyzeTestability, calculateTestabilityScore };
package/dist/index.js ADDED
@@ -0,0 +1,365 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ analyzeTestability: () => analyzeTestability,
24
+ calculateTestabilityScore: () => calculateTestabilityScore
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/analyzer.ts
29
+ var import_fs = require("fs");
30
+ var import_path = require("path");
31
+ var import_typescript_estree = require("@typescript-eslint/typescript-estree");
32
+ var import_core = require("@aiready/core");
33
+ var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
34
+ var DEFAULT_EXCLUDES = ["node_modules", "dist", ".git", "coverage", ".turbo", "build"];
35
+ var TEST_PATTERNS = [
36
+ /\.(test|spec)\.(ts|tsx|js|jsx)$/,
37
+ /__tests__\//,
38
+ /\/tests?\//,
39
+ /\/e2e\//,
40
+ /\/fixtures\//
41
+ ];
42
+ function isTestFile(filePath, extra) {
43
+ if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
44
+ if (extra) return extra.some((p) => filePath.includes(p));
45
+ return false;
46
+ }
47
+ function isSourceFile(filePath) {
48
+ return SRC_EXTENSIONS.has((0, import_path.extname)(filePath));
49
+ }
50
+ function collectFiles(dir, options, depth = 0) {
51
+ if (depth > (options.maxDepth ?? 20)) return [];
52
+ const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
53
+ const files = [];
54
+ let entries;
55
+ try {
56
+ entries = (0, import_fs.readdirSync)(dir);
57
+ } catch {
58
+ return files;
59
+ }
60
+ for (const entry of entries) {
61
+ if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
62
+ const full = (0, import_path.join)(dir, entry);
63
+ let stat;
64
+ try {
65
+ stat = (0, import_fs.statSync)(full);
66
+ } catch {
67
+ continue;
68
+ }
69
+ if (stat.isDirectory()) {
70
+ files.push(...collectFiles(full, options, depth + 1));
71
+ } else if (stat.isFile() && isSourceFile(full)) {
72
+ if (!options.include || options.include.some((p) => full.includes(p))) {
73
+ files.push(full);
74
+ }
75
+ }
76
+ }
77
+ return files;
78
+ }
79
+ function countMethodsInInterface(node) {
80
+ if (node.type === "TSInterfaceDeclaration") {
81
+ return node.body.body.filter(
82
+ (m) => m.type === "TSMethodSignature" || m.type === "TSPropertySignature"
83
+ ).length;
84
+ }
85
+ if (node.type === "TSTypeAliasDeclaration" && node.typeAnnotation.type === "TSTypeLiteral") {
86
+ return node.typeAnnotation.members.length;
87
+ }
88
+ return 0;
89
+ }
90
+ function hasDependencyInjection(node) {
91
+ for (const member of node.body.body) {
92
+ if (member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "constructor") {
93
+ const fn = member.value;
94
+ if (fn.params && fn.params.length > 0) {
95
+ const typedParams = fn.params.filter((p) => {
96
+ const param = p;
97
+ return param.typeAnnotation != null || param.parameter?.typeAnnotation != null;
98
+ });
99
+ if (typedParams.length > 0) return true;
100
+ }
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+ function isPureFunction(fn) {
106
+ let hasReturn = false;
107
+ let hasSideEffect = false;
108
+ function walk(node) {
109
+ if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
110
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression") hasSideEffect = true;
111
+ if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(node.callee.object.name)) hasSideEffect = true;
112
+ for (const key of Object.keys(node)) {
113
+ if (key === "parent") continue;
114
+ const child = node[key];
115
+ if (child && typeof child === "object") {
116
+ if (Array.isArray(child)) {
117
+ child.forEach((c) => c?.type && walk(c));
118
+ } else if (child.type) {
119
+ walk(child);
120
+ }
121
+ }
122
+ }
123
+ }
124
+ if (fn.body?.type === "BlockStatement") {
125
+ fn.body.body.forEach((s) => walk(s));
126
+ } else if (fn.body) {
127
+ hasReturn = true;
128
+ }
129
+ return hasReturn && !hasSideEffect;
130
+ }
131
+ function hasExternalStateMutation(fn) {
132
+ let found = false;
133
+ function walk(node) {
134
+ if (found) return;
135
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression") found = true;
136
+ for (const key of Object.keys(node)) {
137
+ if (key === "parent") continue;
138
+ const child = node[key];
139
+ if (child && typeof child === "object") {
140
+ if (Array.isArray(child)) child.forEach((c) => c?.type && walk(c));
141
+ else if (child.type) walk(child);
142
+ }
143
+ }
144
+ }
145
+ if (fn.body?.type === "BlockStatement") fn.body.body.forEach((s) => walk(s));
146
+ return found;
147
+ }
148
+ function analyzeFileTestability(filePath) {
149
+ const result = {
150
+ pureFunctions: 0,
151
+ totalFunctions: 0,
152
+ injectionPatterns: 0,
153
+ totalClasses: 0,
154
+ bloatedInterfaces: 0,
155
+ totalInterfaces: 0,
156
+ externalStateMutations: 0
157
+ };
158
+ let code;
159
+ try {
160
+ code = (0, import_fs.readFileSync)(filePath, "utf-8");
161
+ } catch {
162
+ return result;
163
+ }
164
+ let ast;
165
+ try {
166
+ ast = (0, import_typescript_estree.parse)(code, {
167
+ jsx: filePath.endsWith(".tsx") || filePath.endsWith(".jsx"),
168
+ range: false,
169
+ loc: false
170
+ });
171
+ } catch {
172
+ return result;
173
+ }
174
+ function visit(node) {
175
+ if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
176
+ result.totalFunctions++;
177
+ if (isPureFunction(node)) result.pureFunctions++;
178
+ if (hasExternalStateMutation(node)) result.externalStateMutations++;
179
+ }
180
+ if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
181
+ result.totalClasses++;
182
+ if (hasDependencyInjection(node)) result.injectionPatterns++;
183
+ }
184
+ if (node.type === "TSInterfaceDeclaration" || node.type === "TSTypeAliasDeclaration") {
185
+ result.totalInterfaces++;
186
+ const methodCount = countMethodsInInterface(node);
187
+ if (methodCount > 10) result.bloatedInterfaces++;
188
+ }
189
+ for (const key of Object.keys(node)) {
190
+ if (key === "parent") continue;
191
+ const child = node[key];
192
+ if (child && typeof child === "object") {
193
+ if (Array.isArray(child)) child.forEach((c) => c?.type && visit(c));
194
+ else if (child.type) visit(child);
195
+ }
196
+ }
197
+ }
198
+ ast.body.forEach(visit);
199
+ return result;
200
+ }
201
+ function detectTestFramework(rootDir) {
202
+ const pkgPath = (0, import_path.join)(rootDir, "package.json");
203
+ if (!(0, import_fs.existsSync)(pkgPath)) return false;
204
+ try {
205
+ const pkg = JSON.parse((0, import_fs.readFileSync)(pkgPath, "utf-8"));
206
+ const allDeps = {
207
+ ...pkg.dependencies ?? {},
208
+ ...pkg.devDependencies ?? {}
209
+ };
210
+ const testFrameworks = ["jest", "vitest", "mocha", "jasmine", "ava", "tap", "pytest", "unittest"];
211
+ return testFrameworks.some((fw) => allDeps[fw]);
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+ async function analyzeTestability(options) {
217
+ const allFiles = collectFiles(options.rootDir, options);
218
+ const sourceFiles = allFiles.filter((f) => !isTestFile(f, options.testPatterns));
219
+ const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
220
+ const aggregated = {
221
+ pureFunctions: 0,
222
+ totalFunctions: 0,
223
+ injectionPatterns: 0,
224
+ totalClasses: 0,
225
+ bloatedInterfaces: 0,
226
+ totalInterfaces: 0,
227
+ externalStateMutations: 0
228
+ };
229
+ for (const f of sourceFiles) {
230
+ const a = analyzeFileTestability(f);
231
+ for (const key of Object.keys(aggregated)) {
232
+ aggregated[key] += a[key];
233
+ }
234
+ }
235
+ const hasTestFramework = detectTestFramework(options.rootDir);
236
+ const indexResult = (0, import_core.calculateTestabilityIndex)({
237
+ testFiles: testFiles.length,
238
+ sourceFiles: sourceFiles.length,
239
+ pureFunctions: aggregated.pureFunctions,
240
+ totalFunctions: Math.max(1, aggregated.totalFunctions),
241
+ injectionPatterns: aggregated.injectionPatterns,
242
+ totalClasses: Math.max(1, aggregated.totalClasses),
243
+ bloatedInterfaces: aggregated.bloatedInterfaces,
244
+ totalInterfaces: Math.max(1, aggregated.totalInterfaces),
245
+ externalStateMutations: aggregated.externalStateMutations,
246
+ hasTestFramework
247
+ });
248
+ const issues = [];
249
+ const minCoverage = options.minCoverageRatio ?? 0.3;
250
+ const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
251
+ if (!hasTestFramework) {
252
+ issues.push({
253
+ type: "low-testability",
254
+ dimension: "framework",
255
+ severity: "critical",
256
+ message: "No testing framework detected in package.json \u2014 AI changes cannot be verified at all.",
257
+ location: { file: options.rootDir, line: 0 },
258
+ suggestion: "Add Jest, Vitest, or another testing framework as a devDependency."
259
+ });
260
+ }
261
+ if (actualRatio < minCoverage) {
262
+ const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
263
+ issues.push({
264
+ type: "low-testability",
265
+ dimension: "test-coverage",
266
+ severity: actualRatio === 0 ? "critical" : "major",
267
+ message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
268
+ location: { file: options.rootDir, line: 0 },
269
+ suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
270
+ });
271
+ }
272
+ if (indexResult.dimensions.purityScore < 50) {
273
+ issues.push({
274
+ type: "low-testability",
275
+ dimension: "purity",
276
+ severity: "major",
277
+ message: `Only ${indexResult.dimensions.purityScore}% of functions are pure \u2014 side-effectful functions require complex test setup.`,
278
+ location: { file: options.rootDir, line: 0 },
279
+ suggestion: "Extract pure transformation logic from I/O and mutation code."
280
+ });
281
+ }
282
+ if (indexResult.dimensions.observabilityScore < 50) {
283
+ issues.push({
284
+ type: "low-testability",
285
+ dimension: "observability",
286
+ severity: "major",
287
+ message: `Many functions mutate external state directly \u2014 outputs are invisible to unit tests.`,
288
+ location: { file: options.rootDir, line: 0 },
289
+ suggestion: "Prefer returning values over mutating shared state."
290
+ });
291
+ }
292
+ return {
293
+ summary: {
294
+ sourceFiles: sourceFiles.length,
295
+ testFiles: testFiles.length,
296
+ coverageRatio: Math.round(actualRatio * 100) / 100,
297
+ score: indexResult.score,
298
+ rating: indexResult.rating,
299
+ aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
300
+ dimensions: indexResult.dimensions
301
+ },
302
+ issues,
303
+ rawData: {
304
+ sourceFiles: sourceFiles.length,
305
+ testFiles: testFiles.length,
306
+ ...aggregated,
307
+ hasTestFramework
308
+ },
309
+ recommendations: indexResult.recommendations
310
+ };
311
+ }
312
+
313
+ // src/scoring.ts
314
+ function calculateTestabilityScore(report) {
315
+ const { summary, rawData, recommendations } = report;
316
+ const factors = [
317
+ {
318
+ name: "Test Coverage",
319
+ impact: Math.round(summary.dimensions.testCoverageRatio - 50),
320
+ description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
321
+ },
322
+ {
323
+ name: "Function Purity",
324
+ impact: Math.round(summary.dimensions.purityScore - 50),
325
+ description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
326
+ },
327
+ {
328
+ name: "Dependency Injection",
329
+ impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
330
+ description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
331
+ },
332
+ {
333
+ name: "Interface Focus",
334
+ impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
335
+ description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
336
+ },
337
+ {
338
+ name: "Observability",
339
+ impact: Math.round(summary.dimensions.observabilityScore - 50),
340
+ description: `${rawData.externalStateMutations} functions mutate external state`
341
+ }
342
+ ];
343
+ const recs = recommendations.map((action) => ({
344
+ action,
345
+ estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
346
+ priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
347
+ }));
348
+ return {
349
+ toolName: "testability",
350
+ score: summary.score,
351
+ rawMetrics: {
352
+ ...rawData,
353
+ rating: summary.rating,
354
+ aiChangeSafetyRating: summary.aiChangeSafetyRating,
355
+ coverageRatio: summary.coverageRatio
356
+ },
357
+ factors,
358
+ recommendations: recs
359
+ };
360
+ }
361
+ // Annotate the CommonJS export names for ESM import in node:
362
+ 0 && (module.exports = {
363
+ analyzeTestability,
364
+ calculateTestabilityScore
365
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,8 @@
1
+ import {
2
+ analyzeTestability,
3
+ calculateTestabilityScore
4
+ } from "./chunk-CYZ7DTWN.mjs";
5
+ export {
6
+ analyzeTestability,
7
+ calculateTestabilityScore
8
+ };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@aiready/testability",
3
+ "version": "0.1.1",
4
+ "description": "Measures how safely and verifiably AI-generated changes can be made to your codebase",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "aiready-testability": "./dist/cli.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "require": "./dist/index.js",
15
+ "import": "./dist/index.mjs"
16
+ }
17
+ },
18
+ "keywords": [
19
+ "aiready",
20
+ "testability",
21
+ "ai-safety",
22
+ "code-quality",
23
+ "copilot",
24
+ "claude",
25
+ "chatgpt",
26
+ "ai-assisted-development",
27
+ "testing",
28
+ "verification"
29
+ ],
30
+ "author": "AIReady Team",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/caopengau/aiready-testability.git"
35
+ },
36
+ "homepage": "https://github.com/caopengau/aiready-testability",
37
+ "dependencies": {
38
+ "@typescript-eslint/types": "^8.53.0",
39
+ "@typescript-eslint/typescript-estree": "^8.53.0",
40
+ "chalk": "^5.3.0",
41
+ "commander": "^14.0.0",
42
+ "glob": "^11.0.0",
43
+ "@aiready/core": "0.9.28"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^24.0.0",
47
+ "tsup": "^8.3.5",
48
+ "typescript": "^5.7.2",
49
+ "vitest": "^4.0.0"
50
+ },
51
+ "engines": {
52
+ "node": ">=18.0.0"
53
+ },
54
+ "scripts": {
55
+ "build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts",
56
+ "dev": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --watch",
57
+ "test": "vitest run",
58
+ "lint": "eslint src",
59
+ "clean": "rm -rf dist",
60
+ "release": "pnpm build && pnpm publish --no-git-checks"
61
+ }
62
+ }
@@ -0,0 +1,84 @@
1
+ import { analyzeTestability } from '../analyzer';
2
+ import { join } from 'path';
3
+ import { writeFileSync, mkdirSync, rmSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
6
+
7
+ describe('Testability Analyzer', () => {
8
+ const tmpDir = join(tmpdir(), 'aiready-testability-tests');
9
+
10
+ beforeAll(() => {
11
+ mkdirSync(tmpDir, { recursive: true });
12
+ });
13
+
14
+ afterAll(() => {
15
+ rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ function createTestFile(name: string, content: string): string {
19
+ const filePath = join(tmpDir, name);
20
+ const dir = join(filePath, '..');
21
+ mkdirSync(dir, { recursive: true });
22
+ writeFileSync(filePath, content, 'utf8');
23
+ return filePath;
24
+ }
25
+
26
+ describe('Test Coverage Ratio', () => {
27
+ it('should calculate ratio of test files to source files', async () => {
28
+ createTestFile('src/math.ts', 'export const add = (a, b) => a + b;');
29
+ createTestFile('src/math.test.ts', 'import { add } from "./math"; test("add", () => {});');
30
+ createTestFile('src/string.ts', 'export const upper = (s) => s.toUpperCase();');
31
+
32
+ const report = await analyzeTestability({ rootDir: tmpDir });
33
+
34
+ expect(report.rawData.sourceFiles).toBeGreaterThanOrEqual(2);
35
+ expect(report.rawData.testFiles).toBeGreaterThanOrEqual(1);
36
+ });
37
+ });
38
+
39
+ describe('Pure Functions and State Mutations', () => {
40
+ it('should detect state mutations inside functions', async () => {
41
+ createTestFile('src/mutations.ts', `
42
+ const globalState = { value: 0 };
43
+
44
+ export function impureAdd(a: number) {
45
+ globalState.value += a; // mutation here
46
+ return globalState.value;
47
+ }
48
+
49
+ export function pureAdd(a: number, b: number) {
50
+ return a + b;
51
+ }
52
+ `);
53
+
54
+ const report = await analyzeTestability({ rootDir: tmpDir });
55
+
56
+ expect(report.rawData.externalStateMutations).toBeGreaterThanOrEqual(1);
57
+ expect(report.rawData.pureFunctions).toBeGreaterThanOrEqual(1);
58
+ });
59
+ });
60
+
61
+ describe('Bloated Interfaces', () => {
62
+ it('should detect interfaces with too many methods', async () => {
63
+ createTestFile('src/interfaces.ts', `
64
+ export interface BloatedService {
65
+ m1(): void;
66
+ m2(): void;
67
+ m3(): void;
68
+ m4(): void;
69
+ m5(): void;
70
+ m6(): void;
71
+ m7(): void;
72
+ m8(): void;
73
+ m9(): void;
74
+ m10(): void;
75
+ m11(): void;
76
+ }
77
+ `);
78
+
79
+ const report = await analyzeTestability({ rootDir: tmpDir });
80
+
81
+ expect(report.rawData.bloatedInterfaces).toBeGreaterThanOrEqual(1);
82
+ });
83
+ });
84
+ });