@aiready/contract-enforcement 0.2.0

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,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { calculateContractEnforcementScore } from '../scoring';
3
+ import { ZERO_COUNTS } from '../types';
4
+
5
+ describe('calculateContractEnforcementScore', () => {
6
+ it('returns 100 with no defensive patterns', () => {
7
+ const result = calculateContractEnforcementScore(ZERO_COUNTS, 1000, 10);
8
+ expect(result.score).toBe(100);
9
+ expect(result.rating).toBe('excellent');
10
+ });
11
+
12
+ it('returns lower score with many as-any patterns', () => {
13
+ const counts = { ...ZERO_COUNTS, 'as-any': 50 };
14
+ const result = calculateContractEnforcementScore(counts, 1000, 10);
15
+ expect(result.score).toBeLessThan(100);
16
+ expect(result.dimensions.typeEscapeHatchScore).toBeLessThan(100);
17
+ });
18
+
19
+ it('returns lower score with many swallowed errors', () => {
20
+ const counts = { ...ZERO_COUNTS, 'swallowed-error': 10 };
21
+ const result = calculateContractEnforcementScore(counts, 1000, 10);
22
+ expect(result.score).toBeLessThan(100);
23
+ expect(result.dimensions.errorTransparencyScore).toBeLessThan(100);
24
+ });
25
+
26
+ it('scores boundary validation correctly', () => {
27
+ const counts = {
28
+ ...ZERO_COUNTS,
29
+ 'env-fallback': 20,
30
+ 'unnecessary-guard': 10,
31
+ };
32
+ const result = calculateContractEnforcementScore(counts, 1000, 10);
33
+ expect(result.dimensions.boundaryValidationScore).toBeLessThan(100);
34
+ });
35
+
36
+ it('generates recommendations for low-scoring dimensions', () => {
37
+ const counts = {
38
+ ...ZERO_COUNTS,
39
+ 'as-any': 30,
40
+ 'swallowed-error': 10,
41
+ };
42
+ const result = calculateContractEnforcementScore(counts, 1000, 10);
43
+ expect(result.recommendations.length).toBeGreaterThan(0);
44
+ });
45
+
46
+ it('handles zero LOC without division errors', () => {
47
+ const result = calculateContractEnforcementScore(ZERO_COUNTS, 0, 0);
48
+ expect(result.score).toBe(100);
49
+ expect(isNaN(result.score)).toBe(false);
50
+ });
51
+
52
+ it('rates correctly across thresholds', () => {
53
+ // Excellent (>= 90)
54
+ expect(
55
+ calculateContractEnforcementScore(ZERO_COUNTS, 1000, 10).rating
56
+ ).toBe('excellent');
57
+ });
58
+ });
@@ -0,0 +1,80 @@
1
+ import { readFileSync } from 'fs';
2
+ import { scanFiles, runBatchAnalysis } from '@aiready/core';
3
+ import { detectDefensivePatterns } from './detector';
4
+ import { calculateContractEnforcementScore } from './scoring';
5
+ import type {
6
+ ContractEnforcementOptions,
7
+ ContractEnforcementReport,
8
+ DetectionResult,
9
+ PatternCounts,
10
+ } from './types';
11
+ import { ZERO_COUNTS } from './types';
12
+
13
+ export async function analyzeContractEnforcement(
14
+ options: ContractEnforcementOptions
15
+ ): Promise<ContractEnforcementReport> {
16
+ const files = await scanFiles({
17
+ ...options,
18
+ include: options.include || ['**/*.{ts,tsx,js,jsx}'],
19
+ });
20
+
21
+ const allIssues: ContractEnforcementReport['issues'] = [];
22
+ const aggregate: PatternCounts = { ...ZERO_COUNTS };
23
+ let totalLines = 0;
24
+
25
+ await runBatchAnalysis(
26
+ files,
27
+ 'scanning for defensive patterns',
28
+ 'contract-enforcement',
29
+ options.onProgress,
30
+ async (f: string) => {
31
+ let code: string;
32
+ try {
33
+ code = readFileSync(f, 'utf-8');
34
+ } catch {
35
+ return { issues: [], counts: { ...ZERO_COUNTS }, totalLines: 0 };
36
+ }
37
+ return detectDefensivePatterns(f, code, options.minChainDepth ?? 3);
38
+ },
39
+ (result: DetectionResult) => {
40
+ allIssues.push(...result.issues);
41
+ totalLines += result.totalLines;
42
+ for (const key of Object.keys(aggregate) as Array<keyof PatternCounts>) {
43
+ aggregate[key] += result.counts[key];
44
+ }
45
+ }
46
+ );
47
+
48
+ const totalPatterns = Object.values(aggregate).reduce((a, b) => a + b, 0);
49
+ const density =
50
+ totalLines > 0 ? Math.round((totalPatterns / totalLines) * 10000) / 10 : 0;
51
+
52
+ const scoreResult = calculateContractEnforcementScore(
53
+ aggregate,
54
+ totalLines,
55
+ files.length
56
+ );
57
+
58
+ return {
59
+ summary: {
60
+ sourceFiles: files.length,
61
+ totalDefensivePatterns: totalPatterns,
62
+ defensiveDensity: density,
63
+ score: scoreResult.score,
64
+ rating: scoreResult.rating,
65
+ dimensions: {
66
+ typeEscapeHatchScore: scoreResult.dimensions
67
+ .typeEscapeHatchScore as number,
68
+ fallbackCascadeScore: scoreResult.dimensions
69
+ .fallbackCascadeScore as number,
70
+ errorTransparencyScore: scoreResult.dimensions
71
+ .errorTransparencyScore as number,
72
+ boundaryValidationScore: scoreResult.dimensions
73
+ .boundaryValidationScore as number,
74
+ },
75
+ },
76
+ issues: allIssues,
77
+ rawData: { ...aggregate, sourceFiles: files.length, totalLines },
78
+ recommendations: scoreResult.recommendations,
79
+ };
80
+ }
@@ -0,0 +1,373 @@
1
+ import { parse } from '@typescript-eslint/typescript-estree';
2
+ import { Severity, IssueType } from '@aiready/core';
3
+ import type {
4
+ ContractEnforcementIssue,
5
+ DetectionResult,
6
+ DefensivePattern,
7
+ PatternCounts,
8
+ } from './types';
9
+ import { ZERO_COUNTS } from './types';
10
+
11
+ function makeIssue(
12
+ pattern: DefensivePattern,
13
+ severity: Severity,
14
+ message: string,
15
+ filePath: string,
16
+ line: number,
17
+ column: number,
18
+ context: string
19
+ ): ContractEnforcementIssue {
20
+ return {
21
+ type: IssueType.ContractGap,
22
+ severity,
23
+ pattern,
24
+ message,
25
+ location: { file: filePath, line, column },
26
+ context,
27
+ suggestion: getSuggestion(pattern),
28
+ };
29
+ }
30
+
31
+ function getSuggestion(pattern: DefensivePattern): string {
32
+ switch (pattern) {
33
+ case 'as-any':
34
+ return 'Define a proper type or use type narrowing instead of `as any`.';
35
+ case 'as-unknown':
36
+ return 'Use a single validated type assertion or schema validation instead of `as unknown as`.';
37
+ case 'deep-optional-chain':
38
+ return 'Enforce a non-nullable type at the source to eliminate deep optional chaining.';
39
+ case 'nullish-literal-default':
40
+ return 'Define defaults in a typed config object rather than inline literal fallbacks.';
41
+ case 'swallowed-error':
42
+ return 'Log or propagate errors — silent catch blocks hide failures.';
43
+ case 'env-fallback':
44
+ return 'Use a validated env schema (e.g., Zod) to enforce required variables at startup.';
45
+ case 'unnecessary-guard':
46
+ return 'Make the parameter non-nullable in the type signature to eliminate the guard.';
47
+ case 'any-parameter':
48
+ return 'Define a proper type for this parameter instead of `any`.';
49
+ case 'any-return':
50
+ return 'Define a proper return type instead of `any`.';
51
+ }
52
+ }
53
+
54
+ function getLineContent(code: string, line: number): string {
55
+ const lines = code.split('\n');
56
+ return (lines[line - 1] || '').trim().slice(0, 120);
57
+ }
58
+
59
+ function countOptionalChainDepth(node: any): number {
60
+ let depth = 0;
61
+ let current = node;
62
+ while (current) {
63
+ if (current.type === 'MemberExpression' && current.optional) {
64
+ depth++;
65
+ current = current.object;
66
+ } else if (current.type === 'ChainExpression') {
67
+ current = current.expression;
68
+ } else if (current.type === 'CallExpression' && current.optional) {
69
+ depth++;
70
+ current = current.callee;
71
+ } else {
72
+ break;
73
+ }
74
+ }
75
+ return depth;
76
+ }
77
+
78
+ function isLiteral(node: any): boolean {
79
+ if (!node) return false;
80
+ if (node.type === 'Literal') return true;
81
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0)
82
+ return true;
83
+ if (
84
+ node.type === 'UnaryExpression' &&
85
+ (node.operator === '-' || node.operator === '+')
86
+ ) {
87
+ return isLiteral(node.argument);
88
+ }
89
+ return false;
90
+ }
91
+
92
+ function isProcessEnvAccess(node: any): boolean {
93
+ return (
94
+ node?.type === 'MemberExpression' &&
95
+ node.object?.type === 'MemberExpression' &&
96
+ node.object.object?.name === 'process' &&
97
+ node.object.property?.name === 'env'
98
+ );
99
+ }
100
+
101
+ function isSwallowedCatch(body: any[]): boolean {
102
+ if (body.length === 0) return true;
103
+ if (body.length === 1) {
104
+ const stmt = body[0];
105
+ if (
106
+ stmt.type === 'ExpressionStatement' &&
107
+ stmt.expression?.type === 'CallExpression'
108
+ ) {
109
+ const callee = stmt.expression.callee;
110
+ if (callee?.object?.name === 'console') return true;
111
+ }
112
+ if (stmt.type === 'ThrowStatement') return false;
113
+ }
114
+ return false;
115
+ }
116
+
117
+ export function detectDefensivePatterns(
118
+ filePath: string,
119
+ code: string,
120
+ minChainDepth: number = 3
121
+ ): DetectionResult {
122
+ const issues: ContractEnforcementIssue[] = [];
123
+ const counts: PatternCounts = { ...ZERO_COUNTS };
124
+ const totalLines = code.split('\n').length;
125
+
126
+ let ast: any;
127
+ try {
128
+ ast = parse(code, {
129
+ filePath,
130
+ loc: true,
131
+ range: true,
132
+ jsx: filePath.endsWith('x'),
133
+ });
134
+ } catch {
135
+ return { issues, counts, totalLines };
136
+ }
137
+
138
+ const nodesAtFunctionStart = new WeakSet<any>();
139
+
140
+ function markFunctionParamNodes(node: any) {
141
+ if (
142
+ node.type === 'FunctionDeclaration' ||
143
+ node.type === 'FunctionExpression' ||
144
+ node.type === 'ArrowFunctionExpression'
145
+ ) {
146
+ const body = node.body?.type === 'BlockStatement' ? node.body.body : null;
147
+ if (body && body.length > 0) {
148
+ nodesAtFunctionStart.add(body[0]);
149
+ }
150
+ }
151
+ }
152
+
153
+ function visit(node: any, _parent?: any, _keyInParent?: string) {
154
+ if (!node || typeof node !== 'object') return;
155
+
156
+ markFunctionParamNodes(node);
157
+
158
+ // Pattern: as any
159
+ if (
160
+ node.type === 'TSAsExpression' &&
161
+ node.typeAnnotation?.type === 'TSAnyKeyword'
162
+ ) {
163
+ counts['as-any']++;
164
+ issues.push(
165
+ makeIssue(
166
+ 'as-any',
167
+ Severity.Major,
168
+ '`as any` type assertion bypasses type safety',
169
+ filePath,
170
+ node.loc?.start.line ?? 0,
171
+ node.loc?.start.column ?? 0,
172
+ getLineContent(code, node.loc?.start.line ?? 0)
173
+ )
174
+ );
175
+ }
176
+
177
+ // Pattern: as unknown
178
+ if (
179
+ node.type === 'TSAsExpression' &&
180
+ node.typeAnnotation?.type === 'TSUnknownKeyword'
181
+ ) {
182
+ counts['as-unknown']++;
183
+ issues.push(
184
+ makeIssue(
185
+ 'as-unknown',
186
+ Severity.Major,
187
+ '`as unknown` double-cast bypasses type safety',
188
+ filePath,
189
+ node.loc?.start.line ?? 0,
190
+ node.loc?.start.column ?? 0,
191
+ getLineContent(code, node.loc?.start.line ?? 0)
192
+ )
193
+ );
194
+ }
195
+
196
+ // Pattern: deep optional chaining
197
+ if (node.type === 'ChainExpression') {
198
+ const depth = countOptionalChainDepth(node);
199
+ if (depth >= minChainDepth) {
200
+ counts['deep-optional-chain']++;
201
+ issues.push(
202
+ makeIssue(
203
+ 'deep-optional-chain',
204
+ Severity.Minor,
205
+ `Optional chain depth of ${depth} indicates missing structural guarantees`,
206
+ filePath,
207
+ node.loc?.start.line ?? 0,
208
+ node.loc?.start.column ?? 0,
209
+ getLineContent(code, node.loc?.start.line ?? 0)
210
+ )
211
+ );
212
+ }
213
+ }
214
+
215
+ // Pattern: nullish coalescing with literal default
216
+ if (
217
+ node.type === 'LogicalExpression' &&
218
+ node.operator === '??' &&
219
+ isLiteral(node.right)
220
+ ) {
221
+ counts['nullish-literal-default']++;
222
+ issues.push(
223
+ makeIssue(
224
+ 'nullish-literal-default',
225
+ Severity.Minor,
226
+ 'Nullish coalescing with literal default suggests missing upstream type guarantee',
227
+ filePath,
228
+ node.loc?.start.line ?? 0,
229
+ node.loc?.start.column ?? 0,
230
+ getLineContent(code, node.loc?.start.line ?? 0)
231
+ )
232
+ );
233
+ }
234
+
235
+ // Pattern: swallowed error
236
+ if (node.type === 'TryStatement' && node.handler) {
237
+ const catchBody = node.handler.body?.body;
238
+ if (catchBody && isSwallowedCatch(catchBody)) {
239
+ counts['swallowed-error']++;
240
+ issues.push(
241
+ makeIssue(
242
+ 'swallowed-error',
243
+ Severity.Major,
244
+ 'Error is swallowed in catch block — failures will be silent',
245
+ filePath,
246
+ node.handler.loc?.start.line ?? 0,
247
+ node.handler.loc?.start.column ?? 0,
248
+ getLineContent(code, node.handler.loc?.start.line ?? 0)
249
+ )
250
+ );
251
+ }
252
+ }
253
+
254
+ // Pattern: process.env.X || default
255
+ if (
256
+ node.type === 'LogicalExpression' &&
257
+ node.operator === '||' &&
258
+ isProcessEnvAccess(node.left)
259
+ ) {
260
+ counts['env-fallback']++;
261
+ issues.push(
262
+ makeIssue(
263
+ 'env-fallback',
264
+ Severity.Minor,
265
+ 'Environment variable with fallback — use a validated env schema instead',
266
+ filePath,
267
+ node.loc?.start.line ?? 0,
268
+ node.loc?.start.column ?? 0,
269
+ getLineContent(code, node.loc?.start.line ?? 0)
270
+ )
271
+ );
272
+ }
273
+
274
+ // Pattern: if (!x) return guard at function entry
275
+ if (
276
+ node.type === 'IfStatement' &&
277
+ node.test?.type === 'UnaryExpression' &&
278
+ node.test.operator === '!'
279
+ ) {
280
+ const consequent = node.consequent;
281
+ let isReturn = false;
282
+ if (consequent.type === 'ReturnStatement') {
283
+ isReturn = true;
284
+ } else if (
285
+ consequent.type === 'BlockStatement' &&
286
+ consequent.body?.length === 1 &&
287
+ consequent.body[0].type === 'ReturnStatement'
288
+ ) {
289
+ isReturn = true;
290
+ }
291
+ if (isReturn && nodesAtFunctionStart.has(node)) {
292
+ counts['unnecessary-guard']++;
293
+ issues.push(
294
+ makeIssue(
295
+ 'unnecessary-guard',
296
+ Severity.Info,
297
+ 'Guard clause could be eliminated with non-nullable type at source',
298
+ filePath,
299
+ node.loc?.start.line ?? 0,
300
+ node.loc?.start.column ?? 0,
301
+ getLineContent(code, node.loc?.start.line ?? 0)
302
+ )
303
+ );
304
+ }
305
+ }
306
+
307
+ // Pattern: any parameter type
308
+ if (
309
+ (node.type === 'FunctionDeclaration' ||
310
+ node.type === 'FunctionExpression' ||
311
+ node.type === 'ArrowFunctionExpression') &&
312
+ node.params
313
+ ) {
314
+ for (const param of node.params) {
315
+ const typeAnno =
316
+ param.typeAnnotation?.typeAnnotation ?? param.typeAnnotation;
317
+ if (typeAnno?.type === 'TSAnyKeyword') {
318
+ counts['any-parameter']++;
319
+ issues.push(
320
+ makeIssue(
321
+ 'any-parameter',
322
+ Severity.Major,
323
+ 'Parameter typed as `any` bypasses type safety',
324
+ filePath,
325
+ param.loc?.start.line ?? 0,
326
+ param.loc?.start.column ?? 0,
327
+ getLineContent(code, param.loc?.start.line ?? 0)
328
+ )
329
+ );
330
+ }
331
+ }
332
+
333
+ // Pattern: any return type
334
+ const returnAnno = node.returnType?.typeAnnotation ?? node.returnType;
335
+ if (returnAnno?.type === 'TSAnyKeyword') {
336
+ counts['any-return']++;
337
+ issues.push(
338
+ makeIssue(
339
+ 'any-return',
340
+ Severity.Major,
341
+ 'Return type is `any` — callers cannot rely on the result shape',
342
+ filePath,
343
+ node.returnType?.loc?.start.line ?? 0,
344
+ node.returnType?.loc?.start.column ?? 0,
345
+ getLineContent(code, node.returnType?.loc?.start.line ?? 0)
346
+ )
347
+ );
348
+ }
349
+ }
350
+
351
+ // Recurse
352
+ for (const key in node) {
353
+ if (key === 'loc' || key === 'range' || key === 'parent') continue;
354
+ const child = node[key];
355
+ if (Array.isArray(child)) {
356
+ for (const item of child) {
357
+ if (item && typeof item.type === 'string') {
358
+ visit(item, node, key);
359
+ }
360
+ }
361
+ } else if (
362
+ child &&
363
+ typeof child === 'object' &&
364
+ typeof child.type === 'string'
365
+ ) {
366
+ visit(child, node, key);
367
+ }
368
+ }
369
+ }
370
+
371
+ visit(ast);
372
+ return { issues, counts, totalLines };
373
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { ToolRegistry } from '@aiready/core';
2
+ import { ContractEnforcementProvider } from './provider';
3
+
4
+ ToolRegistry.register(ContractEnforcementProvider);
5
+
6
+ export { analyzeContractEnforcement } from './analyzer';
7
+ export { calculateContractEnforcementScore } from './scoring';
8
+ export { detectDefensivePatterns } from './detector';
9
+ export { ContractEnforcementProvider };
10
+ export type {
11
+ ContractEnforcementOptions,
12
+ ContractEnforcementReport,
13
+ ContractEnforcementIssue,
14
+ PatternCounts,
15
+ DefensivePattern,
16
+ DetectionResult,
17
+ } from './types';
@@ -0,0 +1,40 @@
1
+ import { createProvider, ToolName, groupIssuesByFile } from '@aiready/core';
2
+ import type { ScanOptions, AnalysisResult, SpokeOutput } from '@aiready/core';
3
+ import { analyzeContractEnforcement } from './analyzer';
4
+ import { calculateContractEnforcementScore } from './scoring';
5
+ import type {
6
+ ContractEnforcementOptions,
7
+ ContractEnforcementReport,
8
+ } from './types';
9
+
10
+ export const ContractEnforcementProvider = createProvider({
11
+ id: ToolName.ContractEnforcement,
12
+ alias: ['contract', 'ce', 'enforcement'],
13
+ version: '0.1.0',
14
+ defaultWeight: 10,
15
+
16
+ async analyzeReport(options: ScanOptions) {
17
+ return analyzeContractEnforcement(options as ContractEnforcementOptions);
18
+ },
19
+
20
+ getResults(report: ContractEnforcementReport): AnalysisResult[] {
21
+ return groupIssuesByFile(report.issues);
22
+ },
23
+
24
+ getSummary(report: ContractEnforcementReport) {
25
+ return report.summary;
26
+ },
27
+
28
+ getMetadata(report: ContractEnforcementReport) {
29
+ return { rawData: report.rawData };
30
+ },
31
+
32
+ score(output: SpokeOutput, _options: ScanOptions) {
33
+ const rawData = (output.metadata as any)?.rawData ?? {};
34
+ return calculateContractEnforcementScore(
35
+ rawData,
36
+ rawData.totalLines ?? 1,
37
+ rawData.sourceFiles ?? 1
38
+ ) as any;
39
+ },
40
+ });
package/src/scoring.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { PatternCounts, ScoreResult } from './types';
2
+
3
+ const DIMENSION_WEIGHTS = {
4
+ typeEscapeHatch: 0.35,
5
+ fallbackCascade: 0.25,
6
+ errorTransparency: 0.2,
7
+ boundaryValidation: 0.2,
8
+ };
9
+
10
+ function clamp(v: number, min: number, max: number): number {
11
+ return Math.max(min, Math.min(max, v));
12
+ }
13
+
14
+ export function calculateContractEnforcementScore(
15
+ counts: PatternCounts,
16
+ totalLines: number,
17
+ _fileCount: number
18
+ ): ScoreResult {
19
+ const loc = Math.max(1, totalLines);
20
+
21
+ const typeEscapeCount =
22
+ counts['as-any'] +
23
+ counts['as-unknown'] +
24
+ counts['any-parameter'] +
25
+ counts['any-return'];
26
+ const fallbackCount =
27
+ counts['deep-optional-chain'] + counts['nullish-literal-default'];
28
+ const errorCount = counts['swallowed-error'];
29
+ const boundaryCount = counts['env-fallback'] + counts['unnecessary-guard'];
30
+
31
+ // Density: patterns per 1000 LOC
32
+ const typeDensity = (typeEscapeCount / loc) * 1000;
33
+ const fallbackDensity = (fallbackCount / loc) * 1000;
34
+ const errorDensity = (errorCount / loc) * 1000;
35
+ const boundaryDensity = (boundaryCount / loc) * 1000;
36
+
37
+ // Dimension scores: 100 = no patterns, 0 = very high density
38
+ const typeEscapeHatchScore = clamp(100 - typeDensity * 15, 0, 100);
39
+ const fallbackCascadeScore = clamp(100 - fallbackDensity * 12, 0, 100);
40
+ const errorTransparencyScore = clamp(100 - errorDensity * 25, 0, 100);
41
+ const boundaryValidationScore = clamp(100 - boundaryDensity * 10, 0, 100);
42
+
43
+ const score = Math.round(
44
+ typeEscapeHatchScore * DIMENSION_WEIGHTS.typeEscapeHatch +
45
+ fallbackCascadeScore * DIMENSION_WEIGHTS.fallbackCascade +
46
+ errorTransparencyScore * DIMENSION_WEIGHTS.errorTransparency +
47
+ boundaryValidationScore * DIMENSION_WEIGHTS.boundaryValidation
48
+ );
49
+
50
+ const rating =
51
+ score >= 90
52
+ ? 'excellent'
53
+ : score >= 75
54
+ ? 'good'
55
+ : score >= 60
56
+ ? 'moderate'
57
+ : score >= 40
58
+ ? 'needs-work'
59
+ : 'critical';
60
+
61
+ const recommendations: string[] = [];
62
+ if (typeEscapeHatchScore < 60) {
63
+ recommendations.push(
64
+ `Reduce type escape hatches (${typeEscapeCount} found): define proper types at system boundaries instead of \`as any\`/parameter \`any\`.`
65
+ );
66
+ }
67
+ if (fallbackCascadeScore < 60) {
68
+ recommendations.push(
69
+ `Reduce fallback cascades (${fallbackCount} found): enforce non-nullable types at the source so consumers don't need \`?.\`/?? fallbacks.`
70
+ );
71
+ }
72
+ if (errorTransparencyScore < 60) {
73
+ recommendations.push(
74
+ `Fix swallowed errors (${errorCount} found): log or propagate errors so failures are visible.`
75
+ );
76
+ }
77
+ if (boundaryValidationScore < 60) {
78
+ recommendations.push(
79
+ `Add boundary validation (${boundaryCount} gaps): use a Zod schema for env vars and API inputs instead of inline fallbacks.`
80
+ );
81
+ }
82
+
83
+ return {
84
+ score,
85
+ rating,
86
+ dimensions: {
87
+ typeEscapeHatchScore: Math.round(typeEscapeHatchScore),
88
+ fallbackCascadeScore: Math.round(fallbackCascadeScore),
89
+ errorTransparencyScore: Math.round(errorTransparencyScore),
90
+ boundaryValidationScore: Math.round(boundaryValidationScore),
91
+ },
92
+ recommendations,
93
+ };
94
+ }