@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.
@@ -0,0 +1,363 @@
1
+ // src/analyzer.ts
2
+ import { readdirSync, statSync, readFileSync, existsSync } from "fs";
3
+ import { join, extname } from "path";
4
+ import { parse } from "@typescript-eslint/typescript-estree";
5
+ import { calculateTestabilityIndex } from "@aiready/core";
6
+ var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
7
+ var DEFAULT_EXCLUDES = [
8
+ "node_modules",
9
+ "dist",
10
+ ".git",
11
+ "coverage",
12
+ ".turbo",
13
+ "build"
14
+ ];
15
+ var TEST_PATTERNS = [
16
+ /\.(test|spec)\.(ts|tsx|js|jsx)$/,
17
+ /__tests__\//,
18
+ /\/tests?\//,
19
+ /\/e2e\//,
20
+ /\/fixtures\//
21
+ ];
22
+ function isTestFile(filePath, extra) {
23
+ if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
24
+ if (extra) return extra.some((p) => filePath.includes(p));
25
+ return false;
26
+ }
27
+ function isSourceFile(filePath) {
28
+ return SRC_EXTENSIONS.has(extname(filePath));
29
+ }
30
+ function collectFiles(dir, options, depth = 0) {
31
+ if (depth > (options.maxDepth ?? 20)) return [];
32
+ const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
33
+ const files = [];
34
+ let entries;
35
+ try {
36
+ entries = readdirSync(dir);
37
+ } catch {
38
+ return files;
39
+ }
40
+ for (const entry of entries) {
41
+ if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
42
+ const full = join(dir, entry);
43
+ let stat;
44
+ try {
45
+ stat = statSync(full);
46
+ } catch {
47
+ continue;
48
+ }
49
+ if (stat.isDirectory()) {
50
+ files.push(...collectFiles(full, options, depth + 1));
51
+ } else if (stat.isFile() && isSourceFile(full)) {
52
+ if (!options.include || options.include.some((p) => full.includes(p))) {
53
+ files.push(full);
54
+ }
55
+ }
56
+ }
57
+ return files;
58
+ }
59
+ function countMethodsInInterface(node) {
60
+ if (node.type === "TSInterfaceDeclaration") {
61
+ return node.body.body.filter(
62
+ (m) => m.type === "TSMethodSignature" || m.type === "TSPropertySignature"
63
+ ).length;
64
+ }
65
+ if (node.type === "TSTypeAliasDeclaration" && node.typeAnnotation.type === "TSTypeLiteral") {
66
+ return node.typeAnnotation.members.length;
67
+ }
68
+ return 0;
69
+ }
70
+ function hasDependencyInjection(node) {
71
+ for (const member of node.body.body) {
72
+ if (member.type === "MethodDefinition" && member.key.type === "Identifier" && member.key.name === "constructor") {
73
+ const fn = member.value;
74
+ if (fn.params && fn.params.length > 0) {
75
+ const typedParams = fn.params.filter((p) => {
76
+ const param = p;
77
+ return param.typeAnnotation != null || param.parameter?.typeAnnotation != null;
78
+ });
79
+ if (typedParams.length > 0) return true;
80
+ }
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+ function isPureFunction(fn) {
86
+ let hasReturn = false;
87
+ let hasSideEffect = false;
88
+ function walk(node) {
89
+ if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
90
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
91
+ hasSideEffect = true;
92
+ if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(
93
+ node.callee.object.name
94
+ ))
95
+ hasSideEffect = true;
96
+ for (const key of Object.keys(node)) {
97
+ if (key === "parent") continue;
98
+ const child = node[key];
99
+ if (child && typeof child === "object") {
100
+ if (Array.isArray(child)) {
101
+ child.forEach((c) => c?.type && walk(c));
102
+ } else if (child.type) {
103
+ walk(child);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ if (fn.body?.type === "BlockStatement") {
109
+ fn.body.body.forEach((s) => walk(s));
110
+ } else if (fn.body) {
111
+ hasReturn = true;
112
+ }
113
+ return hasReturn && !hasSideEffect;
114
+ }
115
+ function hasExternalStateMutation(fn) {
116
+ let found = false;
117
+ function walk(node) {
118
+ if (found) return;
119
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
120
+ found = true;
121
+ for (const key of Object.keys(node)) {
122
+ if (key === "parent") continue;
123
+ const child = node[key];
124
+ if (child && typeof child === "object") {
125
+ if (Array.isArray(child)) child.forEach((c) => c?.type && walk(c));
126
+ else if (child.type) walk(child);
127
+ }
128
+ }
129
+ }
130
+ if (fn.body?.type === "BlockStatement") fn.body.body.forEach((s) => walk(s));
131
+ return found;
132
+ }
133
+ function analyzeFileTestability(filePath) {
134
+ const result = {
135
+ pureFunctions: 0,
136
+ totalFunctions: 0,
137
+ injectionPatterns: 0,
138
+ totalClasses: 0,
139
+ bloatedInterfaces: 0,
140
+ totalInterfaces: 0,
141
+ externalStateMutations: 0
142
+ };
143
+ let code;
144
+ try {
145
+ code = readFileSync(filePath, "utf-8");
146
+ } catch {
147
+ return result;
148
+ }
149
+ let ast;
150
+ try {
151
+ ast = parse(code, {
152
+ jsx: filePath.endsWith(".tsx") || filePath.endsWith(".jsx"),
153
+ range: false,
154
+ loc: false
155
+ });
156
+ } catch {
157
+ return result;
158
+ }
159
+ function visit(node) {
160
+ if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
161
+ result.totalFunctions++;
162
+ if (isPureFunction(node)) result.pureFunctions++;
163
+ if (hasExternalStateMutation(node)) result.externalStateMutations++;
164
+ }
165
+ if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
166
+ result.totalClasses++;
167
+ if (hasDependencyInjection(node)) result.injectionPatterns++;
168
+ }
169
+ if (node.type === "TSInterfaceDeclaration" || node.type === "TSTypeAliasDeclaration") {
170
+ result.totalInterfaces++;
171
+ const methodCount = countMethodsInInterface(node);
172
+ if (methodCount > 10) result.bloatedInterfaces++;
173
+ }
174
+ for (const key of Object.keys(node)) {
175
+ if (key === "parent") continue;
176
+ const child = node[key];
177
+ if (child && typeof child === "object") {
178
+ if (Array.isArray(child)) child.forEach((c) => c?.type && visit(c));
179
+ else if (child.type) visit(child);
180
+ }
181
+ }
182
+ }
183
+ ast.body.forEach(visit);
184
+ return result;
185
+ }
186
+ function detectTestFramework(rootDir) {
187
+ const pkgPath = join(rootDir, "package.json");
188
+ if (!existsSync(pkgPath)) return false;
189
+ try {
190
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
191
+ const allDeps = {
192
+ ...pkg.dependencies ?? {},
193
+ ...pkg.devDependencies ?? {}
194
+ };
195
+ const testFrameworks = [
196
+ "jest",
197
+ "vitest",
198
+ "mocha",
199
+ "jasmine",
200
+ "ava",
201
+ "tap",
202
+ "pytest",
203
+ "unittest"
204
+ ];
205
+ return testFrameworks.some((fw) => allDeps[fw]);
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+ async function analyzeTestability(options) {
211
+ const allFiles = collectFiles(options.rootDir, options);
212
+ const sourceFiles = allFiles.filter(
213
+ (f) => !isTestFile(f, options.testPatterns)
214
+ );
215
+ const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
216
+ const aggregated = {
217
+ pureFunctions: 0,
218
+ totalFunctions: 0,
219
+ injectionPatterns: 0,
220
+ totalClasses: 0,
221
+ bloatedInterfaces: 0,
222
+ totalInterfaces: 0,
223
+ externalStateMutations: 0
224
+ };
225
+ for (const f of sourceFiles) {
226
+ const a = analyzeFileTestability(f);
227
+ for (const key of Object.keys(aggregated)) {
228
+ aggregated[key] += a[key];
229
+ }
230
+ }
231
+ const hasTestFramework = detectTestFramework(options.rootDir);
232
+ const indexResult = calculateTestabilityIndex({
233
+ testFiles: testFiles.length,
234
+ sourceFiles: sourceFiles.length,
235
+ pureFunctions: aggregated.pureFunctions,
236
+ totalFunctions: Math.max(1, aggregated.totalFunctions),
237
+ injectionPatterns: aggregated.injectionPatterns,
238
+ totalClasses: Math.max(1, aggregated.totalClasses),
239
+ bloatedInterfaces: aggregated.bloatedInterfaces,
240
+ totalInterfaces: Math.max(1, aggregated.totalInterfaces),
241
+ externalStateMutations: aggregated.externalStateMutations,
242
+ hasTestFramework
243
+ });
244
+ const issues = [];
245
+ const minCoverage = options.minCoverageRatio ?? 0.3;
246
+ const actualRatio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
247
+ if (!hasTestFramework) {
248
+ issues.push({
249
+ type: "low-testability",
250
+ dimension: "framework",
251
+ severity: "critical",
252
+ message: "No testing framework detected in package.json \u2014 AI changes cannot be verified at all.",
253
+ location: { file: options.rootDir, line: 0 },
254
+ suggestion: "Add Jest, Vitest, or another testing framework as a devDependency."
255
+ });
256
+ }
257
+ if (actualRatio < minCoverage) {
258
+ const needed = Math.ceil(sourceFiles.length * minCoverage) - testFiles.length;
259
+ issues.push({
260
+ type: "low-testability",
261
+ dimension: "test-coverage",
262
+ severity: actualRatio === 0 ? "critical" : "major",
263
+ message: `Test ratio is ${Math.round(actualRatio * 100)}% (${testFiles.length} test files for ${sourceFiles.length} source files). Need at least ${Math.round(minCoverage * 100)}%.`,
264
+ location: { file: options.rootDir, line: 0 },
265
+ suggestion: `Add ~${needed} test file(s) to reach the ${Math.round(minCoverage * 100)}% minimum for safe AI assistance.`
266
+ });
267
+ }
268
+ if (indexResult.dimensions.purityScore < 50) {
269
+ issues.push({
270
+ type: "low-testability",
271
+ dimension: "purity",
272
+ severity: "major",
273
+ message: `Only ${indexResult.dimensions.purityScore}% of functions are pure \u2014 side-effectful functions require complex test setup.`,
274
+ location: { file: options.rootDir, line: 0 },
275
+ suggestion: "Extract pure transformation logic from I/O and mutation code."
276
+ });
277
+ }
278
+ if (indexResult.dimensions.observabilityScore < 50) {
279
+ issues.push({
280
+ type: "low-testability",
281
+ dimension: "observability",
282
+ severity: "major",
283
+ message: `Many functions mutate external state directly \u2014 outputs are invisible to unit tests.`,
284
+ location: { file: options.rootDir, line: 0 },
285
+ suggestion: "Prefer returning values over mutating shared state."
286
+ });
287
+ }
288
+ return {
289
+ summary: {
290
+ sourceFiles: sourceFiles.length,
291
+ testFiles: testFiles.length,
292
+ coverageRatio: Math.round(actualRatio * 100) / 100,
293
+ score: indexResult.score,
294
+ rating: indexResult.rating,
295
+ aiChangeSafetyRating: indexResult.aiChangeSafetyRating,
296
+ dimensions: indexResult.dimensions
297
+ },
298
+ issues,
299
+ rawData: {
300
+ sourceFiles: sourceFiles.length,
301
+ testFiles: testFiles.length,
302
+ ...aggregated,
303
+ hasTestFramework
304
+ },
305
+ recommendations: indexResult.recommendations
306
+ };
307
+ }
308
+
309
+ // src/scoring.ts
310
+ function calculateTestabilityScore(report) {
311
+ const { summary, rawData, recommendations } = report;
312
+ const factors = [
313
+ {
314
+ name: "Test Coverage",
315
+ impact: Math.round(summary.dimensions.testCoverageRatio - 50),
316
+ description: `${rawData.testFiles} test files / ${rawData.sourceFiles} source files (${Math.round(summary.coverageRatio * 100)}%)`
317
+ },
318
+ {
319
+ name: "Function Purity",
320
+ impact: Math.round(summary.dimensions.purityScore - 50),
321
+ description: `${rawData.pureFunctions}/${rawData.totalFunctions} functions are pure`
322
+ },
323
+ {
324
+ name: "Dependency Injection",
325
+ impact: Math.round(summary.dimensions.dependencyInjectionScore - 50),
326
+ description: `${rawData.injectionPatterns}/${rawData.totalClasses} classes use DI`
327
+ },
328
+ {
329
+ name: "Interface Focus",
330
+ impact: Math.round(summary.dimensions.interfaceFocusScore - 50),
331
+ description: `${rawData.bloatedInterfaces} interfaces have >10 methods`
332
+ },
333
+ {
334
+ name: "Observability",
335
+ impact: Math.round(summary.dimensions.observabilityScore - 50),
336
+ description: `${rawData.externalStateMutations} functions mutate external state`
337
+ }
338
+ ];
339
+ const recs = recommendations.map(
340
+ (action) => ({
341
+ action,
342
+ estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
343
+ priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
344
+ })
345
+ );
346
+ return {
347
+ toolName: "testability",
348
+ score: summary.score,
349
+ rawMetrics: {
350
+ ...rawData,
351
+ rating: summary.rating,
352
+ aiChangeSafetyRating: summary.aiChangeSafetyRating,
353
+ coverageRatio: summary.coverageRatio
354
+ },
355
+ factors,
356
+ recommendations: recs
357
+ };
358
+ }
359
+
360
+ export {
361
+ analyzeTestability,
362
+ calculateTestabilityScore
363
+ };
package/dist/cli.js CHANGED
@@ -27,56 +27,10 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
27
  var import_commander = require("commander");
28
28
 
29
29
  // src/analyzer.ts
30
+ var import_core = require("@aiready/core");
30
31
  var import_fs = require("fs");
31
32
  var import_path = require("path");
32
33
  var import_typescript_estree = require("@typescript-eslint/typescript-estree");
33
- var import_core = require("@aiready/core");
34
- var SRC_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
35
- var DEFAULT_EXCLUDES = ["node_modules", "dist", ".git", "coverage", ".turbo", "build"];
36
- var TEST_PATTERNS = [
37
- /\.(test|spec)\.(ts|tsx|js|jsx)$/,
38
- /__tests__\//,
39
- /\/tests?\//,
40
- /\/e2e\//,
41
- /\/fixtures\//
42
- ];
43
- function isTestFile(filePath, extra) {
44
- if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
45
- if (extra) return extra.some((p) => filePath.includes(p));
46
- return false;
47
- }
48
- function isSourceFile(filePath) {
49
- return SRC_EXTENSIONS.has((0, import_path.extname)(filePath));
50
- }
51
- function collectFiles(dir, options, depth = 0) {
52
- if (depth > (options.maxDepth ?? 20)) return [];
53
- const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
54
- const files = [];
55
- let entries;
56
- try {
57
- entries = (0, import_fs.readdirSync)(dir);
58
- } catch {
59
- return files;
60
- }
61
- for (const entry of entries) {
62
- if (excludes.some((ex) => entry === ex || entry.includes(ex))) continue;
63
- const full = (0, import_path.join)(dir, entry);
64
- let stat;
65
- try {
66
- stat = (0, import_fs.statSync)(full);
67
- } catch {
68
- continue;
69
- }
70
- if (stat.isDirectory()) {
71
- files.push(...collectFiles(full, options, depth + 1));
72
- } else if (stat.isFile() && isSourceFile(full)) {
73
- if (!options.include || options.include.some((p) => full.includes(p))) {
74
- files.push(full);
75
- }
76
- }
77
- }
78
- return files;
79
- }
80
34
  function countMethodsInInterface(node) {
81
35
  if (node.type === "TSInterfaceDeclaration") {
82
36
  return node.body.body.filter(
@@ -108,8 +62,12 @@ function isPureFunction(fn) {
108
62
  let hasSideEffect = false;
109
63
  function walk(node) {
110
64
  if (node.type === "ReturnStatement" && node.argument) hasReturn = true;
111
- if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression") hasSideEffect = true;
112
- 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;
65
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
66
+ hasSideEffect = true;
67
+ if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && ["console", "process", "window", "document", "fs"].includes(
68
+ node.callee.object.name
69
+ ))
70
+ hasSideEffect = true;
113
71
  for (const key of Object.keys(node)) {
114
72
  if (key === "parent") continue;
115
73
  const child = node[key];
@@ -133,7 +91,8 @@ function hasExternalStateMutation(fn) {
133
91
  let found = false;
134
92
  function walk(node) {
135
93
  if (found) return;
136
- if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression") found = true;
94
+ if (node.type === "AssignmentExpression" && node.left.type === "MemberExpression")
95
+ found = true;
137
96
  for (const key of Object.keys(node)) {
138
97
  if (key === "parent") continue;
139
98
  const child = node[key];
@@ -208,15 +167,42 @@ function detectTestFramework(rootDir) {
208
167
  ...pkg.dependencies ?? {},
209
168
  ...pkg.devDependencies ?? {}
210
169
  };
211
- const testFrameworks = ["jest", "vitest", "mocha", "jasmine", "ava", "tap", "pytest", "unittest"];
170
+ const testFrameworks = [
171
+ "jest",
172
+ "vitest",
173
+ "mocha",
174
+ "jasmine",
175
+ "ava",
176
+ "tap",
177
+ "pytest",
178
+ "unittest"
179
+ ];
212
180
  return testFrameworks.some((fw) => allDeps[fw]);
213
181
  } catch {
214
182
  return false;
215
183
  }
216
184
  }
185
+ var TEST_PATTERNS = [
186
+ /\.(test|spec)\.(ts|tsx|js|jsx)$/,
187
+ /__tests__\//,
188
+ /\/tests?\//,
189
+ /\/e2e\//,
190
+ /\/fixtures\//
191
+ ];
192
+ function isTestFile(filePath, extra) {
193
+ if (TEST_PATTERNS.some((p) => p.test(filePath))) return true;
194
+ if (extra) return extra.some((p) => filePath.includes(p));
195
+ return false;
196
+ }
217
197
  async function analyzeTestability(options) {
218
- const allFiles = collectFiles(options.rootDir, options);
219
- const sourceFiles = allFiles.filter((f) => !isTestFile(f, options.testPatterns));
198
+ const allFiles = await (0, import_core.scanFiles)({
199
+ ...options,
200
+ include: options.include || ["**/*.{ts,tsx,js,jsx}"],
201
+ includeTests: true
202
+ });
203
+ const sourceFiles = allFiles.filter(
204
+ (f) => !isTestFile(f, options.testPatterns)
205
+ );
220
206
  const testFiles = allFiles.filter((f) => isTestFile(f, options.testPatterns));
221
207
  const aggregated = {
222
208
  pureFunctions: 0,
@@ -227,7 +213,14 @@ async function analyzeTestability(options) {
227
213
  totalInterfaces: 0,
228
214
  externalStateMutations: 0
229
215
  };
216
+ let processed = 0;
230
217
  for (const f of sourceFiles) {
218
+ processed++;
219
+ options.onProgress?.(
220
+ processed,
221
+ sourceFiles.length,
222
+ `testability: analyzing files`
223
+ );
231
224
  const a = analyzeFileTestability(f);
232
225
  for (const key of Object.keys(aggregated)) {
233
226
  aggregated[key] += a[key];
@@ -341,11 +334,13 @@ function calculateTestabilityScore(report) {
341
334
  description: `${rawData.externalStateMutations} functions mutate external state`
342
335
  }
343
336
  ];
344
- const recs = recommendations.map((action) => ({
345
- action,
346
- estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
347
- priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
348
- }));
337
+ const recs = recommendations.map(
338
+ (action) => ({
339
+ action,
340
+ estimatedImpact: summary.aiChangeSafetyRating === "blind-risk" ? 15 : 8,
341
+ priority: summary.aiChangeSafetyRating === "blind-risk" || summary.aiChangeSafetyRating === "high-risk" ? "high" : "medium"
342
+ })
343
+ );
349
344
  return {
350
345
  toolName: "testability",
351
346
  score: summary.score,
@@ -366,7 +361,11 @@ var import_fs2 = require("fs");
366
361
  var import_path2 = require("path");
367
362
  var import_core2 = require("@aiready/core");
368
363
  var program = new import_commander.Command();
369
- program.name("aiready-testability").description("Measure how safely AI-generated changes can be verified in your codebase").version("0.1.0").addHelpText("after", `
364
+ program.name("aiready-testability").description(
365
+ "Measure how safely AI-generated changes can be verified in your codebase"
366
+ ).version("0.1.0").addHelpText(
367
+ "after",
368
+ `
370
369
  DIMENSIONS MEASURED:
371
370
  Test Coverage Ratio of test files to source files
372
371
  Function Purity Pure functions are trivially AI-testable
@@ -384,7 +383,15 @@ EXAMPLES:
384
383
  aiready-testability . # Full analysis
385
384
  aiready-testability src/ --output json # JSON report
386
385
  aiready-testability . --min-coverage 0.5 # Stricter 50% threshold
387
- `).argument("<directory>", "Directory to analyze").option("--min-coverage <ratio>", "Minimum acceptable test/source ratio (default: 0.3)", "0.3").option("--test-patterns <patterns>", "Additional test file patterns (comma-separated)").option("--include <patterns>", "File patterns to include (comma-separated)").option("--exclude <patterns>", "File patterns to exclude (comma-separated)").option("-o, --output <format>", "Output format: console|json", "console").option("--output-file <path>", "Output file path (for json)").action(async (directory, options) => {
386
+ `
387
+ ).argument("<directory>", "Directory to analyze").option(
388
+ "--min-coverage <ratio>",
389
+ "Minimum acceptable test/source ratio (default: 0.3)",
390
+ "0.3"
391
+ ).option(
392
+ "--test-patterns <patterns>",
393
+ "Additional test file patterns (comma-separated)"
394
+ ).option("--include <patterns>", "File patterns to include (comma-separated)").option("--exclude <patterns>", "File patterns to exclude (comma-separated)").option("-o, --output <format>", "Output format: console|json", "console").option("--output-file <path>", "Output file path (for json)").action(async (directory, options) => {
388
395
  console.log(import_chalk.default.blue("\u{1F9EA} Analyzing testability...\n"));
389
396
  const startTime = Date.now();
390
397
  const config = await (0, import_core2.loadConfig)(directory);
@@ -453,18 +460,32 @@ function displayConsoleReport(report, scoring, elapsed) {
453
460
  const safetyRating = summary.aiChangeSafetyRating;
454
461
  console.log(import_chalk.default.bold("\n\u{1F9EA} Testability Analysis\n"));
455
462
  if (safetyRating === "blind-risk") {
456
- console.log(import_chalk.default.bgRed.white.bold(
457
- " \u{1F480} BLIND RISK \u2014 NO TESTS DETECTED. AI-GENERATED CHANGES CANNOT BE VERIFIED. "
458
- ));
463
+ console.log(
464
+ import_chalk.default.bgRed.white.bold(
465
+ " \u{1F480} BLIND RISK \u2014 NO TESTS DETECTED. AI-GENERATED CHANGES CANNOT BE VERIFIED. "
466
+ )
467
+ );
459
468
  console.log();
460
469
  } else if (safetyRating === "high-risk") {
461
- console.log(import_chalk.default.red.bold(` \u{1F534} HIGH RISK \u2014 Insufficient test coverage. AI changes may introduce silent bugs.`));
470
+ console.log(
471
+ import_chalk.default.red.bold(
472
+ ` \u{1F534} HIGH RISK \u2014 Insufficient test coverage. AI changes may introduce silent bugs.`
473
+ )
474
+ );
462
475
  console.log();
463
476
  }
464
- console.log(`AI Change Safety: ${safetyColor(safetyRating)(`${safetyIcon(safetyRating)} ${safetyRating.toUpperCase()}`)}`);
465
- console.log(`Score: ${import_chalk.default.bold(summary.score + "/100")} (${summary.rating})`);
466
- console.log(`Source Files: ${import_chalk.default.cyan(rawData.sourceFiles)} Test Files: ${import_chalk.default.cyan(rawData.testFiles)}`);
467
- console.log(`Coverage Ratio: ${import_chalk.default.bold(Math.round(summary.coverageRatio * 100) + "%")}`);
477
+ console.log(
478
+ `AI Change Safety: ${safetyColor(safetyRating)(`${safetyIcon(safetyRating)} ${safetyRating.toUpperCase()}`)}`
479
+ );
480
+ console.log(
481
+ `Score: ${import_chalk.default.bold(summary.score + "/100")} (${summary.rating})`
482
+ );
483
+ console.log(
484
+ `Source Files: ${import_chalk.default.cyan(rawData.sourceFiles)} Test Files: ${import_chalk.default.cyan(rawData.testFiles)}`
485
+ );
486
+ console.log(
487
+ `Coverage Ratio: ${import_chalk.default.bold(Math.round(summary.coverageRatio * 100) + "%")}`
488
+ );
468
489
  console.log(`Analysis Time: ${import_chalk.default.gray(elapsed + "s")}
469
490
  `);
470
491
  console.log(import_chalk.default.bold("\u{1F4D0} Dimension Scores\n"));
@@ -484,7 +505,10 @@ function displayConsoleReport(report, scoring, elapsed) {
484
505
  for (const issue of issues) {
485
506
  const sev = issue.severity === "critical" ? import_chalk.default.red : issue.severity === "major" ? import_chalk.default.yellow : import_chalk.default.blue;
486
507
  console.log(`${sev(issue.severity.toUpperCase())} ${issue.message}`);
487
- if (issue.suggestion) console.log(` ${import_chalk.default.dim("\u2192")} ${import_chalk.default.italic(issue.suggestion)}`);
508
+ if (issue.suggestion)
509
+ console.log(
510
+ ` ${import_chalk.default.dim("\u2192")} ${import_chalk.default.italic(issue.suggestion)}`
511
+ );
488
512
  console.log();
489
513
  }
490
514
  }