@safetnsr/vet 1.16.0 → 1.17.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 +121 -16
- package/dist/checks/deps.js +17 -0
- package/package.json +1 -1
package/dist/checks/debt.js
CHANGED
|
@@ -8,12 +8,45 @@ function isSourceFile(f) {
|
|
|
8
8
|
return dot !== -1 && SOURCE_EXTS.has(f.substring(dot));
|
|
9
9
|
}
|
|
10
10
|
function isTestFile(f) {
|
|
11
|
-
return /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') ||
|
|
11
|
+
return /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || /(?:^|[/\\])tests?[/\\]/.test(f);
|
|
12
12
|
}
|
|
13
13
|
function isEntryFile(f) {
|
|
14
14
|
const b = basename(f);
|
|
15
15
|
return /^(cli|main|index)\.[jt]sx?$/.test(b);
|
|
16
16
|
}
|
|
17
|
+
// Next.js / Remix / SvelteKit / Nuxt convention exports consumed by the framework, not via imports
|
|
18
|
+
const FRAMEWORK_CONVENTION_EXPORTS = new Set([
|
|
19
|
+
// Next.js App Router
|
|
20
|
+
'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS',
|
|
21
|
+
'metadata', 'generateMetadata', 'generateStaticParams', 'generateViewport',
|
|
22
|
+
'viewport', 'runtime', 'revalidate', 'dynamic', 'dynamicParams',
|
|
23
|
+
'fetchCache', 'preferredRegion', 'maxDuration',
|
|
24
|
+
'default', // default export in page/layout/route files
|
|
25
|
+
// Next.js Pages Router
|
|
26
|
+
'getServerSideProps', 'getStaticProps', 'getStaticPaths',
|
|
27
|
+
// Remix
|
|
28
|
+
'loader', 'action', 'meta', 'links', 'headers', 'handle',
|
|
29
|
+
'shouldRevalidate', 'ErrorBoundary', 'HydrateFallback',
|
|
30
|
+
// SvelteKit
|
|
31
|
+
'load', 'prerender', 'ssr', 'csr', 'trailingSlash',
|
|
32
|
+
// Nuxt
|
|
33
|
+
'definePageMeta', 'useHead',
|
|
34
|
+
]);
|
|
35
|
+
function isFrameworkConventionFile(file) {
|
|
36
|
+
// Next.js app router: app/**/page.tsx, layout.tsx, route.tsx, loading.tsx, error.tsx, etc.
|
|
37
|
+
if (/\/(app|pages)\//.test(file) && /\/(page|layout|route|loading|error|not-found|template|default|middleware)\.[jt]sx?$/.test(file))
|
|
38
|
+
return true;
|
|
39
|
+
// Next.js API routes
|
|
40
|
+
if (/\/api\//.test(file) && /\/route\.[jt]sx?$/.test(file))
|
|
41
|
+
return true;
|
|
42
|
+
// Remix routes
|
|
43
|
+
if (/\/routes\//.test(file) && /\.[jt]sx?$/.test(file))
|
|
44
|
+
return true;
|
|
45
|
+
// SvelteKit
|
|
46
|
+
if (/\+(page|layout|server|error)\.[jt]s/.test(file))
|
|
47
|
+
return true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
17
50
|
function isBarrelFile(f) {
|
|
18
51
|
const b = basename(f);
|
|
19
52
|
return /^index\.[jt]sx?$/.test(b);
|
|
@@ -236,14 +269,14 @@ function findDuplicates(allFuncs) {
|
|
|
236
269
|
// Downgrade to info if all functions in the group are in test directories
|
|
237
270
|
// or if any function is in an examples/demo directory
|
|
238
271
|
const allInTest = group.every(f => isInTestDir(f.file));
|
|
239
|
-
const anyInExample = group.some(f => /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(f.file));
|
|
272
|
+
const anyInExample = group.some(f => /(?:^|[/\\])(?:examples?|demos?|templates?|fixtures?)[/\\]/.test(f.file));
|
|
240
273
|
issues.push({
|
|
241
274
|
severity: (allInTest || anyInExample) ? 'info' : 'warning',
|
|
242
275
|
message: `near-duplicate functions: ${locations}`,
|
|
243
276
|
file: group[0].file,
|
|
244
277
|
line: group[0].line,
|
|
245
|
-
fixable:
|
|
246
|
-
fixHint: 'extract shared logic into a single function',
|
|
278
|
+
fixable: !(allInTest || anyInExample),
|
|
279
|
+
fixHint: (allInTest || anyInExample) ? 'duplication in examples/tests is often intentional' : 'extract shared logic into a single function',
|
|
247
280
|
});
|
|
248
281
|
}
|
|
249
282
|
// Similarity check for non-exact matches — length-bucketed to avoid O(n²) explosion
|
|
@@ -275,14 +308,14 @@ function findDuplicates(allFuncs) {
|
|
|
275
308
|
// Downgrade to info if both functions are in test directories
|
|
276
309
|
// or if either is in an examples/demo directory
|
|
277
310
|
const bothInTest = isInTestDir(a.file) && isInTestDir(b.file);
|
|
278
|
-
const anyInExample = /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(a.file) || /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(b.file);
|
|
311
|
+
const anyInExample = /(?:^|[/\\])(?:examples?|demos?|templates?|fixtures?)[/\\]/.test(a.file) || /(?:^|[/\\])(?:examples?|demos?|templates?|fixtures?)[/\\]/.test(b.file);
|
|
279
312
|
issues.push({
|
|
280
313
|
severity: (bothInTest || anyInExample) ? 'info' : 'warning',
|
|
281
314
|
message: `similar functions (${Math.round(sim * 100)}%): ${a.name} (${a.file}:${a.line}) and ${b.name} (${b.file}:${b.line})`,
|
|
282
315
|
file: a.file,
|
|
283
316
|
line: a.line,
|
|
284
|
-
fixable:
|
|
285
|
-
fixHint: 'consider merging or extracting shared logic',
|
|
317
|
+
fixable: !(bothInTest || anyInExample),
|
|
318
|
+
fixHint: (bothInTest || anyInExample) ? 'duplication in examples/tests is often intentional' : 'consider merging or extracting shared logic',
|
|
286
319
|
});
|
|
287
320
|
}
|
|
288
321
|
}
|
|
@@ -373,26 +406,38 @@ function findOrphanedExports(cwd, files) {
|
|
|
373
406
|
continue;
|
|
374
407
|
const braceMatch = line.match(/^export\s*\{([^}]+)\}/);
|
|
375
408
|
if (braceMatch) {
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
if (
|
|
409
|
+
for (const part of braceMatch[1].split(',')) {
|
|
410
|
+
const trimmed = part.trim();
|
|
411
|
+
if (!trimmed)
|
|
379
412
|
continue;
|
|
380
|
-
|
|
413
|
+
// export { x as y } — use the alias (y) as the exported name, since that's what consumers see
|
|
414
|
+
const asParts = trimmed.split(/\s+as\s+/);
|
|
415
|
+
const exportedName = (asParts.length > 1 ? asParts[1] : asParts[0]).trim();
|
|
416
|
+
if (exportedName === 'default' || exportedName === 'type')
|
|
417
|
+
continue;
|
|
418
|
+
exports.push({ name: exportedName, file, line: i + 1 });
|
|
381
419
|
}
|
|
382
420
|
}
|
|
383
421
|
}
|
|
384
422
|
}
|
|
385
|
-
// Scan
|
|
423
|
+
// Scan ALL files (including tests) for import names — an export consumed by a test is not orphaned
|
|
386
424
|
const importedNames = new Set();
|
|
387
425
|
const importRe = /import\s+(?:type\s+)?(?:\{([^}]+)\}|([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*,\s*\{([^}]+)\})?)\s+from\s+/g;
|
|
388
|
-
for
|
|
426
|
+
// Also scan for dynamic imports: require('x'), import('x') — to catch non-static usage
|
|
427
|
+
const dynamicImportRe = /(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
428
|
+
const allSourceFiles = files.filter(f => isSourceFile(f));
|
|
429
|
+
// Track which files are re-exported via `export * from './x'`
|
|
430
|
+
const reExportedFiles = new Set();
|
|
431
|
+
const exportFromRe = /export\s+(?:type\s+)?\*\s+from\s+['"]([^'"]+)['"]/g;
|
|
432
|
+
const exportNamedFromRe = /export\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g;
|
|
433
|
+
for (const file of allSourceFiles) {
|
|
389
434
|
const content = readFile(join(cwd, file));
|
|
390
435
|
if (!content)
|
|
391
436
|
continue;
|
|
392
437
|
let match;
|
|
438
|
+
// Standard imports
|
|
393
439
|
importRe.lastIndex = 0;
|
|
394
440
|
while ((match = importRe.exec(content)) !== null) {
|
|
395
|
-
// Named imports: { a, b as c }
|
|
396
441
|
const namedParts = [match[1], match[3]].filter(Boolean);
|
|
397
442
|
for (const part of namedParts) {
|
|
398
443
|
for (const name of part.split(',')) {
|
|
@@ -401,15 +446,75 @@ function findOrphanedExports(cwd, files) {
|
|
|
401
446
|
importedNames.add(trimmed);
|
|
402
447
|
}
|
|
403
448
|
}
|
|
404
|
-
// Default import
|
|
405
449
|
if (match[2])
|
|
406
450
|
importedNames.add(match[2]);
|
|
407
451
|
}
|
|
452
|
+
// `export * from './module'` — all exports from that module are consumed
|
|
453
|
+
exportFromRe.lastIndex = 0;
|
|
454
|
+
while ((match = exportFromRe.exec(content)) !== null) {
|
|
455
|
+
// Resolve the re-exported file relative to current file
|
|
456
|
+
const specifier = match[1];
|
|
457
|
+
if (specifier.startsWith('.')) {
|
|
458
|
+
const dir = dirname(file);
|
|
459
|
+
const candidates = [
|
|
460
|
+
join(dir, specifier),
|
|
461
|
+
join(dir, specifier + '.ts'), join(dir, specifier + '.tsx'),
|
|
462
|
+
join(dir, specifier + '.js'), join(dir, specifier + '.jsx'),
|
|
463
|
+
join(dir, specifier, 'index.ts'), join(dir, specifier, 'index.tsx'),
|
|
464
|
+
join(dir, specifier, 'index.js'),
|
|
465
|
+
];
|
|
466
|
+
for (const c of candidates) {
|
|
467
|
+
const normalized = c.replace(/\\/g, '/');
|
|
468
|
+
reExportedFiles.add(normalized);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// `export { name } from './module'` — named re-exports count as imports
|
|
473
|
+
exportNamedFromRe.lastIndex = 0;
|
|
474
|
+
while ((match = exportNamedFromRe.exec(content)) !== null) {
|
|
475
|
+
for (const name of match[1].split(',')) {
|
|
476
|
+
const trimmed = name.trim().split(/\s+as\s+/)[0].trim();
|
|
477
|
+
if (trimmed)
|
|
478
|
+
importedNames.add(trimmed);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Build a cross-reference map: for each exported name, check if it appears in other files
|
|
483
|
+
// This catches hook returns ({ Component } = useHook()), dynamic usage, re-exports, JSX, etc.
|
|
484
|
+
// Only build refs for names we actually export (not all identifiers — too expensive)
|
|
485
|
+
const exportNames = new Set(exports.map(e => e.name));
|
|
486
|
+
const nameToFiles = new Map();
|
|
487
|
+
for (const name of exportNames)
|
|
488
|
+
nameToFiles.set(name, new Set());
|
|
489
|
+
for (const file of allSourceFiles) {
|
|
490
|
+
const content = readFile(join(cwd, file));
|
|
491
|
+
if (!content)
|
|
492
|
+
continue;
|
|
493
|
+
for (const name of exportNames) {
|
|
494
|
+
if (content.includes(name)) {
|
|
495
|
+
nameToFiles.get(name).add(file);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
408
498
|
}
|
|
409
499
|
const lib = isLibrary(cwd);
|
|
410
500
|
const mono = isMonorepo(cwd);
|
|
411
501
|
for (const exp of exports) {
|
|
412
502
|
if (!importedNames.has(exp.name)) {
|
|
503
|
+
// Skip if the file is re-exported via `export * from './file'`
|
|
504
|
+
const normalizedFile = exp.file.replace(/\\/g, '/');
|
|
505
|
+
if (reExportedFiles.has(normalizedFile))
|
|
506
|
+
continue;
|
|
507
|
+
// Cross-reference check: if the export name appears in a different file, it's likely used
|
|
508
|
+
const refs = nameToFiles.get(exp.name);
|
|
509
|
+
if (refs) {
|
|
510
|
+
const otherFiles = new Set(refs);
|
|
511
|
+
otherFiles.delete(exp.file);
|
|
512
|
+
if (otherFiles.size > 0)
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
// Skip framework convention exports (Next.js, Remix, SvelteKit, Nuxt)
|
|
516
|
+
if (FRAMEWORK_CONVENTION_EXPORTS.has(exp.name) && isFrameworkConventionFile(exp.file))
|
|
517
|
+
continue;
|
|
413
518
|
// In monorepos, check if the export's file is inside a workspace package that is a library
|
|
414
519
|
const isLib = lib || (mono && isFileInLibraryPackage(cwd, exp.file));
|
|
415
520
|
issues.push({
|
|
@@ -417,7 +522,7 @@ function findOrphanedExports(cwd, files) {
|
|
|
417
522
|
message: `orphaned export: "${exp.name}" is exported but never imported${isLib ? ' (library detected — exports may be consumed externally)' : ''}`,
|
|
418
523
|
file: exp.file,
|
|
419
524
|
line: exp.line,
|
|
420
|
-
fixable:
|
|
525
|
+
fixable: !isLib,
|
|
421
526
|
fixHint: isLib ? 'may be public API — verify if still needed' : 'remove the export keyword or delete the function',
|
|
422
527
|
});
|
|
423
528
|
}
|
package/dist/checks/deps.js
CHANGED
|
@@ -290,10 +290,27 @@ const TOOLING_PACKAGES = new Set([
|
|
|
290
290
|
'husky', 'lint-staged', 'tsx', 'ts-node', 'concurrently', 'npm-run-all',
|
|
291
291
|
'shx', 'rimraf', 'cross-env', 'nodemon', 'jest', 'vitest', 'mocha',
|
|
292
292
|
'c8', 'nyc', 'turbo', 'lerna', 'changesets', '@changesets/cli',
|
|
293
|
+
'@changesets/changelog-github', '@changesets/changelog-git',
|
|
293
294
|
'webpack', 'webpack-cli', 'vite', 'rollup', 'esbuild', 'swc',
|
|
294
295
|
'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'less',
|
|
295
296
|
'commitizen', 'cz-conventional-changelog', 'semantic-release',
|
|
296
297
|
'@typescript/native-preview',
|
|
298
|
+
// Linting configs (used via eslint extends, not imported)
|
|
299
|
+
'eslint-config-next', 'eslint-config-prettier', 'eslint-config-turbo',
|
|
300
|
+
'@next/eslint-plugin-next', 'eslint-plugin-react', 'eslint-plugin-react-hooks',
|
|
301
|
+
'eslint-plugin-import', 'eslint-plugin-jsx-a11y', 'eslint-plugin-tailwindcss',
|
|
302
|
+
// Commit/release tooling
|
|
303
|
+
'@commitlint/cli', '@commitlint/config-conventional',
|
|
304
|
+
'standard-version', 'release-it', 'np',
|
|
305
|
+
// Test utilities (used as test runner plugins/reporters)
|
|
306
|
+
'chai', 'sinon', 'supertest', 'nock', '@testing-library/react',
|
|
307
|
+
'@testing-library/jest-dom', '@testing-library/user-event',
|
|
308
|
+
'ts-jest', '@swc/jest', 'babel-jest',
|
|
309
|
+
// Build plugins (used via config files, not imported)
|
|
310
|
+
'@vitejs/plugin-react', '@sveltejs/adapter-auto', '@sveltejs/kit',
|
|
311
|
+
'del-cli', 'make-node',
|
|
312
|
+
// Type packages (consumed by TS compiler, not imported)
|
|
313
|
+
'@types/react', '@types/react-dom', '@types/jest', '@types/mocha',
|
|
297
314
|
]);
|
|
298
315
|
// ── Collect all deps declared in workspace sub-packages ──────────────────────
|
|
299
316
|
export function collectWorkspaceDeps(cwd) {
|