@webpieces/code-rules 0.0.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.
- package/LICENSE +373 -0
- package/jest.config.ts +20 -0
- package/package.json +23 -0
- package/project.json +22 -0
- package/src/cli.ts +17 -0
- package/src/diff-utils.ts +129 -0
- package/src/from-shared-config.ts +118 -0
- package/src/index.ts +14 -0
- package/src/validate-catch-error-pattern.ts +639 -0
- package/src/validate-code.ts +491 -0
- package/src/validate-dtos.ts +697 -0
- package/src/validate-modified-files.ts +579 -0
- package/src/validate-modified-methods.ts +812 -0
- package/src/validate-new-methods.ts +594 -0
- package/src/validate-no-any-unknown.ts +552 -0
- package/src/validate-no-destructure.ts +588 -0
- package/src/validate-no-direct-api-resolver.ts +676 -0
- package/src/validate-no-implicit-any.ts +378 -0
- package/src/validate-no-inline-types.ts +787 -0
- package/src/validate-no-unmanaged-exceptions.ts +431 -0
- package/src/validate-prisma-converters.ts +830 -0
- package/src/validate-return-types.ts +532 -0
- package/tsconfig.json +22 -0
- package/tsconfig.lib.json +10 -0
- package/tsconfig.spec.json +14 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate No Implicit Any Executor
|
|
3
|
+
*
|
|
4
|
+
* Flags function parameters, variables, and object-literal properties whose
|
|
5
|
+
* types collapse to the implicit `any` produced by TypeScript inference when
|
|
6
|
+
* an annotation is missing. Pairs with validate-no-any-unknown (which bans
|
|
7
|
+
* the literal keyword) so together they force developers to write real types.
|
|
8
|
+
*
|
|
9
|
+
* Detection leverages the TypeScript compiler directly: we build a ts.Program
|
|
10
|
+
* from the project's tsconfig.json with `noImplicitAny: true` overridden, then
|
|
11
|
+
* filter pre-emit diagnostics to the set of codes that describe implicit-any
|
|
12
|
+
* inferences (TS7006, TS7005, TS7018, etc.) and map them back to changed lines.
|
|
13
|
+
*
|
|
14
|
+
* ============================================================================
|
|
15
|
+
* MODES (LINE-BASED)
|
|
16
|
+
* ============================================================================
|
|
17
|
+
* - OFF: Skip validation entirely.
|
|
18
|
+
* - MODIFIED_CODE: Flag implicit-any on changed lines (lines in diff hunks).
|
|
19
|
+
* - MODIFIED_FILES: Flag ALL implicit-any in files that were modified.
|
|
20
|
+
*
|
|
21
|
+
* ============================================================================
|
|
22
|
+
* ESCAPE HATCH
|
|
23
|
+
* ============================================================================
|
|
24
|
+
* // webpieces-disable no-implicit-any -- [your justification]
|
|
25
|
+
* function handler(x) { ... }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execSync } from 'child_process';
|
|
29
|
+
import * as fs from 'fs';
|
|
30
|
+
import * as path from 'path';
|
|
31
|
+
import * as ts from 'typescript';
|
|
32
|
+
|
|
33
|
+
export type NoImplicitAnyMode = 'OFF' | 'MODIFIED_CODE' | 'MODIFIED_FILES';
|
|
34
|
+
|
|
35
|
+
export interface ValidateNoImplicitAnyOptions {
|
|
36
|
+
mode?: NoImplicitAnyMode;
|
|
37
|
+
disableAllowed?: boolean;
|
|
38
|
+
ignoreModifiedUntilEpoch?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ExecutorResult {
|
|
42
|
+
success: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ImplicitAnyViolation {
|
|
46
|
+
file: string;
|
|
47
|
+
line: number;
|
|
48
|
+
column: number;
|
|
49
|
+
code: number;
|
|
50
|
+
message: string;
|
|
51
|
+
hasDisableComment: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// TS diagnostic codes that describe an implicit-any inference. TS7010 (missing
|
|
55
|
+
// return-type annotation) is intentionally omitted because it is already
|
|
56
|
+
// covered by the sibling `require-return-type` validator.
|
|
57
|
+
const IMPLICIT_ANY_CODES: ReadonlySet<number> = new Set<number>([
|
|
58
|
+
7005, 7006, 7008, 7015, 7018, 7019, 7031, 7034, 7053,
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function listUntrackedOrEmpty(workspaceRoot: string): string[] {
|
|
62
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
63
|
+
try {
|
|
64
|
+
const output = execSync(`git ls-files --others --exclude-standard '*.ts' '*.tsx'`, {
|
|
65
|
+
cwd: workspaceRoot,
|
|
66
|
+
encoding: 'utf-8',
|
|
67
|
+
});
|
|
68
|
+
return output.trim().split('\n').filter((f: string) => f && !f.includes('.spec.ts') && !f.includes('.test.ts'));
|
|
69
|
+
} catch (err: unknown) {
|
|
70
|
+
//const error = toError(err);
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getChangedTypeScriptFiles(workspaceRoot: string, base: string, head?: string): string[] {
|
|
76
|
+
const diffTarget = head ? `${base} ${head}` : base;
|
|
77
|
+
const output = execSync(`git diff --name-only ${diffTarget} -- '*.ts' '*.tsx'`, {
|
|
78
|
+
cwd: workspaceRoot,
|
|
79
|
+
encoding: 'utf-8',
|
|
80
|
+
});
|
|
81
|
+
const changed = output.trim().split('\n').filter((f: string) => f && !f.includes('.spec.ts') && !f.includes('.test.ts'));
|
|
82
|
+
if (head) return changed;
|
|
83
|
+
return Array.from(new Set([...changed, ...listUntrackedOrEmpty(workspaceRoot)]));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getFileDiff(workspaceRoot: string, file: string, base: string, head?: string): string {
|
|
87
|
+
const diffTarget = head ? `${base} ${head}` : base;
|
|
88
|
+
const diff = execSync(`git diff ${diffTarget} -- "${file}"`, {
|
|
89
|
+
cwd: workspaceRoot,
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
});
|
|
92
|
+
if (diff || head) return diff;
|
|
93
|
+
const fullPath = path.join(workspaceRoot, file);
|
|
94
|
+
if (!fs.existsSync(fullPath)) return '';
|
|
95
|
+
const isUntracked = execSync(`git ls-files --others --exclude-standard "${file}"`, {
|
|
96
|
+
cwd: workspaceRoot,
|
|
97
|
+
encoding: 'utf-8',
|
|
98
|
+
}).trim();
|
|
99
|
+
if (!isUntracked) return '';
|
|
100
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
101
|
+
const lines = content.split('\n');
|
|
102
|
+
const hunk = `@@ -0,0 +1,${String(lines.length)} @@`;
|
|
103
|
+
return hunk + '\n' + lines.map((line: string) => `+${line}`).join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getChangedLineNumbers(diffContent: string): Set<number> {
|
|
107
|
+
const changedLines = new Set<number>();
|
|
108
|
+
const lines = diffContent.split('\n');
|
|
109
|
+
let currentLine = 0;
|
|
110
|
+
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
113
|
+
if (hunkMatch) {
|
|
114
|
+
currentLine = parseInt(hunkMatch[1], 10);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
119
|
+
changedLines.add(currentLine);
|
|
120
|
+
currentLine++;
|
|
121
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
122
|
+
// Deletions don't increment line number
|
|
123
|
+
} else {
|
|
124
|
+
currentLine++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return changedLines;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hasDisableComment(lines: string[], lineNumber: number): boolean {
|
|
132
|
+
const startCheck = Math.max(0, lineNumber - 5);
|
|
133
|
+
for (let i = lineNumber - 2; i >= startCheck; i--) {
|
|
134
|
+
const line = lines[i]?.trim() ?? '';
|
|
135
|
+
if (line.startsWith('function ') || line.startsWith('class ') || line.endsWith('}')) {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
if (line.includes('webpieces-disable') && line.includes('no-implicit-any')) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Cache one ts.Program per tsconfig.json so multiple changed files in the
|
|
146
|
+
// same project share parse/binding cost.
|
|
147
|
+
const programCache = new Map<string, ts.Program | null>();
|
|
148
|
+
|
|
149
|
+
function findTsConfigForFile(absoluteFilePath: string, workspaceRoot: string): string | null {
|
|
150
|
+
const root = path.resolve(workspaceRoot);
|
|
151
|
+
let dir = path.dirname(absoluteFilePath);
|
|
152
|
+
while (dir.startsWith(root)) {
|
|
153
|
+
const candidate = path.join(dir, 'tsconfig.json');
|
|
154
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
155
|
+
const parent = path.dirname(dir);
|
|
156
|
+
if (parent === dir) break;
|
|
157
|
+
dir = parent;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildProgram(tsconfigPath: string): ts.Program | null {
|
|
163
|
+
const configFile = ts.readConfigFile(tsconfigPath, (p: string) => ts.sys.readFile(p));
|
|
164
|
+
if (configFile.error) return null;
|
|
165
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(tsconfigPath));
|
|
166
|
+
if (parsed.errors.length > 0 || parsed.fileNames.length === 0) return null;
|
|
167
|
+
const options: ts.CompilerOptions = { ...parsed.options, noImplicitAny: true, noEmit: true, skipLibCheck: true };
|
|
168
|
+
return ts.createProgram({ rootNames: parsed.fileNames, options });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getProgramForFile(absoluteFilePath: string, workspaceRoot: string): ts.Program | null {
|
|
172
|
+
const tsconfigPath = findTsConfigForFile(absoluteFilePath, workspaceRoot);
|
|
173
|
+
if (!tsconfigPath) return null;
|
|
174
|
+
if (programCache.has(tsconfigPath)) {
|
|
175
|
+
return programCache.get(tsconfigPath) ?? null;
|
|
176
|
+
}
|
|
177
|
+
const program = buildProgram(tsconfigPath);
|
|
178
|
+
programCache.set(tsconfigPath, program);
|
|
179
|
+
return program;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function flattenMessage(message: string | ts.DiagnosticMessageChain): string {
|
|
183
|
+
return ts.flattenDiagnosticMessageText(message, ' ');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function findImplicitAnyInFile(filePath: string, workspaceRoot: string): ImplicitAnyViolation[] {
|
|
187
|
+
const absolute = path.resolve(workspaceRoot, filePath);
|
|
188
|
+
if (!fs.existsSync(absolute)) return [];
|
|
189
|
+
|
|
190
|
+
const program = getProgramForFile(absolute, workspaceRoot);
|
|
191
|
+
if (!program) return [];
|
|
192
|
+
|
|
193
|
+
const sourceFile = program.getSourceFile(absolute);
|
|
194
|
+
if (!sourceFile) return [];
|
|
195
|
+
|
|
196
|
+
const fileLines = fs.readFileSync(absolute, 'utf-8').split('\n');
|
|
197
|
+
const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile);
|
|
198
|
+
const violations: ImplicitAnyViolation[] = [];
|
|
199
|
+
|
|
200
|
+
for (const diag of diagnostics) {
|
|
201
|
+
if (!IMPLICIT_ANY_CODES.has(diag.code)) continue;
|
|
202
|
+
if (!diag.file || diag.start === undefined) continue;
|
|
203
|
+
const pos = diag.file.getLineAndCharacterOfPosition(diag.start);
|
|
204
|
+
const line = pos.line + 1;
|
|
205
|
+
const column = pos.character + 1;
|
|
206
|
+
violations.push({
|
|
207
|
+
file: filePath,
|
|
208
|
+
line,
|
|
209
|
+
column,
|
|
210
|
+
code: diag.code,
|
|
211
|
+
message: flattenMessage(diag.messageText),
|
|
212
|
+
hasDisableComment: hasDisableComment(fileLines, line),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return violations;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function findViolationsForModifiedCode(
|
|
220
|
+
workspaceRoot: string,
|
|
221
|
+
changedFiles: string[],
|
|
222
|
+
base: string,
|
|
223
|
+
head: string | undefined,
|
|
224
|
+
disableAllowed: boolean,
|
|
225
|
+
): ImplicitAnyViolation[] {
|
|
226
|
+
const results: ImplicitAnyViolation[] = [];
|
|
227
|
+
for (const file of changedFiles) {
|
|
228
|
+
const diff = getFileDiff(workspaceRoot, file, base, head);
|
|
229
|
+
const changedLines = getChangedLineNumbers(diff);
|
|
230
|
+
if (changedLines.size === 0) continue;
|
|
231
|
+
|
|
232
|
+
const all = findImplicitAnyInFile(file, workspaceRoot);
|
|
233
|
+
for (const v of all) {
|
|
234
|
+
if (disableAllowed && v.hasDisableComment) continue;
|
|
235
|
+
if (!changedLines.has(v.line)) continue;
|
|
236
|
+
results.push(v);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return results;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function findViolationsForModifiedFiles(
|
|
243
|
+
workspaceRoot: string,
|
|
244
|
+
changedFiles: string[],
|
|
245
|
+
disableAllowed: boolean,
|
|
246
|
+
): ImplicitAnyViolation[] {
|
|
247
|
+
const results: ImplicitAnyViolation[] = [];
|
|
248
|
+
for (const file of changedFiles) {
|
|
249
|
+
const all = findImplicitAnyInFile(file, workspaceRoot);
|
|
250
|
+
for (const v of all) {
|
|
251
|
+
if (disableAllowed && v.hasDisableComment) continue;
|
|
252
|
+
results.push(v);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return results;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function detectBase(workspaceRoot: string): string | null {
|
|
259
|
+
for (const ref of ['origin/main', 'main']) {
|
|
260
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
261
|
+
try {
|
|
262
|
+
const merged = execSync(`git merge-base HEAD ${ref}`, {
|
|
263
|
+
cwd: workspaceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
264
|
+
}).trim();
|
|
265
|
+
if (merged) return merged;
|
|
266
|
+
} catch (err: unknown) {
|
|
267
|
+
//const error = toError(err);
|
|
268
|
+
// try next ref
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function reportViolations(violations: ImplicitAnyViolation[], mode: NoImplicitAnyMode): void {
|
|
275
|
+
console.error('');
|
|
276
|
+
console.error('\u274c Implicit-any inferences found! Add explicit type annotations.');
|
|
277
|
+
console.error('');
|
|
278
|
+
console.error('\ud83d\udcda Why: an untyped parameter or variable erases type safety silently.');
|
|
279
|
+
console.error('');
|
|
280
|
+
console.error(' BAD: function process(input) { return input.length; }');
|
|
281
|
+
console.error(' GOOD: function process(input: string): number { return input.length; }');
|
|
282
|
+
console.error('');
|
|
283
|
+
|
|
284
|
+
for (const v of violations) {
|
|
285
|
+
console.error(` \u274c ${v.file}:${v.line}:${v.column}`);
|
|
286
|
+
console.error(` TS${v.code}: ${v.message}`);
|
|
287
|
+
}
|
|
288
|
+
console.error('');
|
|
289
|
+
console.error(' Escape hatch (use sparingly):');
|
|
290
|
+
console.error(' // webpieces-disable no-implicit-any -- [your reason]');
|
|
291
|
+
console.error('');
|
|
292
|
+
console.error(` Current mode: ${mode}`);
|
|
293
|
+
console.error('');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function resolveMode(normalMode: NoImplicitAnyMode, epoch: number | undefined): NoImplicitAnyMode {
|
|
297
|
+
if (epoch === undefined || normalMode === 'OFF') {
|
|
298
|
+
return normalMode;
|
|
299
|
+
}
|
|
300
|
+
const nowSeconds = Date.now() / 1000;
|
|
301
|
+
if (nowSeconds < epoch) {
|
|
302
|
+
const expiresDate = new Date(epoch * 1000).toISOString().split('T')[0];
|
|
303
|
+
console.log(`\n\u23ed\ufe0f Skipping no-implicit-any validation (ignoreModifiedUntilEpoch active, expires: ${expiresDate})`);
|
|
304
|
+
console.log('');
|
|
305
|
+
return 'OFF';
|
|
306
|
+
}
|
|
307
|
+
return normalMode;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function runInternal(
|
|
311
|
+
options: ValidateNoImplicitAnyOptions,
|
|
312
|
+
workspaceRoot: string,
|
|
313
|
+
): Promise<ExecutorResult> {
|
|
314
|
+
const mode: NoImplicitAnyMode = resolveMode(options.mode ?? 'OFF', options.ignoreModifiedUntilEpoch);
|
|
315
|
+
const disableAllowed = options.disableAllowed ?? true;
|
|
316
|
+
|
|
317
|
+
if (mode === 'OFF') {
|
|
318
|
+
console.log('\n\u23ed\ufe0f Skipping no-implicit-any validation (mode: OFF)');
|
|
319
|
+
console.log('');
|
|
320
|
+
return { success: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.log('\n\ud83d\udccf Validating No Implicit Any\n');
|
|
324
|
+
console.log(` Mode: ${mode}`);
|
|
325
|
+
|
|
326
|
+
let base = process.env['NX_BASE'];
|
|
327
|
+
const head = process.env['NX_HEAD'];
|
|
328
|
+
|
|
329
|
+
if (!base) {
|
|
330
|
+
base = detectBase(workspaceRoot) ?? undefined;
|
|
331
|
+
if (!base) {
|
|
332
|
+
console.log('\n\u23ed\ufe0f Skipping no-implicit-any validation (could not detect base branch)');
|
|
333
|
+
console.log('');
|
|
334
|
+
return { success: true };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log(` Base: ${base}`);
|
|
339
|
+
console.log(` Head: ${head ?? 'working tree (includes uncommitted changes)'}`);
|
|
340
|
+
console.log('');
|
|
341
|
+
|
|
342
|
+
const changedFiles = getChangedTypeScriptFiles(workspaceRoot, base, head);
|
|
343
|
+
if (changedFiles.length === 0) {
|
|
344
|
+
console.log('\u2705 No TypeScript files changed');
|
|
345
|
+
return { success: true };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
console.log(`\ud83d\udcc2 Checking ${changedFiles.length} changed file(s)...`);
|
|
349
|
+
|
|
350
|
+
let violations: ImplicitAnyViolation[] = [];
|
|
351
|
+
if (mode === 'MODIFIED_CODE') {
|
|
352
|
+
violations = findViolationsForModifiedCode(workspaceRoot, changedFiles, base, head, disableAllowed);
|
|
353
|
+
} else if (mode === 'MODIFIED_FILES') {
|
|
354
|
+
violations = findViolationsForModifiedFiles(workspaceRoot, changedFiles, disableAllowed);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (violations.length === 0) {
|
|
358
|
+
console.log('\u2705 No implicit-any inferences found');
|
|
359
|
+
return { success: true };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
reportViolations(violations, mode);
|
|
363
|
+
return { success: false };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export default async function runValidator(
|
|
367
|
+
options: ValidateNoImplicitAnyOptions,
|
|
368
|
+
workspaceRoot: string
|
|
369
|
+
): Promise<ExecutorResult> {
|
|
370
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
371
|
+
try {
|
|
372
|
+
return await runInternal(options, workspaceRoot);
|
|
373
|
+
} catch (err: unknown) {
|
|
374
|
+
//const error = toError(err);
|
|
375
|
+
console.warn('\n\u23ed\ufe0f Skipping no-implicit-any validation due to unexpected error\n');
|
|
376
|
+
return { success: true };
|
|
377
|
+
}
|
|
378
|
+
}
|