@safetnsr/vet 1.16.0 → 1.17.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.
@@ -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__') || f.startsWith('test/') || f.startsWith('test\\');
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: true,
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: true,
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,19 +406,27 @@ function findOrphanedExports(cwd, files) {
373
406
  continue;
374
407
  const braceMatch = line.match(/^export\s*\{([^}]+)\}/);
375
408
  if (braceMatch) {
376
- const names = braceMatch[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
377
- for (const name of names) {
378
- if (name === 'default' || name === 'type')
409
+ for (const part of braceMatch[1].split(',')) {
410
+ const trimmed = part.trim();
411
+ if (!trimmed)
412
+ continue;
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')
379
417
  continue;
380
- exports.push({ name, file, line: i + 1 });
418
+ exports.push({ name: exportedName, file, line: i + 1 });
381
419
  }
382
420
  }
383
421
  }
384
422
  }
385
- // Scan all files for import names — collect all imported identifiers into a Set
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 (const file of sourceFiles) {
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
+ for (const file of allSourceFiles) {
389
430
  const content = readFile(join(cwd, file));
390
431
  if (!content)
391
432
  continue;
@@ -406,10 +447,39 @@ function findOrphanedExports(cwd, files) {
406
447
  importedNames.add(match[2]);
407
448
  }
408
449
  }
450
+ // Build a cross-reference map: for each exported name, check if it appears in other files
451
+ // This catches hook returns ({ Component } = useHook()), dynamic usage, re-exports, JSX, etc.
452
+ // Only build refs for names we actually export (not all identifiers — too expensive)
453
+ const exportNames = new Set(exports.map(e => e.name));
454
+ const nameToFiles = new Map();
455
+ for (const name of exportNames)
456
+ nameToFiles.set(name, new Set());
457
+ for (const file of allSourceFiles) {
458
+ const content = readFile(join(cwd, file));
459
+ if (!content)
460
+ continue;
461
+ for (const name of exportNames) {
462
+ if (content.includes(name)) {
463
+ nameToFiles.get(name).add(file);
464
+ }
465
+ }
466
+ }
409
467
  const lib = isLibrary(cwd);
410
468
  const mono = isMonorepo(cwd);
411
469
  for (const exp of exports) {
412
470
  if (!importedNames.has(exp.name)) {
471
+ // Cross-reference check: if the export name appears in a different file, it's likely used
472
+ // (catches hook returns, JSX usage, dynamic imports, re-exports)
473
+ const refs = nameToFiles.get(exp.name);
474
+ if (refs) {
475
+ const otherFiles = new Set(refs);
476
+ otherFiles.delete(exp.file);
477
+ if (otherFiles.size > 0)
478
+ continue; // referenced in another file → not orphaned
479
+ }
480
+ // Skip framework convention exports (Next.js, Remix, SvelteKit, Nuxt)
481
+ if (FRAMEWORK_CONVENTION_EXPORTS.has(exp.name) && isFrameworkConventionFile(exp.file))
482
+ continue;
413
483
  // In monorepos, check if the export's file is inside a workspace package that is a library
414
484
  const isLib = lib || (mono && isFileInLibraryPackage(cwd, exp.file));
415
485
  issues.push({
@@ -417,7 +487,7 @@ function findOrphanedExports(cwd, files) {
417
487
  message: `orphaned export: "${exp.name}" is exported but never imported${isLib ? ' (library detected — exports may be consumed externally)' : ''}`,
418
488
  file: exp.file,
419
489
  line: exp.line,
420
- fixable: true,
490
+ fixable: !isLib,
421
491
  fixHint: isLib ? 'may be public API — verify if still needed' : 'remove the export keyword or delete the function',
422
492
  });
423
493
  }
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {