@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.
- package/.turbo/turbo-build.log +18 -0
- package/.turbo/turbo-test.log +16 -0
- package/dist/index.d.mts +69 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +484 -0
- package/dist/index.mjs +456 -0
- package/package.json +35 -0
- package/src/__tests__/detector.test.ts +148 -0
- package/src/__tests__/provider.test.ts +26 -0
- package/src/__tests__/scoring.test.ts +58 -0
- package/src/analyzer.ts +80 -0
- package/src/detector.ts +373 -0
- package/src/index.ts +17 -0
- package/src/provider.ts +40 -0
- package/src/scoring.ts +94 -0
- package/src/types.ts +82 -0
- package/tsconfig.json +9 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { ToolRegistry } from "@aiready/core";
|
|
3
|
+
|
|
4
|
+
// src/provider.ts
|
|
5
|
+
import { createProvider, ToolName, groupIssuesByFile } from "@aiready/core";
|
|
6
|
+
|
|
7
|
+
// src/analyzer.ts
|
|
8
|
+
import { readFileSync } from "fs";
|
|
9
|
+
import { scanFiles, runBatchAnalysis } from "@aiready/core";
|
|
10
|
+
|
|
11
|
+
// src/detector.ts
|
|
12
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
13
|
+
import { Severity, IssueType } from "@aiready/core";
|
|
14
|
+
|
|
15
|
+
// src/types.ts
|
|
16
|
+
var ZERO_COUNTS = {
|
|
17
|
+
"as-any": 0,
|
|
18
|
+
"as-unknown": 0,
|
|
19
|
+
"deep-optional-chain": 0,
|
|
20
|
+
"nullish-literal-default": 0,
|
|
21
|
+
"swallowed-error": 0,
|
|
22
|
+
"env-fallback": 0,
|
|
23
|
+
"unnecessary-guard": 0,
|
|
24
|
+
"any-parameter": 0,
|
|
25
|
+
"any-return": 0
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// src/detector.ts
|
|
29
|
+
function makeIssue(pattern, severity, message, filePath, line, column, context) {
|
|
30
|
+
return {
|
|
31
|
+
type: IssueType.ContractGap,
|
|
32
|
+
severity,
|
|
33
|
+
pattern,
|
|
34
|
+
message,
|
|
35
|
+
location: { file: filePath, line, column },
|
|
36
|
+
context,
|
|
37
|
+
suggestion: getSuggestion(pattern)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function getSuggestion(pattern) {
|
|
41
|
+
switch (pattern) {
|
|
42
|
+
case "as-any":
|
|
43
|
+
return "Define a proper type or use type narrowing instead of `as any`.";
|
|
44
|
+
case "as-unknown":
|
|
45
|
+
return "Use a single validated type assertion or schema validation instead of `as unknown as`.";
|
|
46
|
+
case "deep-optional-chain":
|
|
47
|
+
return "Enforce a non-nullable type at the source to eliminate deep optional chaining.";
|
|
48
|
+
case "nullish-literal-default":
|
|
49
|
+
return "Define defaults in a typed config object rather than inline literal fallbacks.";
|
|
50
|
+
case "swallowed-error":
|
|
51
|
+
return "Log or propagate errors \u2014 silent catch blocks hide failures.";
|
|
52
|
+
case "env-fallback":
|
|
53
|
+
return "Use a validated env schema (e.g., Zod) to enforce required variables at startup.";
|
|
54
|
+
case "unnecessary-guard":
|
|
55
|
+
return "Make the parameter non-nullable in the type signature to eliminate the guard.";
|
|
56
|
+
case "any-parameter":
|
|
57
|
+
return "Define a proper type for this parameter instead of `any`.";
|
|
58
|
+
case "any-return":
|
|
59
|
+
return "Define a proper return type instead of `any`.";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function getLineContent(code, line) {
|
|
63
|
+
const lines = code.split("\n");
|
|
64
|
+
return (lines[line - 1] || "").trim().slice(0, 120);
|
|
65
|
+
}
|
|
66
|
+
function countOptionalChainDepth(node) {
|
|
67
|
+
let depth = 0;
|
|
68
|
+
let current = node;
|
|
69
|
+
while (current) {
|
|
70
|
+
if (current.type === "MemberExpression" && current.optional) {
|
|
71
|
+
depth++;
|
|
72
|
+
current = current.object;
|
|
73
|
+
} else if (current.type === "ChainExpression") {
|
|
74
|
+
current = current.expression;
|
|
75
|
+
} else if (current.type === "CallExpression" && current.optional) {
|
|
76
|
+
depth++;
|
|
77
|
+
current = current.callee;
|
|
78
|
+
} else {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return depth;
|
|
83
|
+
}
|
|
84
|
+
function isLiteral(node) {
|
|
85
|
+
if (!node) return false;
|
|
86
|
+
if (node.type === "Literal") return true;
|
|
87
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0)
|
|
88
|
+
return true;
|
|
89
|
+
if (node.type === "UnaryExpression" && (node.operator === "-" || node.operator === "+")) {
|
|
90
|
+
return isLiteral(node.argument);
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
function isProcessEnvAccess(node) {
|
|
95
|
+
return node?.type === "MemberExpression" && node.object?.type === "MemberExpression" && node.object.object?.name === "process" && node.object.property?.name === "env";
|
|
96
|
+
}
|
|
97
|
+
function isSwallowedCatch(body) {
|
|
98
|
+
if (body.length === 0) return true;
|
|
99
|
+
if (body.length === 1) {
|
|
100
|
+
const stmt = body[0];
|
|
101
|
+
if (stmt.type === "ExpressionStatement" && stmt.expression?.type === "CallExpression") {
|
|
102
|
+
const callee = stmt.expression.callee;
|
|
103
|
+
if (callee?.object?.name === "console") return true;
|
|
104
|
+
}
|
|
105
|
+
if (stmt.type === "ThrowStatement") return false;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
function detectDefensivePatterns(filePath, code, minChainDepth = 3) {
|
|
110
|
+
const issues = [];
|
|
111
|
+
const counts = { ...ZERO_COUNTS };
|
|
112
|
+
const totalLines = code.split("\n").length;
|
|
113
|
+
let ast;
|
|
114
|
+
try {
|
|
115
|
+
ast = parse(code, {
|
|
116
|
+
filePath,
|
|
117
|
+
loc: true,
|
|
118
|
+
range: true,
|
|
119
|
+
jsx: filePath.endsWith("x")
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
return { issues, counts, totalLines };
|
|
123
|
+
}
|
|
124
|
+
const nodesAtFunctionStart = /* @__PURE__ */ new WeakSet();
|
|
125
|
+
function markFunctionParamNodes(node) {
|
|
126
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
|
|
127
|
+
const body = node.body?.type === "BlockStatement" ? node.body.body : null;
|
|
128
|
+
if (body && body.length > 0) {
|
|
129
|
+
nodesAtFunctionStart.add(body[0]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function visit(node, _parent, _keyInParent) {
|
|
134
|
+
if (!node || typeof node !== "object") return;
|
|
135
|
+
markFunctionParamNodes(node);
|
|
136
|
+
if (node.type === "TSAsExpression" && node.typeAnnotation?.type === "TSAnyKeyword") {
|
|
137
|
+
counts["as-any"]++;
|
|
138
|
+
issues.push(
|
|
139
|
+
makeIssue(
|
|
140
|
+
"as-any",
|
|
141
|
+
Severity.Major,
|
|
142
|
+
"`as any` type assertion bypasses type safety",
|
|
143
|
+
filePath,
|
|
144
|
+
node.loc?.start.line ?? 0,
|
|
145
|
+
node.loc?.start.column ?? 0,
|
|
146
|
+
getLineContent(code, node.loc?.start.line ?? 0)
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (node.type === "TSAsExpression" && node.typeAnnotation?.type === "TSUnknownKeyword") {
|
|
151
|
+
counts["as-unknown"]++;
|
|
152
|
+
issues.push(
|
|
153
|
+
makeIssue(
|
|
154
|
+
"as-unknown",
|
|
155
|
+
Severity.Major,
|
|
156
|
+
"`as unknown` double-cast bypasses type safety",
|
|
157
|
+
filePath,
|
|
158
|
+
node.loc?.start.line ?? 0,
|
|
159
|
+
node.loc?.start.column ?? 0,
|
|
160
|
+
getLineContent(code, node.loc?.start.line ?? 0)
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (node.type === "ChainExpression") {
|
|
165
|
+
const depth = countOptionalChainDepth(node);
|
|
166
|
+
if (depth >= minChainDepth) {
|
|
167
|
+
counts["deep-optional-chain"]++;
|
|
168
|
+
issues.push(
|
|
169
|
+
makeIssue(
|
|
170
|
+
"deep-optional-chain",
|
|
171
|
+
Severity.Minor,
|
|
172
|
+
`Optional chain depth of ${depth} indicates missing structural guarantees`,
|
|
173
|
+
filePath,
|
|
174
|
+
node.loc?.start.line ?? 0,
|
|
175
|
+
node.loc?.start.column ?? 0,
|
|
176
|
+
getLineContent(code, node.loc?.start.line ?? 0)
|
|
177
|
+
)
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (node.type === "LogicalExpression" && node.operator === "??" && isLiteral(node.right)) {
|
|
182
|
+
counts["nullish-literal-default"]++;
|
|
183
|
+
issues.push(
|
|
184
|
+
makeIssue(
|
|
185
|
+
"nullish-literal-default",
|
|
186
|
+
Severity.Minor,
|
|
187
|
+
"Nullish coalescing with literal default suggests missing upstream type guarantee",
|
|
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
|
+
if (node.type === "TryStatement" && node.handler) {
|
|
196
|
+
const catchBody = node.handler.body?.body;
|
|
197
|
+
if (catchBody && isSwallowedCatch(catchBody)) {
|
|
198
|
+
counts["swallowed-error"]++;
|
|
199
|
+
issues.push(
|
|
200
|
+
makeIssue(
|
|
201
|
+
"swallowed-error",
|
|
202
|
+
Severity.Major,
|
|
203
|
+
"Error is swallowed in catch block \u2014 failures will be silent",
|
|
204
|
+
filePath,
|
|
205
|
+
node.handler.loc?.start.line ?? 0,
|
|
206
|
+
node.handler.loc?.start.column ?? 0,
|
|
207
|
+
getLineContent(code, node.handler.loc?.start.line ?? 0)
|
|
208
|
+
)
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (node.type === "LogicalExpression" && node.operator === "||" && isProcessEnvAccess(node.left)) {
|
|
213
|
+
counts["env-fallback"]++;
|
|
214
|
+
issues.push(
|
|
215
|
+
makeIssue(
|
|
216
|
+
"env-fallback",
|
|
217
|
+
Severity.Minor,
|
|
218
|
+
"Environment variable with fallback \u2014 use a validated env schema instead",
|
|
219
|
+
filePath,
|
|
220
|
+
node.loc?.start.line ?? 0,
|
|
221
|
+
node.loc?.start.column ?? 0,
|
|
222
|
+
getLineContent(code, node.loc?.start.line ?? 0)
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (node.type === "IfStatement" && node.test?.type === "UnaryExpression" && node.test.operator === "!") {
|
|
227
|
+
const consequent = node.consequent;
|
|
228
|
+
let isReturn = false;
|
|
229
|
+
if (consequent.type === "ReturnStatement") {
|
|
230
|
+
isReturn = true;
|
|
231
|
+
} else if (consequent.type === "BlockStatement" && consequent.body?.length === 1 && consequent.body[0].type === "ReturnStatement") {
|
|
232
|
+
isReturn = true;
|
|
233
|
+
}
|
|
234
|
+
if (isReturn && nodesAtFunctionStart.has(node)) {
|
|
235
|
+
counts["unnecessary-guard"]++;
|
|
236
|
+
issues.push(
|
|
237
|
+
makeIssue(
|
|
238
|
+
"unnecessary-guard",
|
|
239
|
+
Severity.Info,
|
|
240
|
+
"Guard clause could be eliminated with non-nullable type at source",
|
|
241
|
+
filePath,
|
|
242
|
+
node.loc?.start.line ?? 0,
|
|
243
|
+
node.loc?.start.column ?? 0,
|
|
244
|
+
getLineContent(code, node.loc?.start.line ?? 0)
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if ((node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") && node.params) {
|
|
250
|
+
for (const param of node.params) {
|
|
251
|
+
const typeAnno = param.typeAnnotation?.typeAnnotation ?? param.typeAnnotation;
|
|
252
|
+
if (typeAnno?.type === "TSAnyKeyword") {
|
|
253
|
+
counts["any-parameter"]++;
|
|
254
|
+
issues.push(
|
|
255
|
+
makeIssue(
|
|
256
|
+
"any-parameter",
|
|
257
|
+
Severity.Major,
|
|
258
|
+
"Parameter typed as `any` bypasses type safety",
|
|
259
|
+
filePath,
|
|
260
|
+
param.loc?.start.line ?? 0,
|
|
261
|
+
param.loc?.start.column ?? 0,
|
|
262
|
+
getLineContent(code, param.loc?.start.line ?? 0)
|
|
263
|
+
)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const returnAnno = node.returnType?.typeAnnotation ?? node.returnType;
|
|
268
|
+
if (returnAnno?.type === "TSAnyKeyword") {
|
|
269
|
+
counts["any-return"]++;
|
|
270
|
+
issues.push(
|
|
271
|
+
makeIssue(
|
|
272
|
+
"any-return",
|
|
273
|
+
Severity.Major,
|
|
274
|
+
"Return type is `any` \u2014 callers cannot rely on the result shape",
|
|
275
|
+
filePath,
|
|
276
|
+
node.returnType?.loc?.start.line ?? 0,
|
|
277
|
+
node.returnType?.loc?.start.column ?? 0,
|
|
278
|
+
getLineContent(code, node.returnType?.loc?.start.line ?? 0)
|
|
279
|
+
)
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const key in node) {
|
|
284
|
+
if (key === "loc" || key === "range" || key === "parent") continue;
|
|
285
|
+
const child = node[key];
|
|
286
|
+
if (Array.isArray(child)) {
|
|
287
|
+
for (const item of child) {
|
|
288
|
+
if (item && typeof item.type === "string") {
|
|
289
|
+
visit(item, node, key);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} else if (child && typeof child === "object" && typeof child.type === "string") {
|
|
293
|
+
visit(child, node, key);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
visit(ast);
|
|
298
|
+
return { issues, counts, totalLines };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/scoring.ts
|
|
302
|
+
var DIMENSION_WEIGHTS = {
|
|
303
|
+
typeEscapeHatch: 0.35,
|
|
304
|
+
fallbackCascade: 0.25,
|
|
305
|
+
errorTransparency: 0.2,
|
|
306
|
+
boundaryValidation: 0.2
|
|
307
|
+
};
|
|
308
|
+
function clamp(v, min, max) {
|
|
309
|
+
return Math.max(min, Math.min(max, v));
|
|
310
|
+
}
|
|
311
|
+
function calculateContractEnforcementScore(counts, totalLines, _fileCount) {
|
|
312
|
+
const loc = Math.max(1, totalLines);
|
|
313
|
+
const typeEscapeCount = counts["as-any"] + counts["as-unknown"] + counts["any-parameter"] + counts["any-return"];
|
|
314
|
+
const fallbackCount = counts["deep-optional-chain"] + counts["nullish-literal-default"];
|
|
315
|
+
const errorCount = counts["swallowed-error"];
|
|
316
|
+
const boundaryCount = counts["env-fallback"] + counts["unnecessary-guard"];
|
|
317
|
+
const typeDensity = typeEscapeCount / loc * 1e3;
|
|
318
|
+
const fallbackDensity = fallbackCount / loc * 1e3;
|
|
319
|
+
const errorDensity = errorCount / loc * 1e3;
|
|
320
|
+
const boundaryDensity = boundaryCount / loc * 1e3;
|
|
321
|
+
const typeEscapeHatchScore = clamp(100 - typeDensity * 15, 0, 100);
|
|
322
|
+
const fallbackCascadeScore = clamp(100 - fallbackDensity * 12, 0, 100);
|
|
323
|
+
const errorTransparencyScore = clamp(100 - errorDensity * 25, 0, 100);
|
|
324
|
+
const boundaryValidationScore = clamp(100 - boundaryDensity * 10, 0, 100);
|
|
325
|
+
const score = Math.round(
|
|
326
|
+
typeEscapeHatchScore * DIMENSION_WEIGHTS.typeEscapeHatch + fallbackCascadeScore * DIMENSION_WEIGHTS.fallbackCascade + errorTransparencyScore * DIMENSION_WEIGHTS.errorTransparency + boundaryValidationScore * DIMENSION_WEIGHTS.boundaryValidation
|
|
327
|
+
);
|
|
328
|
+
const rating = score >= 90 ? "excellent" : score >= 75 ? "good" : score >= 60 ? "moderate" : score >= 40 ? "needs-work" : "critical";
|
|
329
|
+
const recommendations = [];
|
|
330
|
+
if (typeEscapeHatchScore < 60) {
|
|
331
|
+
recommendations.push(
|
|
332
|
+
`Reduce type escape hatches (${typeEscapeCount} found): define proper types at system boundaries instead of \`as any\`/parameter \`any\`.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (fallbackCascadeScore < 60) {
|
|
336
|
+
recommendations.push(
|
|
337
|
+
`Reduce fallback cascades (${fallbackCount} found): enforce non-nullable types at the source so consumers don't need \`?.\`/?? fallbacks.`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (errorTransparencyScore < 60) {
|
|
341
|
+
recommendations.push(
|
|
342
|
+
`Fix swallowed errors (${errorCount} found): log or propagate errors so failures are visible.`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (boundaryValidationScore < 60) {
|
|
346
|
+
recommendations.push(
|
|
347
|
+
`Add boundary validation (${boundaryCount} gaps): use a Zod schema for env vars and API inputs instead of inline fallbacks.`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
score,
|
|
352
|
+
rating,
|
|
353
|
+
dimensions: {
|
|
354
|
+
typeEscapeHatchScore: Math.round(typeEscapeHatchScore),
|
|
355
|
+
fallbackCascadeScore: Math.round(fallbackCascadeScore),
|
|
356
|
+
errorTransparencyScore: Math.round(errorTransparencyScore),
|
|
357
|
+
boundaryValidationScore: Math.round(boundaryValidationScore)
|
|
358
|
+
},
|
|
359
|
+
recommendations
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/analyzer.ts
|
|
364
|
+
async function analyzeContractEnforcement(options) {
|
|
365
|
+
const files = await scanFiles({
|
|
366
|
+
...options,
|
|
367
|
+
include: options.include || ["**/*.{ts,tsx,js,jsx}"]
|
|
368
|
+
});
|
|
369
|
+
const allIssues = [];
|
|
370
|
+
const aggregate = { ...ZERO_COUNTS };
|
|
371
|
+
let totalLines = 0;
|
|
372
|
+
await runBatchAnalysis(
|
|
373
|
+
files,
|
|
374
|
+
"scanning for defensive patterns",
|
|
375
|
+
"contract-enforcement",
|
|
376
|
+
options.onProgress,
|
|
377
|
+
async (f) => {
|
|
378
|
+
let code;
|
|
379
|
+
try {
|
|
380
|
+
code = readFileSync(f, "utf-8");
|
|
381
|
+
} catch {
|
|
382
|
+
return { issues: [], counts: { ...ZERO_COUNTS }, totalLines: 0 };
|
|
383
|
+
}
|
|
384
|
+
return detectDefensivePatterns(f, code, options.minChainDepth ?? 3);
|
|
385
|
+
},
|
|
386
|
+
(result) => {
|
|
387
|
+
allIssues.push(...result.issues);
|
|
388
|
+
totalLines += result.totalLines;
|
|
389
|
+
for (const key of Object.keys(aggregate)) {
|
|
390
|
+
aggregate[key] += result.counts[key];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
);
|
|
394
|
+
const totalPatterns = Object.values(aggregate).reduce((a, b) => a + b, 0);
|
|
395
|
+
const density = totalLines > 0 ? Math.round(totalPatterns / totalLines * 1e4) / 10 : 0;
|
|
396
|
+
const scoreResult = calculateContractEnforcementScore(
|
|
397
|
+
aggregate,
|
|
398
|
+
totalLines,
|
|
399
|
+
files.length
|
|
400
|
+
);
|
|
401
|
+
return {
|
|
402
|
+
summary: {
|
|
403
|
+
sourceFiles: files.length,
|
|
404
|
+
totalDefensivePatterns: totalPatterns,
|
|
405
|
+
defensiveDensity: density,
|
|
406
|
+
score: scoreResult.score,
|
|
407
|
+
rating: scoreResult.rating,
|
|
408
|
+
dimensions: {
|
|
409
|
+
typeEscapeHatchScore: scoreResult.dimensions.typeEscapeHatchScore,
|
|
410
|
+
fallbackCascadeScore: scoreResult.dimensions.fallbackCascadeScore,
|
|
411
|
+
errorTransparencyScore: scoreResult.dimensions.errorTransparencyScore,
|
|
412
|
+
boundaryValidationScore: scoreResult.dimensions.boundaryValidationScore
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
issues: allIssues,
|
|
416
|
+
rawData: { ...aggregate, sourceFiles: files.length, totalLines },
|
|
417
|
+
recommendations: scoreResult.recommendations
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/provider.ts
|
|
422
|
+
var ContractEnforcementProvider = createProvider({
|
|
423
|
+
id: ToolName.ContractEnforcement,
|
|
424
|
+
alias: ["contract", "ce", "enforcement"],
|
|
425
|
+
version: "0.1.0",
|
|
426
|
+
defaultWeight: 10,
|
|
427
|
+
async analyzeReport(options) {
|
|
428
|
+
return analyzeContractEnforcement(options);
|
|
429
|
+
},
|
|
430
|
+
getResults(report) {
|
|
431
|
+
return groupIssuesByFile(report.issues);
|
|
432
|
+
},
|
|
433
|
+
getSummary(report) {
|
|
434
|
+
return report.summary;
|
|
435
|
+
},
|
|
436
|
+
getMetadata(report) {
|
|
437
|
+
return { rawData: report.rawData };
|
|
438
|
+
},
|
|
439
|
+
score(output, _options) {
|
|
440
|
+
const rawData = output.metadata?.rawData ?? {};
|
|
441
|
+
return calculateContractEnforcementScore(
|
|
442
|
+
rawData,
|
|
443
|
+
rawData.totalLines ?? 1,
|
|
444
|
+
rawData.sourceFiles ?? 1
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// src/index.ts
|
|
450
|
+
ToolRegistry.register(ContractEnforcementProvider);
|
|
451
|
+
export {
|
|
452
|
+
ContractEnforcementProvider,
|
|
453
|
+
analyzeContractEnforcement,
|
|
454
|
+
calculateContractEnforcementScore,
|
|
455
|
+
detectDefensivePatterns
|
|
456
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aiready/contract-enforcement",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Measures structural contract enforcement to reduce defensive coding cascades",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"require": "./dist/index.cjs",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@typescript-eslint/typescript-estree": "^8.53.0",
|
|
17
|
+
"@aiready/core": "0.24.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^24.0.0",
|
|
21
|
+
"tsup": "^8.3.5",
|
|
22
|
+
"typescript": "^5.0.0",
|
|
23
|
+
"vitest": "^4.0.0"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
30
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"lint": "eslint src",
|
|
33
|
+
"clean": "rm -rf dist"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { detectDefensivePatterns } from '../detector';
|
|
3
|
+
|
|
4
|
+
describe('detectDefensivePatterns', () => {
|
|
5
|
+
const filePath = '/test/file.ts';
|
|
6
|
+
|
|
7
|
+
it('detects `as any` type assertions', () => {
|
|
8
|
+
const code = `
|
|
9
|
+
function foo(x: unknown) {
|
|
10
|
+
const y = x as any;
|
|
11
|
+
return y.bar;
|
|
12
|
+
}
|
|
13
|
+
`;
|
|
14
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
15
|
+
expect(result.counts['as-any']).toBe(1);
|
|
16
|
+
expect(result.issues[0].pattern).toBe('as-any');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('detects `as unknown as` double casts', () => {
|
|
20
|
+
const code = `
|
|
21
|
+
const x = data as unknown as Foo;
|
|
22
|
+
`;
|
|
23
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
24
|
+
expect(result.counts['as-unknown']).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('detects deep optional chaining (depth >= 3)', () => {
|
|
28
|
+
const code = `
|
|
29
|
+
const val = obj?.foo?.bar?.baz;
|
|
30
|
+
`;
|
|
31
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
32
|
+
expect(result.counts['deep-optional-chain']).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('does not flag shallow optional chaining (depth < 3)', () => {
|
|
36
|
+
const code = `
|
|
37
|
+
const val = obj?.foo?.bar;
|
|
38
|
+
`;
|
|
39
|
+
const result = detectDefensivePatterns(filePath, code, 3);
|
|
40
|
+
expect(result.counts['deep-optional-chain']).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('detects nullish coalescing with literal default', () => {
|
|
44
|
+
const code = `
|
|
45
|
+
const x = value ?? 'default';
|
|
46
|
+
const y = count ?? 0;
|
|
47
|
+
`;
|
|
48
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
49
|
+
expect(result.counts['nullish-literal-default']).toBe(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not flag nullish coalescing with variable', () => {
|
|
53
|
+
const code = `
|
|
54
|
+
const x = value ?? fallback;
|
|
55
|
+
`;
|
|
56
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
57
|
+
expect(result.counts['nullish-literal-default']).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('detects swallowed errors (empty catch)', () => {
|
|
61
|
+
const code = `
|
|
62
|
+
try {
|
|
63
|
+
doSomething();
|
|
64
|
+
} catch {}
|
|
65
|
+
`;
|
|
66
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
67
|
+
expect(result.counts['swallowed-error']).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('detects swallowed errors (console.log only)', () => {
|
|
71
|
+
const code = `
|
|
72
|
+
try {
|
|
73
|
+
doSomething();
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.error(e);
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
79
|
+
expect(result.counts['swallowed-error']).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not flag catch blocks with real handling', () => {
|
|
83
|
+
const code = `
|
|
84
|
+
try {
|
|
85
|
+
doSomething();
|
|
86
|
+
} catch (e) {
|
|
87
|
+
handleError(e);
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
92
|
+
expect(result.counts['swallowed-error']).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('detects process.env fallbacks', () => {
|
|
96
|
+
const code = `
|
|
97
|
+
const region = process.env.AWS_REGION || 'us-east-1';
|
|
98
|
+
`;
|
|
99
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
100
|
+
expect(result.counts['env-fallback']).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('detects `any` parameter types', () => {
|
|
104
|
+
const code = `
|
|
105
|
+
function handler(data: any): string {
|
|
106
|
+
return data.value;
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
110
|
+
expect(result.counts['any-parameter']).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('detects `any` return types', () => {
|
|
114
|
+
const code = `
|
|
115
|
+
function parse(raw: string): any {
|
|
116
|
+
return JSON.parse(raw);
|
|
117
|
+
}
|
|
118
|
+
`;
|
|
119
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
120
|
+
expect(result.counts['any-return']).toBe(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('handles invalid code gracefully', () => {
|
|
124
|
+
const code = `this is not valid typescript {{{`;
|
|
125
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
126
|
+
expect(result.totalLines).toBeGreaterThan(0);
|
|
127
|
+
expect(result.issues).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('detects multiple patterns in one file', () => {
|
|
131
|
+
const code = `
|
|
132
|
+
function process(data: any): any {
|
|
133
|
+
const val = data?.foo?.bar?.baz ?? 'fallback';
|
|
134
|
+
const region = process.env.REGION || 'ap-southeast-2';
|
|
135
|
+
try {
|
|
136
|
+
return val;
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
`;
|
|
140
|
+
const result = detectDefensivePatterns(filePath, code);
|
|
141
|
+
expect(result.counts['any-parameter']).toBe(1);
|
|
142
|
+
expect(result.counts['any-return']).toBe(1);
|
|
143
|
+
expect(result.counts['deep-optional-chain']).toBe(1);
|
|
144
|
+
expect(result.counts['nullish-literal-default']).toBe(1);
|
|
145
|
+
expect(result.counts['env-fallback']).toBe(1);
|
|
146
|
+
expect(result.counts['swallowed-error']).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ContractEnforcementProvider } from '../provider';
|
|
3
|
+
import { ToolName } from '@aiready/core';
|
|
4
|
+
|
|
5
|
+
describe('ContractEnforcementProvider', () => {
|
|
6
|
+
it('has correct tool ID', () => {
|
|
7
|
+
expect(ContractEnforcementProvider.id).toBe(ToolName.ContractEnforcement);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('has aliases', () => {
|
|
11
|
+
expect(ContractEnforcementProvider.alias).toContain('contract');
|
|
12
|
+
expect(ContractEnforcementProvider.alias).toContain('ce');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('has default weight', () => {
|
|
16
|
+
expect(ContractEnforcementProvider.defaultWeight).toBe(10);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('has analyze function', () => {
|
|
20
|
+
expect(typeof ContractEnforcementProvider.analyze).toBe('function');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('has score function', () => {
|
|
24
|
+
expect(typeof ContractEnforcementProvider.score).toBe('function');
|
|
25
|
+
});
|
|
26
|
+
});
|