@safetnsr/vet 1.11.0 → 1.11.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/dist/checks/debt.js +62 -6
- package/dist/checks/deps.d.ts +1 -0
- package/dist/checks/deps.js +106 -3
- package/dist/checks/integrity.js +113 -10
- package/dist/checks/memory.js +14 -0
- package/dist/checks/models.js +4 -0
- package/dist/checks/owasp/asi03-identity-abuse.js +3 -1
- package/dist/checks/ready.js +33 -6
- package/dist/checks/scan.js +3 -0
- package/dist/checks/tests.js +25 -1
- package/dist/checks/verify.js +53 -2
- package/package.json +1 -1
package/dist/checks/debt.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { join, basename } from 'node:path';
|
|
1
|
+
import { join, basename, dirname } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { walkFiles, readFile } from '../util.js';
|
|
3
4
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
4
5
|
const SOURCE_EXTS = new Set(['.ts', '.js', '.tsx', '.jsx']);
|
|
@@ -232,8 +233,12 @@ function findDuplicates(allFuncs) {
|
|
|
232
233
|
if (isSpecPattern(group))
|
|
233
234
|
continue;
|
|
234
235
|
const locations = group.map(f => `${f.name} (${f.file}:${f.line})`).join(', ');
|
|
236
|
+
// Downgrade to info if all functions in the group are in test directories
|
|
237
|
+
// or if any function is in an examples/demo directory
|
|
238
|
+
const allInTest = group.every(f => isInTestDir(f.file));
|
|
239
|
+
const anyInExample = group.some(f => /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(f.file));
|
|
235
240
|
issues.push({
|
|
236
|
-
severity: 'warning',
|
|
241
|
+
severity: (allInTest || anyInExample) ? 'info' : 'warning',
|
|
237
242
|
message: `near-duplicate functions: ${locations}`,
|
|
238
243
|
file: group[0].file,
|
|
239
244
|
line: group[0].line,
|
|
@@ -267,8 +272,12 @@ function findDuplicates(allFuncs) {
|
|
|
267
272
|
if (reported.has(key))
|
|
268
273
|
continue;
|
|
269
274
|
reported.add(key);
|
|
275
|
+
// Downgrade to info if both functions are in test directories
|
|
276
|
+
// or if either is in an examples/demo directory
|
|
277
|
+
const bothInTest = isInTestDir(a.file) && isInTestDir(b.file);
|
|
278
|
+
const anyInExample = /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(a.file) || /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(b.file);
|
|
270
279
|
issues.push({
|
|
271
|
-
severity: 'warning',
|
|
280
|
+
severity: (bothInTest || anyInExample) ? 'info' : 'warning',
|
|
272
281
|
message: `similar functions (${Math.round(sim * 100)}%): ${a.name} (${a.file}:${a.line}) and ${b.name} (${b.file}:${b.line})`,
|
|
273
282
|
file: a.file,
|
|
274
283
|
line: a.line,
|
|
@@ -293,6 +302,46 @@ function isLibrary(cwd) {
|
|
|
293
302
|
return false;
|
|
294
303
|
}
|
|
295
304
|
}
|
|
305
|
+
function isMonorepo(cwd) {
|
|
306
|
+
try {
|
|
307
|
+
const pkgRaw = readFile(join(cwd, 'package.json'));
|
|
308
|
+
if (pkgRaw) {
|
|
309
|
+
const pkg = JSON.parse(pkgRaw);
|
|
310
|
+
if (Array.isArray(pkg.workspaces) || pkg.workspaces?.packages)
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch { /* skip */ }
|
|
315
|
+
if (existsSync(join(cwd, 'pnpm-workspace.yaml')))
|
|
316
|
+
return true;
|
|
317
|
+
if (existsSync(join(cwd, 'lerna.json')))
|
|
318
|
+
return true;
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
/** Find nearest package.json upward from a file path, check if it's a library */
|
|
322
|
+
function isFileInLibraryPackage(cwd, filePath) {
|
|
323
|
+
let dir = dirname(join(cwd, filePath));
|
|
324
|
+
const root = cwd;
|
|
325
|
+
while (dir.length >= root.length) {
|
|
326
|
+
const pkgPath = join(dir, 'package.json');
|
|
327
|
+
try {
|
|
328
|
+
const raw = readFile(pkgPath);
|
|
329
|
+
if (raw) {
|
|
330
|
+
// Don't count the root package.json — we already check that via isLibrary
|
|
331
|
+
if (dir === root)
|
|
332
|
+
return false;
|
|
333
|
+
const pkg = JSON.parse(raw);
|
|
334
|
+
return !!(pkg.main || pkg.exports || pkg.module || pkg.types || pkg.bin);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch { /* skip */ }
|
|
338
|
+
const parent = dirname(dir);
|
|
339
|
+
if (parent === dir)
|
|
340
|
+
break;
|
|
341
|
+
dir = parent;
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
296
345
|
function findOrphanedExports(cwd, files) {
|
|
297
346
|
const issues = [];
|
|
298
347
|
const sourceFiles = files.filter(f => isSourceFile(f) && !isTestFile(f));
|
|
@@ -358,15 +407,18 @@ function findOrphanedExports(cwd, files) {
|
|
|
358
407
|
}
|
|
359
408
|
}
|
|
360
409
|
const lib = isLibrary(cwd);
|
|
410
|
+
const mono = isMonorepo(cwd);
|
|
361
411
|
for (const exp of exports) {
|
|
362
412
|
if (!importedNames.has(exp.name)) {
|
|
413
|
+
// In monorepos, check if the export's file is inside a workspace package that is a library
|
|
414
|
+
const isLib = lib || (mono && isFileInLibraryPackage(cwd, exp.file));
|
|
363
415
|
issues.push({
|
|
364
|
-
severity:
|
|
365
|
-
message: `orphaned export: "${exp.name}" is exported but never imported${
|
|
416
|
+
severity: isLib ? 'info' : 'warning',
|
|
417
|
+
message: `orphaned export: "${exp.name}" is exported but never imported${isLib ? ' (library detected — exports may be consumed externally)' : ''}`,
|
|
366
418
|
file: exp.file,
|
|
367
419
|
line: exp.line,
|
|
368
420
|
fixable: true,
|
|
369
|
-
fixHint:
|
|
421
|
+
fixHint: isLib ? 'may be public API — verify if still needed' : 'remove the export keyword or delete the function',
|
|
370
422
|
});
|
|
371
423
|
}
|
|
372
424
|
}
|
|
@@ -428,6 +480,10 @@ function findNamingDrift(allFuncs) {
|
|
|
428
480
|
return issues;
|
|
429
481
|
}
|
|
430
482
|
// ── Main check ───────────────────────────────────────────────────────────────
|
|
483
|
+
/** Check if a file path is in a test directory or is a test file */
|
|
484
|
+
function isInTestDir(file) {
|
|
485
|
+
return /(?:^|[/\\])(?:test|tests|__tests__)[/\\]/.test(file) || /\.(?:test|spec)\.[jt]sx?$/.test(file);
|
|
486
|
+
}
|
|
431
487
|
export async function checkDebt(cwd, ignore) {
|
|
432
488
|
const allFiles = walkFiles(cwd, ignore);
|
|
433
489
|
const sourceFiles = allFiles.filter(f => isSourceFile(f) && !isTestFile(f));
|
package/dist/checks/deps.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ export declare function extractPackageName(specifier: string): string | null;
|
|
|
5
5
|
export declare function isBuiltin(specifier: string): boolean;
|
|
6
6
|
export declare function detectWorkspacePackages(cwd: string): Set<string>;
|
|
7
7
|
export declare function detectProvidedDeps(cwd: string): Set<string>;
|
|
8
|
+
export declare function collectWorkspaceDeps(cwd: string): Set<string>;
|
|
8
9
|
export declare function checkDeps(cwd: string): Promise<CheckResult>;
|
package/dist/checks/deps.js
CHANGED
|
@@ -82,6 +82,9 @@ export function extractPackageName(specifier) {
|
|
|
82
82
|
// Skip relative imports
|
|
83
83
|
if (specifier.startsWith('.') || specifier.startsWith('/'))
|
|
84
84
|
return null;
|
|
85
|
+
// Skip URL imports
|
|
86
|
+
if (specifier.startsWith('http://') || specifier.startsWith('https://'))
|
|
87
|
+
return null;
|
|
85
88
|
// Skip node: builtins
|
|
86
89
|
if (specifier.startsWith('node:'))
|
|
87
90
|
return null;
|
|
@@ -241,7 +244,7 @@ export function detectProvidedDeps(cwd) {
|
|
|
241
244
|
if (!pkgRaw)
|
|
242
245
|
return provided;
|
|
243
246
|
const pkg = JSON.parse(pkgRaw);
|
|
244
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
247
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies, ...pkg.peerDependencies };
|
|
245
248
|
// Obsidian plugin
|
|
246
249
|
const hasObsidian = 'obsidian' in (allDeps || {});
|
|
247
250
|
const manifestPath = join(cwd, 'manifest.json');
|
|
@@ -281,19 +284,111 @@ function isProvidedPackage(pkg, provided) {
|
|
|
281
284
|
return true;
|
|
282
285
|
return false;
|
|
283
286
|
}
|
|
287
|
+
// ── Tooling packages (CLI-only, never imported in source) ────────────────────
|
|
288
|
+
const TOOLING_PACKAGES = new Set([
|
|
289
|
+
'typescript', '@types/node', '@biomejs/biome', 'biome', 'prettier', 'eslint',
|
|
290
|
+
'husky', 'lint-staged', 'tsx', 'ts-node', 'concurrently', 'npm-run-all',
|
|
291
|
+
'shx', 'rimraf', 'cross-env', 'nodemon', 'jest', 'vitest', 'mocha',
|
|
292
|
+
'c8', 'nyc', 'turbo', 'lerna', 'changesets', '@changesets/cli',
|
|
293
|
+
'webpack', 'webpack-cli', 'vite', 'rollup', 'esbuild', 'swc',
|
|
294
|
+
'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'less',
|
|
295
|
+
'commitizen', 'cz-conventional-changelog', 'semantic-release',
|
|
296
|
+
'@typescript/native-preview',
|
|
297
|
+
]);
|
|
298
|
+
// ── Collect all deps declared in workspace sub-packages ──────────────────────
|
|
299
|
+
export function collectWorkspaceDeps(cwd) {
|
|
300
|
+
const allDeps = new Set();
|
|
301
|
+
const globs = [];
|
|
302
|
+
try {
|
|
303
|
+
const pkgRaw = readFile(join(cwd, 'package.json'));
|
|
304
|
+
if (pkgRaw) {
|
|
305
|
+
const pkg = JSON.parse(pkgRaw);
|
|
306
|
+
if (Array.isArray(pkg.workspaces))
|
|
307
|
+
globs.push(...pkg.workspaces);
|
|
308
|
+
else if (pkg.workspaces?.packages)
|
|
309
|
+
globs.push(...pkg.workspaces.packages);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch { /* skip */ }
|
|
313
|
+
try {
|
|
314
|
+
const pnpmWs = readFile(join(cwd, 'pnpm-workspace.yaml'));
|
|
315
|
+
if (pnpmWs) {
|
|
316
|
+
const matches = pnpmWs.matchAll(/['"]?([^'":\n]+\*[^'":\n]*)['"]?/g);
|
|
317
|
+
for (const m of matches)
|
|
318
|
+
globs.push(m[1].trim());
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch { /* skip */ }
|
|
322
|
+
try {
|
|
323
|
+
const lernaRaw = readFile(join(cwd, 'lerna.json'));
|
|
324
|
+
if (lernaRaw) {
|
|
325
|
+
const lerna = JSON.parse(lernaRaw);
|
|
326
|
+
if (Array.isArray(lerna.packages))
|
|
327
|
+
globs.push(...lerna.packages);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch { /* skip */ }
|
|
331
|
+
function addDepsFromPkg(pkgPath) {
|
|
332
|
+
try {
|
|
333
|
+
const raw = readFile(pkgPath);
|
|
334
|
+
if (!raw)
|
|
335
|
+
return;
|
|
336
|
+
const pkg = JSON.parse(raw);
|
|
337
|
+
for (const key of Object.keys(pkg.dependencies || {}))
|
|
338
|
+
allDeps.add(key);
|
|
339
|
+
for (const key of Object.keys(pkg.devDependencies || {}))
|
|
340
|
+
allDeps.add(key);
|
|
341
|
+
for (const key of Object.keys(pkg.optionalDependencies || {}))
|
|
342
|
+
allDeps.add(key);
|
|
343
|
+
for (const key of Object.keys(pkg.peerDependencies || {}))
|
|
344
|
+
allDeps.add(key);
|
|
345
|
+
}
|
|
346
|
+
catch { /* skip */ }
|
|
347
|
+
}
|
|
348
|
+
for (const glob of globs) {
|
|
349
|
+
const parts = glob.replace(/\/$/, '').split('/');
|
|
350
|
+
const starIdx = parts.indexOf('*');
|
|
351
|
+
if (starIdx === -1) {
|
|
352
|
+
addDepsFromPkg(join(cwd, glob, 'package.json'));
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
const prefix = parts.slice(0, starIdx).join('/');
|
|
356
|
+
const prefixDir = join(cwd, prefix);
|
|
357
|
+
try {
|
|
358
|
+
if (existsSync(prefixDir) && statSync(prefixDir).isDirectory()) {
|
|
359
|
+
for (const entry of readdirSync(prefixDir)) {
|
|
360
|
+
const entryDir = join(prefixDir, entry);
|
|
361
|
+
try {
|
|
362
|
+
if (!statSync(entryDir).isDirectory())
|
|
363
|
+
continue;
|
|
364
|
+
const suffix = parts.slice(starIdx + 1);
|
|
365
|
+
const pkgDir = suffix.length > 0 ? join(entryDir, ...suffix) : entryDir;
|
|
366
|
+
addDepsFromPkg(join(pkgDir, 'package.json'));
|
|
367
|
+
}
|
|
368
|
+
catch { /* skip */ }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch { /* skip */ }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return allDeps;
|
|
376
|
+
}
|
|
284
377
|
// ── Main check ───────────────────────────────────────────────────────────────
|
|
285
378
|
export async function checkDeps(cwd) {
|
|
286
379
|
try {
|
|
287
380
|
const issues = [];
|
|
288
381
|
// Read package.json
|
|
289
382
|
let declaredDeps = {};
|
|
383
|
+
let devDeps = {};
|
|
290
384
|
let hasPkgJson = false;
|
|
291
385
|
try {
|
|
292
386
|
const pkgRaw = readFile(join(cwd, 'package.json'));
|
|
293
387
|
if (pkgRaw) {
|
|
294
388
|
const pkg = JSON.parse(pkgRaw);
|
|
295
389
|
hasPkgJson = true;
|
|
296
|
-
|
|
390
|
+
devDeps = pkg.devDependencies || {};
|
|
391
|
+
declaredDeps = { ...pkg.dependencies, ...devDeps, ...pkg.optionalDependencies, ...pkg.peerDependencies };
|
|
297
392
|
}
|
|
298
393
|
}
|
|
299
394
|
catch { /* skip */ }
|
|
@@ -382,8 +477,12 @@ export async function checkDeps(cwd) {
|
|
|
382
477
|
}
|
|
383
478
|
// Dead deps: declared but never imported
|
|
384
479
|
const declaredSet = new Set(declaredNames);
|
|
480
|
+
const devDepNames = new Set(Object.keys(devDeps));
|
|
385
481
|
for (const pkg of declaredNames) {
|
|
386
482
|
if (!importedPackages.has(pkg)) {
|
|
483
|
+
// Skip known tooling packages that are devDependencies (used via CLI scripts, not imports)
|
|
484
|
+
if (TOOLING_PACKAGES.has(pkg) && devDepNames.has(pkg))
|
|
485
|
+
continue;
|
|
387
486
|
// Check if it's a CLI tool / plugin / type package (common false positives)
|
|
388
487
|
// Still flag it, but as info
|
|
389
488
|
issues.push({
|
|
@@ -397,13 +496,17 @@ export async function checkDeps(cwd) {
|
|
|
397
496
|
}
|
|
398
497
|
// Detect workspace packages and host-provided deps
|
|
399
498
|
const workspacePackages = detectWorkspacePackages(cwd);
|
|
499
|
+
const workspaceDeps = collectWorkspaceDeps(cwd);
|
|
400
500
|
const providedDeps = detectProvidedDeps(cwd);
|
|
401
501
|
// Phantom imports: imported but not declared
|
|
402
502
|
for (const pkg of importedPackages) {
|
|
403
503
|
if (!declaredSet.has(pkg)) {
|
|
404
|
-
// Skip workspace packages
|
|
504
|
+
// Skip workspace packages (local packages in the monorepo)
|
|
405
505
|
if (workspacePackages.has(pkg))
|
|
406
506
|
continue;
|
|
507
|
+
// Skip deps declared in any workspace sub-package
|
|
508
|
+
if (workspaceDeps.has(pkg))
|
|
509
|
+
continue;
|
|
407
510
|
// Skip host-provided deps
|
|
408
511
|
if (isProvidedPackage(pkg, providedDeps))
|
|
409
512
|
continue;
|
package/dist/checks/integrity.js
CHANGED
|
@@ -66,8 +66,22 @@ function isCommentLine(line) {
|
|
|
66
66
|
function extractRelativeImports(source) {
|
|
67
67
|
const imports = [];
|
|
68
68
|
const lines = source.split('\n');
|
|
69
|
+
let inTemplateLiteral = false;
|
|
69
70
|
for (let i = 0; i < lines.length; i++) {
|
|
70
71
|
const line = lines[i];
|
|
72
|
+
// Track template literal context — check state at start of line, then update
|
|
73
|
+
const wasInTemplate = inTemplateLiteral;
|
|
74
|
+
for (let ci = 0; ci < line.length; ci++) {
|
|
75
|
+
if (line[ci] === '\\') {
|
|
76
|
+
ci++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (line[ci] === '`')
|
|
80
|
+
inTemplateLiteral = !inTemplateLiteral;
|
|
81
|
+
}
|
|
82
|
+
// Skip lines that start inside a template literal — they contain generated code, not real imports
|
|
83
|
+
if (wasInTemplate)
|
|
84
|
+
continue;
|
|
71
85
|
// Skip comment lines
|
|
72
86
|
if (isCommentLine(line))
|
|
73
87
|
continue;
|
|
@@ -153,16 +167,19 @@ function checkEmptyCatch(cwd, files) {
|
|
|
153
167
|
// Skip test files — empty catches in tests are usually intentional (testing error paths)
|
|
154
168
|
if (isTestFile(file))
|
|
155
169
|
continue;
|
|
170
|
+
// Skip example/demo directories — example code doesn't need production error handling
|
|
171
|
+
if (/(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(file))
|
|
172
|
+
continue;
|
|
156
173
|
const content = readFile(join(cwd, file));
|
|
157
174
|
if (!content)
|
|
158
175
|
continue;
|
|
159
176
|
const lines = content.split('\n');
|
|
160
177
|
for (let i = 0; i < lines.length; i++) {
|
|
161
178
|
const line = lines[i];
|
|
162
|
-
// single-line catch with param and empty body — error
|
|
179
|
+
// single-line catch with param and empty body — warning (was error, too harsh)
|
|
163
180
|
if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line)) {
|
|
164
181
|
issues.push({
|
|
165
|
-
severity: '
|
|
182
|
+
severity: 'warning',
|
|
166
183
|
message: 'empty catch block — error silently swallowed',
|
|
167
184
|
file,
|
|
168
185
|
line: i + 1,
|
|
@@ -174,7 +191,7 @@ function checkEmptyCatch(cwd, files) {
|
|
|
174
191
|
// single-line catch without param and empty body
|
|
175
192
|
if (/catch\s*\{\s*\}/.test(line)) {
|
|
176
193
|
issues.push({
|
|
177
|
-
severity: '
|
|
194
|
+
severity: 'warning',
|
|
178
195
|
message: 'empty catch block — error silently swallowed',
|
|
179
196
|
file,
|
|
180
197
|
line: i + 1,
|
|
@@ -213,9 +230,16 @@ function checkEmptyCatch(cwd, files) {
|
|
|
213
230
|
// Check if block body is only comments
|
|
214
231
|
const bodyText = blockLines.join('\n').replace(/\}$/, '').trim();
|
|
215
232
|
if (bodyText.length > 0 && /^(\s*(\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*$/.test(bodyText)) {
|
|
233
|
+
// If the comment contains TODO/FIXME/HACK/XXX/WIP/implement, keep as warning (unfinished work)
|
|
234
|
+
// TEMP only as standalone marker (not "temporary" used as adjective)
|
|
235
|
+
// Otherwise, any comment text means someone documented why it's empty → downgrade to info
|
|
236
|
+
const unfinishedRe = /\b(TODO|FIXME|HACK|XXX|WIP|implement)\b|\bTEMP\b(?!orar)/i;
|
|
237
|
+
const sev = unfinishedRe.test(bodyText) ? 'warning' : 'info';
|
|
216
238
|
issues.push({
|
|
217
|
-
severity:
|
|
218
|
-
message:
|
|
239
|
+
severity: sev,
|
|
240
|
+
message: sev === 'info'
|
|
241
|
+
? 'catch block with intentional comment — acknowledged'
|
|
242
|
+
: 'catch block contains only comments — consider proper error handling',
|
|
219
243
|
file,
|
|
220
244
|
line: i + 1,
|
|
221
245
|
fixable: false,
|
|
@@ -337,6 +361,63 @@ function hasGlobalErrorHandling(content) {
|
|
|
337
361
|
return true;
|
|
338
362
|
return false;
|
|
339
363
|
}
|
|
364
|
+
function buildFuncScopes(lines) {
|
|
365
|
+
const scopes = [];
|
|
366
|
+
// Find function start lines
|
|
367
|
+
const funcStarts = [];
|
|
368
|
+
for (let i = 0; i < lines.length; i++) {
|
|
369
|
+
const l = lines[i];
|
|
370
|
+
// function declarations and arrow functions
|
|
371
|
+
const isFuncDecl = /(?:async\s+)?function\s+\w/.test(l) && /\{/.test(l);
|
|
372
|
+
const isArrow = /=>\s*\{/.test(l);
|
|
373
|
+
const isMethod = /^\s+(?:async\s+)?\w+\s*\([^)]*\)\s*(?::\s*\S+)?\s*\{/.test(l) && !/\b(?:if|for|while|switch|catch)\b/.test(l);
|
|
374
|
+
if (isFuncDecl || isArrow || isMethod) {
|
|
375
|
+
const isExported = /^\s*export\s/.test(l) || (i > 0 && /^\s*export\s/.test(lines[i - 1]));
|
|
376
|
+
funcStarts.push({ line: i, isExported });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
for (const fs of funcStarts) {
|
|
380
|
+
// Find the opening brace on the start line
|
|
381
|
+
let braceIdx = lines[fs.line].indexOf('{');
|
|
382
|
+
if (braceIdx === -1)
|
|
383
|
+
continue;
|
|
384
|
+
let depth = 0;
|
|
385
|
+
let endLine = fs.line;
|
|
386
|
+
let hasTry = false;
|
|
387
|
+
for (let i = fs.line; i < lines.length; i++) {
|
|
388
|
+
const startJ = i === fs.line ? braceIdx : 0;
|
|
389
|
+
for (let j = startJ; j < lines[i].length; j++) {
|
|
390
|
+
if (lines[i][j] === '{')
|
|
391
|
+
depth++;
|
|
392
|
+
if (lines[i][j] === '}') {
|
|
393
|
+
depth--;
|
|
394
|
+
if (depth === 0) {
|
|
395
|
+
endLine = i;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (/\btry\s*\{/.test(lines[i]))
|
|
401
|
+
hasTry = true;
|
|
402
|
+
if (depth === 0)
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
scopes.push({ startLine: fs.line, endLine, hasTryCatch: hasTry, isExported: fs.isExported });
|
|
406
|
+
}
|
|
407
|
+
return scopes;
|
|
408
|
+
}
|
|
409
|
+
function findEnclosingFunc(scopes, lineIdx) {
|
|
410
|
+
// Find the tightest (smallest range) enclosing function
|
|
411
|
+
let best = null;
|
|
412
|
+
for (const s of scopes) {
|
|
413
|
+
if (lineIdx >= s.startLine && lineIdx <= s.endLine) {
|
|
414
|
+
if (!best || (s.endLine - s.startLine) < (best.endLine - best.startLine)) {
|
|
415
|
+
best = s;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return best;
|
|
420
|
+
}
|
|
340
421
|
function checkUnhandledAsync(cwd, files) {
|
|
341
422
|
const issues = [];
|
|
342
423
|
const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
|
|
@@ -349,6 +430,9 @@ function checkUnhandledAsync(cwd, files) {
|
|
|
349
430
|
// Skip error boundary files — they ARE the error handlers
|
|
350
431
|
if (isErrorBoundaryFile(file))
|
|
351
432
|
continue;
|
|
433
|
+
// Skip example/demo directories — example code doesn't need production error handling
|
|
434
|
+
if (/(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(file))
|
|
435
|
+
continue;
|
|
352
436
|
const content = readFile(join(cwd, file));
|
|
353
437
|
if (!content)
|
|
354
438
|
continue;
|
|
@@ -383,6 +467,8 @@ function checkUnhandledAsync(cwd, files) {
|
|
|
383
467
|
insideTry.add(i);
|
|
384
468
|
}
|
|
385
469
|
}
|
|
470
|
+
// Build function scope info for severity decisions
|
|
471
|
+
const funcScopes = buildFuncScopes(lines);
|
|
386
472
|
for (let i = 0; i < lines.length; i++) {
|
|
387
473
|
const line = lines[i];
|
|
388
474
|
// await without try/catch context — detect standalone awaits
|
|
@@ -399,11 +485,27 @@ function checkUnhandledAsync(cwd, files) {
|
|
|
399
485
|
const hasThenError = /\.then\s*\([^,]+,\s*\w+/.test(line) || (i + 1 < lines.length && /\.then\s*\([^,]+,\s*\w+/.test(lines[i + 1]));
|
|
400
486
|
if (!hasCatch && !hasThenError) {
|
|
401
487
|
unhandledCount++;
|
|
402
|
-
//
|
|
488
|
+
// Determine severity:
|
|
489
|
+
// - 'info' for Next.js server components, or functions that have try/catch elsewhere in their body
|
|
490
|
+
// - 'warning' only for exported functions with NO try/catch anywhere
|
|
491
|
+
// - 'info' for everything else (non-exported, internal functions)
|
|
403
492
|
const isServerComp = isNextjsServerComponent(file);
|
|
493
|
+
const enclosing = findEnclosingFunc(funcScopes, i);
|
|
494
|
+
const hasFuncTryCatch = enclosing?.hasTryCatch ?? false;
|
|
495
|
+
const isExported = enclosing?.isExported ?? false;
|
|
496
|
+
let severity;
|
|
497
|
+
if (isServerComp || hasFuncTryCatch) {
|
|
498
|
+
severity = 'info';
|
|
499
|
+
}
|
|
500
|
+
else if (isExported) {
|
|
501
|
+
severity = 'warning';
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
severity = 'info';
|
|
505
|
+
}
|
|
404
506
|
if (unhandledCount <= 10) {
|
|
405
507
|
issues.push({
|
|
406
|
-
severity
|
|
508
|
+
severity,
|
|
407
509
|
message: isServerComp
|
|
408
510
|
? 'unhandled async: await without try/catch (Next.js server component — framework-managed)'
|
|
409
511
|
: 'unhandled async: await without try/catch',
|
|
@@ -436,10 +538,11 @@ export async function checkIntegrity(cwd, ignore) {
|
|
|
436
538
|
let score = 100;
|
|
437
539
|
score -= hallucinatedIssues.length * 10;
|
|
438
540
|
score -= emptyCatchIssues.filter(i => i.severity === 'error').length * 8;
|
|
541
|
+
score -= emptyCatchIssues.filter(i => i.severity === 'warning').length * 3;
|
|
439
542
|
score -= stubbedTestIssues.filter(i => i.severity === 'error').length * 5;
|
|
440
|
-
// Unhandled async capped at -
|
|
441
|
-
const
|
|
442
|
-
score -= Math.min(
|
|
543
|
+
// Unhandled async capped at -15 (only count warnings, not info-downgraded ones)
|
|
544
|
+
const unhandledWarnings = unhandledAsyncIssues.filter(i => i.severity === 'warning').length;
|
|
545
|
+
score -= Math.min(15, unhandledWarnings * 3);
|
|
443
546
|
score = Math.max(0, Math.round(score));
|
|
444
547
|
// Summary parts
|
|
445
548
|
const parts = [];
|
package/dist/checks/memory.js
CHANGED
|
@@ -202,6 +202,20 @@ export function checkMemory(cwd) {
|
|
|
202
202
|
// Skip ../ references — they point to sibling repos and can't be validated locally
|
|
203
203
|
if (p.startsWith('../'))
|
|
204
204
|
continue;
|
|
205
|
+
// Skip relative paths that appear inside inline code (`...`) in markdown files
|
|
206
|
+
if (relPath.endsWith('.md') || relPath.endsWith('.mdx')) {
|
|
207
|
+
const lineText = content.split('\n')[line - 1] || '';
|
|
208
|
+
// Check if this path is inside backtick code spans (inline code examples)
|
|
209
|
+
const pathIdx = lineText.indexOf(p);
|
|
210
|
+
if (pathIdx >= 0) {
|
|
211
|
+
const before = lineText.substring(0, pathIdx);
|
|
212
|
+
const after = lineText.substring(pathIdx + p.length);
|
|
213
|
+
// Count backticks before — odd means inside inline code
|
|
214
|
+
const ticksBefore = (before.match(/`/g) || []).length;
|
|
215
|
+
if (ticksBefore % 2 === 1)
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
205
219
|
const resolved = p.startsWith('/') ? p : resolve(cwd, p);
|
|
206
220
|
if (!existsSync(resolved)) {
|
|
207
221
|
issues.push({
|
package/dist/checks/models.js
CHANGED
|
@@ -102,12 +102,16 @@ async function tryModelGraveyard(cwd) {
|
|
|
102
102
|
const aiFramework = isAiFramework(cwd);
|
|
103
103
|
// Files that define deprecated model registries should not be flagged
|
|
104
104
|
const SELF_FILES = ['models.ts', 'models.js', 'model-graveyard', 'model-registry', 'sunset', 'fix/models'];
|
|
105
|
+
const GENERATED_PATTERNS = ['.generated.', '.gen.'];
|
|
105
106
|
for (const match of report.matches) {
|
|
106
107
|
if (!match.model)
|
|
107
108
|
continue;
|
|
108
109
|
// Skip self-referencing files (model definition/fix files)
|
|
109
110
|
if (match.file && SELF_FILES.some(s => match.file.toLowerCase().includes(s)))
|
|
110
111
|
continue;
|
|
112
|
+
// Skip auto-generated model registries
|
|
113
|
+
if (match.file && GENERATED_PATTERNS.some(p => match.file.includes(p)))
|
|
114
|
+
continue;
|
|
111
115
|
if (match.model.status === 'deprecated' || match.model.status === 'eol') {
|
|
112
116
|
const inTestDocs = match.file && isTestOrDocsFile(match.file);
|
|
113
117
|
const severity = (aiFramework || inTestDocs) ? 'info' : 'error';
|
|
@@ -21,6 +21,8 @@ export function checkASI03(cwd, configFiles) {
|
|
|
21
21
|
continue;
|
|
22
22
|
const contentLower = content.toLowerCase();
|
|
23
23
|
const relPath = relative(cwd, filePath);
|
|
24
|
+
const normalizedPath = relPath.replace(/\\/g, '/');
|
|
25
|
+
const isCiFile = normalizedPath.startsWith('.github/workflows/') || normalizedPath.startsWith('.circleci/') || normalizedPath.startsWith('.gitlab-ci');
|
|
24
26
|
if (leastPrivKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
|
|
25
27
|
hasLeastPrivMention = true;
|
|
26
28
|
}
|
|
@@ -55,7 +57,7 @@ export function checkASI03(cwd, configFiles) {
|
|
|
55
57
|
deduction = Math.min(deduction + 15, 30);
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
|
-
if (sudoPattern.test(line)) {
|
|
60
|
+
if (!isCiFile && sudoPattern.test(line)) {
|
|
59
61
|
findings.push({
|
|
60
62
|
asiId: 'ASI03',
|
|
61
63
|
severity: 'warning',
|
package/dist/checks/ready.js
CHANGED
|
@@ -87,19 +87,46 @@ function builtinReady(cwd, ignore) {
|
|
|
87
87
|
issues.push({ severity: 'error', message: 'no tests — AI agents produce better code when tests exist to validate against', fixable: false });
|
|
88
88
|
}
|
|
89
89
|
let largeFileCount = 0;
|
|
90
|
+
const generatedFileRe = /(?:\.generated\.[jt]sx?$|\.gen\.[jt]s$|\.min\.[jt]s$|\.min\.css$|(?:^|[/\\])(?:generated|vendor|__generated__)[/\\])/;
|
|
91
|
+
const exampleDirRe = /(?:^|[/\\])(?:examples?|demos?|scripts?)[/\\]/;
|
|
92
|
+
const testFileRe = /(?:\.(?:test|spec)\.[jt]sx?$|(?:^|[/\\])(?:test|tests|__tests__)[/\\])/;
|
|
93
|
+
const largeFileIssues = [];
|
|
90
94
|
for (const f of files) {
|
|
91
95
|
if (!codeExts.some(ext => f.endsWith(ext)))
|
|
92
96
|
continue;
|
|
97
|
+
// Skip generated/vendored/minified files
|
|
98
|
+
if (generatedFileRe.test(f))
|
|
99
|
+
continue;
|
|
100
|
+
// Bug 5: Skip files in examples/demo directories
|
|
101
|
+
if (exampleDirRe.test(f))
|
|
102
|
+
continue;
|
|
93
103
|
const content = readFile(join(cwd, f));
|
|
94
|
-
if (content
|
|
104
|
+
if (!content)
|
|
105
|
+
continue;
|
|
106
|
+
const lineCount = content.split('\n').length;
|
|
107
|
+
// Bug 3: Higher threshold for test files (1000 vs 500)
|
|
108
|
+
const threshold = testFileRe.test(f) ? 1000 : 500;
|
|
109
|
+
if (lineCount > threshold) {
|
|
95
110
|
largeFileCount++;
|
|
96
|
-
|
|
97
|
-
issues.push({ severity: 'warning', message: `${f} is ${content.split('\n').length} lines — split for better AI comprehension`, fixable: false });
|
|
98
|
-
}
|
|
111
|
+
largeFileIssues.push({ severity: 'warning', message: `${f} is ${lineCount} lines — split for better AI comprehension`, fixable: false });
|
|
99
112
|
}
|
|
100
113
|
}
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
// Bug 4: Cap large file penalty for monorepos
|
|
115
|
+
if (isMonorepo && largeFileIssues.length > 10) {
|
|
116
|
+
// First 10 stay as warnings, rest downgraded to info
|
|
117
|
+
for (let i = 10; i < largeFileIssues.length; i++) {
|
|
118
|
+
largeFileIssues[i].severity = 'info';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Add issues (show first 3 inline, rest as summary)
|
|
122
|
+
for (let i = 0; i < Math.min(3, largeFileIssues.length); i++) {
|
|
123
|
+
issues.push(largeFileIssues[i]);
|
|
124
|
+
}
|
|
125
|
+
if (largeFileIssues.length > 3) {
|
|
126
|
+
// Add remaining issues individually (for JSON output) but summarize in display
|
|
127
|
+
for (let i = 3; i < largeFileIssues.length; i++) {
|
|
128
|
+
issues.push(largeFileIssues[i]);
|
|
129
|
+
}
|
|
103
130
|
}
|
|
104
131
|
const hasEnv = files.some(f => f === '.env' || f === '.env.local');
|
|
105
132
|
const hasEnvExample = files.some(f => f === '.env.example' || f === '.env.template');
|
package/dist/checks/scan.js
CHANGED
|
@@ -170,6 +170,9 @@ function scanContent(content, relPath) {
|
|
|
170
170
|
// Skip command-substitution checks in workflow files (shell commands are expected)
|
|
171
171
|
if (pattern.id === 'command-substitution' && isWorkflow)
|
|
172
172
|
continue;
|
|
173
|
+
// Skip permission-escalation in CI workflow files (sudo apt-get etc. is standard)
|
|
174
|
+
if (pattern.id === 'permission-escalation' && isWorkflow)
|
|
175
|
+
continue;
|
|
173
176
|
// Skip command-substitution in markdown code contexts
|
|
174
177
|
if (pattern.id === 'command-substitution' && relPath.endsWith('.md') && isInCodeContext(lines, i))
|
|
175
178
|
continue;
|
package/dist/checks/tests.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
1
|
+
import { join, basename } from 'node:path';
|
|
2
2
|
import { walkFiles } from '../util.js';
|
|
3
3
|
import { cachedRead } from '../file-cache.js';
|
|
4
4
|
const TEST_FILE_RE = /\.(test|spec)\.(ts|js|tsx|jsx)$/;
|
|
@@ -6,6 +6,22 @@ const TEST_DIR_RE = /(?:^|[/\\])(__tests__|tests?)[/\\]/;
|
|
|
6
6
|
function isTestFile(relPath) {
|
|
7
7
|
return TEST_FILE_RE.test(relPath) || TEST_DIR_RE.test(relPath);
|
|
8
8
|
}
|
|
9
|
+
/** Test utility/helper file patterns — these export helpers, not actual tests */
|
|
10
|
+
const TEST_UTILITY_NAMES = /(?:^|[/\\])(?:util(?:itie)?s?|helpers?|fixtures?|mocks?|setup|factor(?:y|ies)|themes?|test-(?:utils?|helpers?|setup|fixtures?|mocks?|themes?))\.[jt]sx?$/i;
|
|
11
|
+
function isTestUtilityFile(relPath, content) {
|
|
12
|
+
const hasTestCalls = /\b(?:test|it|describe|Deno\.test)\s*\(/.test(content);
|
|
13
|
+
// Check filename pattern — but only if no test runner calls present
|
|
14
|
+
const base = basename(relPath);
|
|
15
|
+
if (TEST_UTILITY_NAMES.test(base) && !hasTestCalls)
|
|
16
|
+
return true;
|
|
17
|
+
// If in a test dir, has exports, but no test runner calls — it's a utility
|
|
18
|
+
if (TEST_DIR_RE.test(relPath)) {
|
|
19
|
+
const hasExports = /\bexport\s+(function|const|let|var|class|default|{)/.test(content);
|
|
20
|
+
if (hasExports && !hasTestCalls)
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
9
25
|
// Pattern 1: Tautological assertions
|
|
10
26
|
function findTautological(lines, file) {
|
|
11
27
|
const issues = [];
|
|
@@ -119,6 +135,11 @@ function findZeroAssertionTests(content, file) {
|
|
|
119
135
|
// Check for assertion calls
|
|
120
136
|
const assertionRe = /(?:expect\s*\(|assert\.|\.should\.|toBe\s*\(|toEqual\s*\(|toMatch\s*\(|toThrow\s*\()/;
|
|
121
137
|
if (!assertionRe.test(body)) {
|
|
138
|
+
// If every non-empty, non-comment statement is a function call, it's delegating to a helper
|
|
139
|
+
const stmts = stripped.split(/;\s*|\n/).map(s => s.trim()).filter(s => s && !s.startsWith('//'));
|
|
140
|
+
const delegatingRe = /^(await\s+)?[a-zA-Z_$][a-zA-Z0-9_$.]*\s*\(/;
|
|
141
|
+
if (stmts.length > 0 && stmts.length <= 3 && stmts.every(s => delegatingRe.test(s)))
|
|
142
|
+
continue;
|
|
122
143
|
const line = content.substring(0, m.index).split('\n').length;
|
|
123
144
|
issues.push({
|
|
124
145
|
severity: 'warning',
|
|
@@ -213,6 +234,9 @@ export function checkTests(cwd, ignore) {
|
|
|
213
234
|
// Skip files with vet-ignore: tests directive
|
|
214
235
|
if (hasVetIgnore(content, 'tests'))
|
|
215
236
|
continue;
|
|
237
|
+
// Skip test utility/helper files — they export helpers, not tests
|
|
238
|
+
if (isTestUtilityFile(rel, content))
|
|
239
|
+
continue;
|
|
216
240
|
const lines = content.split('\n');
|
|
217
241
|
issues.push(...findTautological(lines, rel));
|
|
218
242
|
issues.push(...findEmptyBodies(content, rel));
|
package/dist/checks/verify.js
CHANGED
|
@@ -94,6 +94,31 @@ function isTestFile(filePath) {
|
|
|
94
94
|
return true;
|
|
95
95
|
return false;
|
|
96
96
|
}
|
|
97
|
+
/** Test utility/helper file patterns — these export helpers, not actual tests */
|
|
98
|
+
const TEST_UTILITY_NAMES = /(?:^|[/\\])(?:util(?:itie)?s?|helpers?|fixtures?|mocks?|setup|factor(?:y|ies)|themes?|test-(?:utils?|helpers?|setup|fixtures?|mocks?|themes?))\.[jt]sx?$/i;
|
|
99
|
+
const TEST_DIR_PATTERN = /(?:^|[/\\])(__tests__|tests?)[/\\]/;
|
|
100
|
+
function isTestUtilityFile(filePath, content) {
|
|
101
|
+
const hasTestCalls = /\b(?:test|it|describe|Deno\.test)\s*\(/.test(content);
|
|
102
|
+
const base = basename(filePath);
|
|
103
|
+
if (TEST_UTILITY_NAMES.test(base) && !hasTestCalls)
|
|
104
|
+
return true;
|
|
105
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
106
|
+
if (TEST_DIR_PATTERN.test(normalized) || normalized.startsWith('test/') || normalized.startsWith('tests/')) {
|
|
107
|
+
const hasExports = /\bexport\s+(function|const|let|var|class|default|{)/.test(content);
|
|
108
|
+
// Files with exports but no test calls are utilities
|
|
109
|
+
if (hasExports && !hasTestCalls)
|
|
110
|
+
return true;
|
|
111
|
+
// Files with no exports AND no test calls but with actual code (imports, function defs)
|
|
112
|
+
// are standalone scripts (debug, examples, repros) — not test files
|
|
113
|
+
if (!hasExports && !hasTestCalls) {
|
|
114
|
+
const hasImports = /\bimport\s/.test(content);
|
|
115
|
+
const hasFunctions = /\b(?:function|class|const\s+\w+\s*=\s*(?:async\s+)?(?:\(|[a-z]))/i.test(content);
|
|
116
|
+
if (hasImports || hasFunctions)
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
97
122
|
function hasAssertions(content) {
|
|
98
123
|
return /\b(assert|expect\s*\(|it\s*\(|test\s*\(|describe\s*\(|should\.|toBe\(|toEqual\(|assertEqual|assertStrictEqual)\b/i.test(content);
|
|
99
124
|
}
|
|
@@ -322,6 +347,32 @@ export function checkVerify(cwd, since) {
|
|
|
322
347
|
verified++;
|
|
323
348
|
continue;
|
|
324
349
|
}
|
|
350
|
+
// Skip vendor, minified, dist, and build files
|
|
351
|
+
if (/(?:^|[/\\])vendor[/\\]/.test(relPath) || /\.min\.(js|css|mjs)$/.test(relPath) || /(?:^|[/\\])(?:dist|build)[/\\]/.test(relPath)) {
|
|
352
|
+
verified++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// Skip re-export barrel files (all non-empty lines are re-exports or wrappers)
|
|
356
|
+
if (lineCount > 0 && lineCount < 10) {
|
|
357
|
+
const nonEmptyLines = content.split('\n').filter((l) => l.trim() && !l.trim().startsWith('//') && !l.trim().startsWith('/*') && !l.trim().startsWith('*'));
|
|
358
|
+
const reExportRe = /^\s*(?:export\s+\*\s+from\s|export\s*\{[^}]*\}\s*from\s|module\.exports\s*=\s*require\s*\()/;
|
|
359
|
+
if (nonEmptyLines.length > 0 && nonEmptyLines.every((l) => reExportRe.test(l))) {
|
|
360
|
+
verified++;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
// Also skip import-and-reexport wrappers: files with only imports, exports, and simple identifiers/braces
|
|
364
|
+
const wrapperRe = /^\s*(?:import\s|export\s|[a-zA-Z_$][a-zA-Z0-9_$]*\s*,?\s*$|\}\s*;?\s*$|\};?\s*$)/;
|
|
365
|
+
if (nonEmptyLines.length > 0 && nonEmptyLines.some((l) => /^\s*import\s/.test(l)) && nonEmptyLines.some((l) => /^\s*export\s/.test(l)) && nonEmptyLines.every((l) => wrapperRe.test(l))) {
|
|
366
|
+
verified++;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Skip thin file check for entry point scripts in scripts/ directory
|
|
371
|
+
const normalizedForScripts = relPath.replace(/\\/g, '/');
|
|
372
|
+
if (/(?:^|[/\\])scripts[/\\]/.test(relPath) || normalizedForScripts.startsWith('scripts/') || normalizedForScripts.includes('/scripts/')) {
|
|
373
|
+
verified++;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
325
376
|
if (lineCount < 10 && lineCount > 0) {
|
|
326
377
|
issues.push({
|
|
327
378
|
severity: 'warning',
|
|
@@ -346,8 +397,8 @@ export function checkVerify(cwd, since) {
|
|
|
346
397
|
failed++;
|
|
347
398
|
continue;
|
|
348
399
|
}
|
|
349
|
-
// 3. Test files must have actual assertions (but not config files)
|
|
350
|
-
if (isTestFile(relPath) && !isConfigFile(relPath)) {
|
|
400
|
+
// 3. Test files must have actual assertions (but not config files, not utility files)
|
|
401
|
+
if (isTestFile(relPath) && !isConfigFile(relPath) && !isTestUtilityFile(relPath, content)) {
|
|
351
402
|
if (!hasAssertions(content)) {
|
|
352
403
|
issues.push({
|
|
353
404
|
severity: 'error',
|